set('app.maintenance.store', 'array'); config()->set('cache.default', 'array'); Server::flushIdentityMap(); Process::fake(); InstanceSettings::query()->delete(); $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); $settings->id = 0; $settings->save(); Once::flush(); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); $this->own = mcpOpsSeedTeam($this->team); $this->otherTeam = Team::factory()->create(); $this->otherUser = User::factory()->create(); $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); $this->other = mcpOpsSeedTeam($this->otherTeam); session(['currentTeam' => $this->team]); Queue::fake(); }); /** * @return array */ function mcpOpsSeedTeam(Team $team): array { $server = Server::factory()->create(['team_id' => $team->id]); $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); $project = Project::factory()->create(['team_id' => $team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); $destinationAttributes = [ 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]; $application = Application::factory()->create($destinationAttributes); $database = StandalonePostgresql::create([ 'name' => 'ops-postgres-'.$team->id, 'image' => 'postgres:17-alpine', 'postgres_user' => 'postgres', 'postgres_password' => 'db-password-'.$team->id, 'postgres_db' => 'postgres', ...$destinationAttributes, ]); $service = Service::factory()->create($destinationAttributes); $serviceApplication = ServiceApplication::create([ 'uuid' => new_public_id(), 'name' => 'ops-service-app', 'image' => 'nginx:alpine', 'service_id' => $service->id, ]); $serviceDatabase = ServiceDatabase::create([ 'uuid' => new_public_id(), 'name' => 'ops-service-db', 'image' => 'postgres:17-alpine', 'service_id' => $service->id, ]); $volume = LocalPersistentVolume::create([ 'name' => $application->uuid.'-data', 'mount_path' => '/data', 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), ]); $s3 = S3Storage::create([ 'name' => 'ops-s3-'.$team->id, 'region' => 'us-east-1', 'key' => 'S3KEY'.$team->id, 'secret' => 'S3SECRET'.$team->id, 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); return compact('server', 'destination', 'project', 'environment', 'application', 'database', 'service', 'serviceApplication', 'serviceDatabase', 'volume', 's3'); } function mcpOpsToken(User $user, array $abilities = ['read', 'write', 'delete']): string { return $user->createToken('mcp-ops', $abilities)->plainTextToken; } function mcpOpsCall(string $token, string $tool, array $arguments = []) { auth()->forgetGuards(); return test()->withHeaders([ 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json, text/event-stream', ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => ['name' => $tool, 'arguments' => (object) $arguments], ]); } function mcpOpsText($response): string { return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); } function mcpOpsIsError($response): bool { return $response->json('result.isError') === true || $response->json('error') !== null; } function mcpOpsMember(Team $team): User { $member = User::factory()->create(); $team->members()->attach($member->id, ['role' => 'member']); return $member; } // ---------------------------------------------------------------- control test('control starts, restarts, and stops a service application and service database of the token team', function () { $token = mcpOpsToken($this->user, ['read', 'deploy']); $start = mcpOpsCall($token, 'control', ['resource' => 'service_application', 'action' => 'start', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid]); expect(mcpOpsIsError($start))->toBeFalse() ->and(mcpOpsText($start))->toContain('deploy request queued'); DeployServiceApplication::assertPushed(); $restart = mcpOpsCall($token, 'control', ['resource' => 'service_database', 'action' => 'restart', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceDatabase']->uuid]); expect(mcpOpsIsError($restart))->toBeFalse() ->and(mcpOpsText($restart))->toContain('restart request queued'); RestartServiceApplication::assertPushed(); $stop = mcpOpsCall($token, 'control', ['resource' => 'service_application', 'action' => 'stop', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid, 'confirm' => true]); expect(mcpOpsIsError($stop))->toBeFalse(); StopServiceApplication::assertPushed(); }); test('control rejects service children of another team, also under an own service_uuid', function (string $resource, string $childKey, bool $ownParent) { $token = mcpOpsToken($this->user, ['read', 'deploy']); $parent = $ownParent ? $this->own['service'] : $this->other['service']; $response = mcpOpsCall($token, 'control', ['resource' => $resource, 'action' => 'restart', 'service_uuid' => $parent->uuid, 'uuid' => $this->other[$childKey]->uuid]); expect(mcpOpsIsError($response))->toBeTrue() ->and(mcpOpsText($response))->toContain('not found'); Queue::assertNothingPushed(); })->with([ 'foreign app, own service' => ['service_application', 'serviceApplication', true], 'foreign app, foreign service' => ['service_application', 'serviceApplication', false], 'foreign db, own service' => ['service_database', 'serviceDatabase', true], 'foreign db, foreign service' => ['service_database', 'serviceDatabase', false], ]); test('control service child stop needs confirm and service_uuid', function () { $token = mcpOpsToken($this->user, ['read', 'deploy']); $noConfirm = mcpOpsCall($token, 'control', ['resource' => 'service_application', 'action' => 'stop', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid]); expect(mcpOpsIsError($noConfirm))->toBeTrue() ->and(mcpOpsText($noConfirm))->toContain('confirm=true'); $noParent = mcpOpsCall($token, 'control', ['resource' => 'service_database', 'action' => 'start', 'uuid' => $this->own['serviceDatabase']->uuid]); expect(mcpOpsIsError($noParent))->toBeTrue() ->and(mcpOpsText($noParent))->toContain('service_uuid'); Queue::assertNothingPushed(); }); test('control service child rejects read-only and member tokens', function () { $args = ['resource' => 'service_application', 'action' => 'restart', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid]; expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken($this->user, ['read', 'write']), 'control', $args)))->toBeTrue(); expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read', 'deploy']), 'control', $args)))->toBeTrue(); Queue::assertNothingPushed(); }); // ---------------------------------------------------------------- deploy by tag test('deploy by tag deploys only the token team resources with that tag', function () { $ownTag = Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); $this->own['application']->tags()->attach($ownTag->id); $foreignTag = Tag::create(['name' => 'prod', 'team_id' => $this->otherTeam->id]); $this->other['application']->tags()->attach($foreignTag->id); $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'deploy']), 'deploy', ['tag' => 'prod', 'confirm' => true]); expect(mcpOpsIsError($response))->toBeFalse() ->and(mcpOpsText($response))->toContain($this->own['application']->uuid) ->and(ApplicationDeploymentQueue::where('application_id', $this->own['application']->id)->count())->toBe(1) ->and(ApplicationDeploymentQueue::where('application_id', $this->other['application']->id)->count())->toBe(0); }); test('deploy by a tag that only another team has deploys nothing', function () { $foreignTag = Tag::create(['name' => 'theirs', 'team_id' => $this->otherTeam->id]); $this->other['application']->tags()->attach($foreignTag->id); $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'deploy']), 'deploy', ['tag' => 'theirs', 'confirm' => true]); expect(mcpOpsIsError($response))->toBeTrue() ->and(ApplicationDeploymentQueue::count())->toBe(0); }); test('deploy by tag needs confirm and exactly one of uuid or tag', function (array $args, string $message) { $tag = Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); $this->own['application']->tags()->attach($tag->id); $args = array_map(fn ($value) => $value === 'APP' ? $this->own['application']->uuid : $value, $args); $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'deploy']), 'deploy', $args); expect(mcpOpsIsError($response))->toBeTrue() ->and(mcpOpsText($response))->toContain($message) ->and(ApplicationDeploymentQueue::count())->toBe(0); })->with([ 'no confirm' => [['tag' => 'prod'], 'confirm=true'], 'both' => [['tag' => 'prod', 'uuid' => 'APP', 'confirm' => true], 'exactly one'], 'neither' => [[], 'exactly one'], 'tag with pull request' => [['tag' => 'prod', 'pull_request_id' => 3, 'confirm' => true], 'pull_request_id'], ]); test('deploy by tag rejects read-only and member tokens', function () { $tag = Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); $this->own['application']->tags()->attach($tag->id); expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken($this->user, ['read', 'write']), 'deploy', ['tag' => 'prod', 'confirm' => true])))->toBeTrue(); expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read', 'deploy']), 'deploy', ['tag' => 'prod', 'confirm' => true])))->toBeTrue(); expect(ApplicationDeploymentQueue::count())->toBe(0); }); // ---------------------------------------------------------------- manage_scheduled_task test('manage_scheduled_task creates, updates, executes, and deletes tasks of the token team', function (string $resource, string $key) { $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); $owner = $this->own[$key]; $created = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'create', 'resource' => $resource, 'uuid' => $owner->uuid, 'data' => ['name' => 'cleanup', 'command' => 'echo SECRETCMD', 'frequency' => 'daily']]); expect(mcpOpsIsError($created))->toBeFalse() ->and(mcpOpsText($created))->not->toContain('SECRETCMD'); $task = ScheduledTask::where('name', 'cleanup')->sole(); expect($task->team_id)->toBe($this->team->id) ->and($task->{$resource.'_id'})->toBe($owner->id); $updated = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'update', 'resource' => $resource, 'uuid' => $owner->uuid, 'task_uuid' => $task->uuid, 'data' => ['frequency' => 'hourly']]); expect(mcpOpsIsError($updated))->toBeFalse() ->and($task->fresh()->frequency)->toBe('hourly'); $executed = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'execute', 'resource' => $resource, 'uuid' => $owner->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); expect(mcpOpsIsError($executed))->toBeFalse(); Queue::assertPushed(ScheduledTaskJob::class); $deleted = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'delete', 'resource' => $resource, 'uuid' => $owner->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); expect(mcpOpsIsError($deleted))->toBeFalse() ->and($task->fresh())->toBeNull(); })->with([ 'application' => ['application', 'application'], 'service' => ['service', 'service'], ]); test('manage_scheduled_task cannot reach tasks or resources of another team', function (string $action, bool $ownParent) { $foreignTask = ScheduledTask::factory()->create(['team_id' => $this->otherTeam->id, 'application_id' => $this->other['application']->id, 'name' => 'theirs']); $parent = $ownParent ? $this->own['application'] : $this->other['application']; $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'write', 'deploy']), 'manage_scheduled_task', [ 'action' => $action, 'resource' => 'application', 'uuid' => $parent->uuid, 'task_uuid' => $foreignTask->uuid, 'confirm' => true, 'data' => $action === 'create' ? ['name' => 'evil', 'command' => 'id', 'frequency' => 'daily'] : ($action === 'update' ? ['name' => 'hijacked'] : []), ]); expect(mcpOpsIsError($response))->toBeTrue() ->and($foreignTask->fresh()?->name)->toBe('theirs') ->and(ScheduledTask::where('name', 'evil')->exists())->toBeFalse(); Queue::assertNotPushed(ScheduledTaskJob::class); })->with([ 'create on foreign app' => ['create', false], 'update via own app' => ['update', true], 'update via foreign app' => ['update', false], 'delete via own app' => ['delete', true], 'execute via own app' => ['execute', true], 'execute via foreign app' => ['execute', false], ]); test('manage_scheduled_task needs confirm for delete and execute and rejects read-only tokens', function () { $task = ScheduledTask::factory()->create(['team_id' => $this->team->id, 'application_id' => $this->own['application']->id]); $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); foreach (['delete', 'execute'] as $action) { $response = mcpOpsCall($token, 'manage_scheduled_task', ['action' => $action, 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid]); expect(mcpOpsIsError($response))->toBeTrue() ->and(mcpOpsText($response))->toContain('confirm=true'); } $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_scheduled_task', ['action' => 'execute', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); expect(mcpOpsIsError($readOnly))->toBeTrue(); $member = mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read']), 'manage_scheduled_task', ['action' => 'delete', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); expect(mcpOpsIsError($member))->toBeTrue(); expect($task->fresh())->not->toBeNull(); Queue::assertNotPushed(ScheduledTaskJob::class); }); // ---------------------------------------------------------------- manage_backup test('manage_backup creates, updates, runs, and deletes a backup schedule of the token team', function () { $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); $database = $this->own['database']; $created = mcpOpsCall($token, 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]); expect(mcpOpsIsError($created))->toBeFalse() ->and(mcpOpsText($created))->not->toContain('S3SECRET') ->and(mcpOpsText($created))->not->toContain('S3KEY'); $backup = ScheduledDatabaseBackup::where('database_id', $database->id)->sole(); expect($backup->team_id)->toBe($this->team->id) ->and($backup->s3_storage_id)->toBe($this->own['s3']->id); $updated = mcpOpsCall($token, 'manage_backup', ['action' => 'update', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'data' => ['frequency' => 'hourly', 'backup_now' => true]]); expect(mcpOpsIsError($updated))->toBeFalse() ->and($backup->fresh()->frequency)->toBe('hourly'); Queue::assertPushed(DatabaseBackupJob::class); $execution = ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $backup->id, 'database_name' => 'postgres', 'status' => 'success']); $deletedExecution = mcpOpsCall($token, 'manage_backup', ['action' => 'delete_execution', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'execution_uuid' => $execution->uuid, 'confirm' => true]); expect(mcpOpsIsError($deletedExecution))->toBeFalse() ->and($execution->fresh())->toBeNull(); $deleted = mcpOpsCall($token, 'manage_backup', ['action' => 'delete', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'confirm' => true]); expect(mcpOpsIsError($deleted))->toBeFalse() ->and($backup->fresh())->toBeNull(); }); test('manage_backup rejects an S3 storage of another team', function () { $token = mcpOpsToken($this->user); $database = $this->own['database']; $created = mcpOpsCall($token, 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->other['s3']->uuid]]); expect(mcpOpsIsError($created))->toBeTrue() ->and(ScheduledDatabaseBackup::where('database_id', $database->id)->exists())->toBeFalse(); $backup = ScheduledDatabaseBackup::create(['team_id' => $this->team->id, 'frequency' => 'daily', 'database_id' => $database->id, 'database_type' => $database->getMorphClass()]); $updated = mcpOpsCall($token, 'manage_backup', ['action' => 'update', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'data' => ['save_s3' => true, 's3_storage_uuids' => [$this->other['s3']->uuid], 'frequency' => 'hourly']]); expect(mcpOpsIsError($updated))->toBeTrue() ->and($backup->fresh()->s3_storage_id)->toBeNull() ->and($backup->fresh()->frequency)->toBe('daily') ->and($backup->fresh()->s3Storages()->exists())->toBeFalse(); }); test('manage_backup cannot reach backups of another team', function (string $action, bool $ownParent) { $foreignDatabase = $this->other['database']; $foreignBackup = ScheduledDatabaseBackup::create(['team_id' => $this->otherTeam->id, 'frequency' => 'daily', 'database_id' => $foreignDatabase->id, 'database_type' => $foreignDatabase->getMorphClass()]); $foreignExecution = ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $foreignBackup->id, 'database_name' => 'postgres', 'status' => 'success']); $parent = $ownParent ? $this->own['database'] : $foreignDatabase; $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_backup', [ 'action' => $action, 'uuid' => $parent->uuid, 'scheduled_backup_uuid' => $foreignBackup->uuid, 'execution_uuid' => $foreignExecution->uuid, 'confirm' => true, 'data' => $action === 'delete' || $action === 'delete_execution' ? [] : ['frequency' => 'hourly', 'backup_now' => true], ]); expect(mcpOpsIsError($response))->toBeTrue() ->and($foreignBackup->fresh()?->frequency)->toBe('daily') ->and($foreignExecution->fresh())->not->toBeNull() ->and(ScheduledDatabaseBackup::where('team_id', $this->team->id)->exists())->toBeFalse(); Queue::assertNotPushed(DatabaseBackupJob::class); })->with([ 'create on foreign db' => ['create', false], 'update via own db' => ['update', true], 'update via foreign db' => ['update', false], 'delete via own db' => ['delete', true], 'delete execution via own db' => ['delete_execution', true], 'delete execution via foreign db' => ['delete_execution', false], ]); test('manage_backup needs confirm for deletes and rejects read-only tokens', function () { $database = $this->own['database']; $backup = ScheduledDatabaseBackup::create(['team_id' => $this->team->id, 'frequency' => 'daily', 'database_id' => $database->id, 'database_type' => $database->getMorphClass()]); $execution = ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $backup->id, 'database_name' => 'postgres', 'status' => 'success']); $token = mcpOpsToken($this->user); foreach (['delete', 'delete_execution'] as $action) { $response = mcpOpsCall($token, 'manage_backup', ['action' => $action, 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'execution_uuid' => $execution->uuid]); expect(mcpOpsIsError($response))->toBeTrue() ->and(mcpOpsText($response))->toContain('confirm=true'); } $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily']]); expect(mcpOpsIsError($readOnly))->toBeTrue() ->and($backup->fresh())->not->toBeNull() ->and($execution->fresh())->not->toBeNull() ->and(ScheduledDatabaseBackup::count())->toBe(1); }); // ---------------------------------------------------------------- manage_storage test('manage_storage creates, updates, and deletes storages of the token team', function () { $token = mcpOpsToken($this->user); $application = $this->own['application']; $created = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['type' => 'persistent', 'name' => 'cache', 'mount_path' => '/cache']]); expect(mcpOpsIsError($created))->toBeFalse(); $volume = LocalPersistentVolume::where('name', $application->uuid.'-cache')->sole(); $updated = mcpOpsCall($token, 'manage_storage', ['action' => 'update', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['type' => 'persistent', 'uuid' => $volume->uuid, 'mount_path' => '/cache2']]); expect(mcpOpsIsError($updated))->toBeFalse() ->and($volume->fresh()->mount_path)->toBe('/cache2'); $dbCreated = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'database', 'uuid' => $this->own['database']->uuid, 'data' => ['type' => 'persistent', 'name' => 'extra', 'mount_path' => '/extra']]); expect(mcpOpsIsError($dbCreated))->toBeFalse(); $serviceCreated = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'service', 'uuid' => $this->own['service']->uuid, 'data' => ['type' => 'persistent', 'name' => 'svc', 'mount_path' => '/svc', 'resource_uuid' => $this->own['serviceApplication']->uuid]]); expect(mcpOpsIsError($serviceCreated))->toBeFalse() ->and(LocalPersistentVolume::where('resource_id', $this->own['serviceApplication']->id)->where('mount_path', '/svc')->exists())->toBeTrue(); $deleted = mcpOpsCall($token, 'manage_storage', ['action' => 'delete', 'resource' => 'application', 'uuid' => $application->uuid, 'storage_uuid' => $volume->uuid, 'confirm' => true]); expect(mcpOpsIsError($deleted))->toBeFalse() ->and($volume->fresh())->toBeNull(); }); test('manage_storage sets, runs, and removes a volume backup without returning S3 secrets', function () { $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); $args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid]; $set = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'set_backup', 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]); expect(mcpOpsIsError($set))->toBeFalse() ->and(mcpOpsText($set))->not->toContain('S3SECRET') ->and(mcpOpsText($set))->not->toContain('S3KEY'); $backup = ScheduledVolumeBackup::query()->sole(); expect($backup->s3_storage_id)->toBe($this->own['s3']->id); $run = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'run_backup', 'confirm' => true]); expect(mcpOpsIsError($run))->toBeFalse(); Queue::assertPushed(VolumeBackupJob::class); $removed = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'remove_backup', 'confirm' => true]); expect(mcpOpsIsError($removed))->toBeFalse() ->and($backup->fresh())->toBeNull(); }); test('manage_storage rejects an S3 storage and a service child of another team', function () { $token = mcpOpsToken($this->user); $set = mcpOpsCall($token, 'manage_storage', ['action' => 'set_backup', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->other['s3']->uuid]]); expect(mcpOpsIsError($set))->toBeTrue() ->and(ScheduledVolumeBackup::query()->exists())->toBeFalse(); $create = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'service', 'uuid' => $this->own['service']->uuid, 'data' => ['type' => 'persistent', 'name' => 'evil', 'mount_path' => '/evil', 'resource_uuid' => $this->other['serviceApplication']->uuid]]); expect(mcpOpsIsError($create))->toBeTrue() ->and(LocalPersistentVolume::where('mount_path', '/evil')->exists())->toBeFalse(); }); test('manage_storage cannot reach storages of another team', function (string $action, bool $ownParent) { $foreignVolume = $this->other['volume']; $foreignVolume->scheduledBackups()->create(['team_id' => $this->otherTeam->id, 'frequency' => 'daily']); $parent = $ownParent ? $this->own['application'] : $this->other['application']; $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'write', 'deploy']), 'manage_storage', [ 'action' => $action, 'resource' => 'application', 'uuid' => $parent->uuid, 'storage_uuid' => $foreignVolume->uuid, 'confirm' => true, 'data' => match ($action) { 'create' => ['type' => 'persistent', 'name' => 'evil', 'mount_path' => '/evil'], 'update' => ['type' => 'persistent', 'uuid' => $foreignVolume->uuid, 'mount_path' => '/hijacked'], 'set_backup' => ['frequency' => 'hourly'], default => [], }, ]); expect(mcpOpsIsError($response))->toBeTrue() ->and($foreignVolume->fresh()?->mount_path)->toBe('/data') ->and($foreignVolume->scheduledBackups()->sole()->frequency)->toBe('daily') ->and(LocalPersistentVolume::where('mount_path', '/evil')->exists())->toBeFalse(); Queue::assertNotPushed(VolumeBackupJob::class); })->with([ 'create on foreign app' => ['create', false], 'update via own app' => ['update', true], 'update via foreign app' => ['update', false], 'delete via own app' => ['delete', true], 'set backup via own app' => ['set_backup', true], 'run backup via own app' => ['run_backup', true], 'run backup via foreign app' => ['run_backup', false], 'remove backup via own app' => ['remove_backup', true], ]); test('manage_storage needs confirm for delete, remove_backup, and run_backup and rejects read-only tokens', function () { $this->own['volume']->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']); $args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid]; $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); foreach (['delete', 'remove_backup', 'run_backup'] as $action) { $response = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => $action]); expect(mcpOpsIsError($response))->toBeTrue() ->and(mcpOpsText($response))->toContain('confirm=true'); } $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_storage', [...$args, 'action' => 'delete', 'confirm' => true]); expect(mcpOpsIsError($readOnly))->toBeTrue() ->and($this->own['volume']->fresh())->not->toBeNull() ->and(ScheduledVolumeBackup::count())->toBe(1); Queue::assertNotPushed(VolumeBackupJob::class); }); // ---------------------------------------------------------------- manage_tags test('manage_tags creates, renames, attaches, detaches, and deletes tags of the token team', function () { $token = mcpOpsToken($this->user); $created = mcpOpsCall($token, 'manage_tags', ['action' => 'create', 'data' => ['name' => 'Web']]); expect(mcpOpsIsError($created))->toBeFalse(); $tag = Tag::where('team_id', $this->team->id)->where('name', 'web')->sole(); $renamed = mcpOpsCall($token, 'manage_tags', ['action' => 'update', 'tag_uuid' => $tag->uuid, 'data' => ['name' => 'frontend']]); expect(mcpOpsIsError($renamed))->toBeFalse() ->and($tag->fresh()->name)->toBe('frontend'); foreach (['application', 'database', 'service'] as $resource) { $attached = mcpOpsCall($token, 'manage_tags', ['action' => 'attach', 'resource' => $resource, 'uuid' => $this->own[$resource]->uuid, 'data' => ['tag_name' => 'frontend']]); expect(mcpOpsIsError($attached))->toBeFalse() ->and($this->own[$resource]->tags()->whereKey($tag->id)->exists())->toBeTrue(); } $detached = mcpOpsCall($token, 'manage_tags', ['action' => 'detach', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'tag_uuid' => $tag->uuid]); expect(mcpOpsIsError($detached))->toBeFalse() ->and($this->own['application']->tags()->whereKey($tag->id)->exists())->toBeFalse(); $deleted = mcpOpsCall($token, 'manage_tags', ['action' => 'delete', 'tag_uuid' => $tag->uuid, 'confirm' => true]); expect(mcpOpsIsError($deleted))->toBeFalse() ->and($tag->fresh())->toBeNull(); }); test('manage_tags attach never links a tag of another team', function () { $foreignTag = Tag::create(['name' => 'shared', 'team_id' => $this->otherTeam->id]); $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_tags', ['action' => 'attach', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'data' => ['tag_name' => 'shared']]); expect(mcpOpsIsError($response))->toBeFalse() ->and(mcpOpsText($response))->not->toContain($foreignTag->uuid) ->and($this->own['application']->tags()->whereKey($foreignTag->id)->exists())->toBeFalse() ->and($this->own['application']->tags()->sole()->team_id)->toBe($this->team->id); }); test('manage_tags cannot change tags or resources of another team', function (string $action) { $foreignTag = Tag::create(['name' => 'theirs', 'team_id' => $this->otherTeam->id]); $this->other['application']->tags()->attach($foreignTag->id); $ownTag = Tag::create(['name' => 'mine', 'team_id' => $this->team->id]); $this->own['application']->tags()->attach($ownTag->id); $args = match ($action) { 'update' => ['tag_uuid' => $foreignTag->uuid, 'data' => ['name' => 'hijacked']], 'delete' => ['tag_uuid' => $foreignTag->uuid], 'attach to foreign app' => ['resource' => 'application', 'uuid' => $this->other['application']->uuid, 'data' => ['tag_name' => 'evil']], 'detach foreign tag from own app' => ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'tag_uuid' => $foreignTag->uuid], 'detach from foreign app' => ['resource' => 'application', 'uuid' => $this->other['application']->uuid, 'tag_uuid' => $foreignTag->uuid], }; $toolAction = str_starts_with($action, 'attach') ? 'attach' : (str_starts_with($action, 'detach') ? 'detach' : $action); $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_tags', ['action' => $toolAction, 'confirm' => true, ...$args]); expect(mcpOpsIsError($response))->toBeTrue() ->and($foreignTag->fresh()?->name)->toBe('theirs') ->and($this->other['application']->tags()->whereKey($foreignTag->id)->exists())->toBeTrue() ->and(Tag::where('name', 'evil')->exists())->toBeFalse(); })->with([ 'update', 'delete', 'attach to foreign app', 'detach foreign tag from own app', 'detach from foreign app', ]); test('manage_tags delete needs confirm and write actions reject read-only and member tokens', function () { $tag = Tag::create(['name' => 'mine', 'team_id' => $this->team->id]); $noConfirm = mcpOpsCall(mcpOpsToken($this->user), 'manage_tags', ['action' => 'delete', 'tag_uuid' => $tag->uuid]); expect(mcpOpsIsError($noConfirm))->toBeTrue() ->and(mcpOpsText($noConfirm))->toContain('confirm=true'); $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_tags', ['action' => 'create', 'data' => ['name' => 'nope']]); expect(mcpOpsIsError($readOnly))->toBeTrue(); $member = mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read', 'write']), 'manage_tags', ['action' => 'create', 'data' => ['name' => 'nope']]); expect(mcpOpsIsError($member))->toBeTrue(); expect($tag->fresh())->not->toBeNull() ->and(Tag::where('name', 'nope')->exists())->toBeFalse(); });