trypost/tests/Feature/BackfillMcpOAuthWorkspaceTest.php

207 lines
7.3 KiB
PHP
Raw Normal View History

Scope MCP OAuth tokens to user + workspace (#222) (#245) * Scope MCP OAuth tokens to user + workspace Bind authorization-code grants to the authorizing workspace (via auth codes), inherit workspace on refresh, resolve MCP/API requests from the token instead of current_workspace_id, backfill existing grants, and revoke workspace tokens when a member is removed. Co-authored-by: Cursor <cursoragent@cursor.com> * Add multi-workspace MCP OAuth coverage Cover coexistence of the same client across workspaces, settings list/disconnect scoped to the current workspace, and API key controllers excluding workspace-bound MCP grants. Co-authored-by: Cursor <cursoragent@cursor.com> * Use constrained foreignUuid for oauth_auth_codes.workspace_id Match the project's UUID foreign-key convention instead of a separate foreign() call. Co-authored-by: Cursor <cursoragent@cursor.com> * Localize the MCP OAuth authorize consent screen Wire authorize.blade.php to mcp.* translation keys (including the workspace scope copy) and cover pt-BR rendering. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix invalid Mockery import in bind workspace test CI treats the non-compound `use Mockery` as an ErrorException and aborts the whole parallel suite. Co-authored-by: Cursor <cursoragent@cursor.com> * Inline MCP OAuth workspace backfill into the migration Move the one-shot backfill out of a dedicated Action and wrap it in an explicit transaction so a failure rolls back partial binds/revokes. Co-authored-by: Cursor <cursoragent@cursor.com> * Nest MCP authorize i18n keys and test backfill rollback Group consent-screen copy under mcp.authorize.*, and assert the workspace backfill migration rolls back binds when it fails before commit. Co-authored-by: Cursor <cursoragent@cursor.com> * Hardcode TryPost in the MCP authorize page title Drop the config('app.name') interpolation from the consent screen title. Co-authored-by: Cursor <cursoragent@cursor.com> * Add workspace picker to MCP OAuth consent screen Let users choose which workspace to bind at authorize time instead of always using current_workspace_id; silent re-consent still falls back. Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten MCP authorize workspace select spacing Match NativeSelect styling and give the label, control, and helper text room to breathe. Co-authored-by: Cursor <cursoragent@cursor.com> * Convert MCP OAuth consent screen to Inertia Vue Reuse AuthCardLayout, Button, and NativeSelect so the authorize page matches the app UI. Keep native form posts so Passport's external redirect still works for MCP client popups. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP authorize layout with logo and workspace combobox Drop the shield and AuthCardLayout double-logo, put TryPost branding at the top, and reuse the app Combobox pattern for workspace search. Co-authored-by: Cursor <cursoragent@cursor.com> * Align MCP OAuth workspace backfill with mcpOAuth scope Reuse AccessToken::mcpOAuth() so the migration only touches mcp:use grants on non-PAT clients, matching the rest of the codebase. Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten MCP OAuth workspace backfill heuristics Only touch connected MCP sessions, bind a sole membership or a valid current workspace, and revoke ambiguous multi-workspace grants instead of guessing the oldest workspace. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop Passport connection override from auth code migration Always use the app default database connection from .env. Co-authored-by: Cursor <cursoragent@cursor.com> * Bind MCP OAuth workspace in AccessTokenRepository Replace the AccessTokenCreated listener with the same Passport repository override pattern used for auth codes, so workspace_id is set at persist. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify AccessTokenRepository workspace binding Drop redundant string casts and the oldest-workspace fallback; keep a small ownedWorkspace/payloadId helper surface instead. Co-authored-by: Cursor <cursoragent@cursor.com> * Extract Passport MCP authorization view from AppServiceProvider Keep configurePassport thin by moving the Inertia consent props into an invokable App\Passport\AuthorizationView class. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify AuthorizationView and cover it with direct tests Use collection higher-order mapping for workspaces/scopes and add focused tests for current-workspace selection and empty-user props. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename BindWorkspaceToAccessTokenTest after listener removal The suite now covers AuthCodeRepository and AccessTokenRepository workspace binding, not an AccessTokenCreated listener. * Fail closed when auth code has no bindable workspace Authorization-code grants no longer fall back to the user's current workspace, so a token cannot be minted for a different tenant than consent. Co-authored-by: Cursor <cursoragent@cursor.com> * Retrigger CI after GitHub Actions infrastructure failures Co-authored-by: Cursor <cursoragent@cursor.com> * chore: retrigger CI Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden MCP OAuth workspace binding on refresh and backfill Co-authored-by: Cursor <cursoragent@cursor.com> * fix: always show MCP OAuth consent to pick a workspace Disable Passport silent re-consent and require an explicit workspace_id from the consent form, with Passport wiring moved to its own provider. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: sort MCP connected clients by last used Show most recently used OAuth connections first on the workspace MCP settings page. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:59:34 +00:00
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Load the data migration as an instance so its up() can run against rows that
* still have unbound MCP OAuth grants.
*/
function backfillMcpOAuthTokenWorkspacesMigration(): object
{
return require database_path('migrations/2026_08_06_144848_backfill_mcp_oauth_token_workspaces.php');
}
test('backfill revokes mcp oauth tokens when the user has multiple workspaces', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$other = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$other->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$token = mcpAccessToken($user, mcpOauthClient(), workspace: null);
backfillMcpOAuthTokenWorkspacesMigration()->up();
expect($token->refresh()->revoked)->toBeTrue()
->and($token->refresh()->workspace_id)->toBeNull();
});
test('backfill binds the sole account workspace when current is missing', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => null]);
$token = mcpAccessToken($user, mcpOauthClient(), workspace: null);
backfillMcpOAuthTokenWorkspacesMigration()->up();
expect($token->refresh()->workspace_id)->toBe($workspace->id);
});
test('backfill revokes tokens that cannot be mapped to a workspace', function () {
$user = User::factory()->create();
$user->update(['current_workspace_id' => null]);
$token = mcpAccessToken($user, mcpOauthClient(), workspace: null);
backfillMcpOAuthTokenWorkspacesMigration()->up();
expect($token->refresh()->revoked)->toBeTrue()
->and($token->refresh()->workspace_id)->toBeNull();
});
test('backfill ignores personal access tokens with null workspace', function () {
$user = User::factory()->create();
$result = $user->createToken('PAT');
$token = $result->token;
$token->forceFill(['workspace_id' => null])->saveQuietly();
backfillMcpOAuthTokenWorkspacesMigration()->up();
expect($token->fresh()->revoked)->toBeFalse()
->and($token->fresh()->workspace_id)->toBeNull();
});
test('backfill ignores oauth tokens without the mcp use scope', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$token = mcpAccessToken($user, mcpOauthClient(), workspace: null, scopes: []);
backfillMcpOAuthTokenWorkspacesMigration()->up();
expect($token->fresh()->workspace_id)->toBeNull()
->and($token->fresh()->revoked)->toBeFalse();
});
test('backfill revokes when multiple workspaces exist without a valid current', function () {
$user = User::factory()->create();
$alpha = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$beta = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$alpha->members()->attach($user->id, ['role' => Role::Admin->value]);
$beta->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => null]);
$token = mcpAccessToken($user, mcpOauthClient(), workspace: null);
backfillMcpOAuthTokenWorkspacesMigration()->up();
expect($token->refresh()->revoked)->toBeTrue()
->and($token->refresh()->workspace_id)->toBeNull();
});
test('backfill binds the remaining membership when current workspace was left', function () {
$user = User::factory()->create();
$current = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$other = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$other->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => $current->id]);
$token = mcpAccessToken($user, mcpOauthClient(), workspace: null);
backfillMcpOAuthTokenWorkspacesMigration()->up();
expect($token->refresh()->workspace_id)->toBe($other->id)
->and($token->refresh()->revoked)->toBeFalse();
});
test('backfill leaves dead expired mcp grants untouched', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$token = mcpAccessToken($user, mcpOauthClient(), workspace: null);
$token->forceFill(['expires_at' => now()->subDay()])->saveQuietly();
backfillMcpOAuthTokenWorkspacesMigration()->up();
expect($token->fresh()->workspace_id)->toBeNull()
->and($token->fresh()->revoked)->toBeFalse();
});
test('backfill binds expired access tokens that still have a live refresh token', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$token = mcpAccessToken($user, mcpOauthClient(), workspace: null);
$token->forceFill(['expires_at' => now()->subDay()])->saveQuietly();
DB::table('oauth_refresh_tokens')->insert([
'id' => Str::random(80),
'access_token_id' => $token->id,
'revoked' => false,
'expires_at' => now()->addMonth(),
]);
backfillMcpOAuthTokenWorkspacesMigration()->up();
expect($token->refresh()->workspace_id)->toBe($workspace->id)
->and($token->refresh()->revoked)->toBeFalse();
});
test('backfill rolls back binds when the migration fails before commit', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$token = mcpAccessToken($user, mcpOauthClient(), workspace: null);
$migration = backfillMcpOAuthTokenWorkspacesMigration();
$migration->beforeCommit = function (): void {
throw new RuntimeException('forced backfill failure');
};
expect(fn () => $migration->up())
->toThrow(RuntimeException::class, 'forced backfill failure');
expect($token->fresh()->workspace_id)->toBeNull()
->and($token->fresh()->revoked)->toBeFalse();
});