'array', 'session.driver' => 'array', 'queue.default' => 'sync', 'app.maintenance.driver' => 'file', ]); InstanceSettings::query()->where('id', 0)->delete(); InstanceSettings::query()->delete(); $settings = new InstanceSettings(['is_mcp_server_enabled' => true]); $settings->id = 0; $settings->save(); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); $this->server = Server::factory()->create(['team_id' => $this->team->id]); // Server::created auto-provisions a default StandaloneDocker (network=coolify). $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); // Project::created auto-creates a production environment. $this->environment = $this->project->environments()->first() ?? Environment::factory()->create(['project_id' => $this->project->id]); $this->application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'fqdn' => 'https://app.example.com', ]); }); function mcpReadToken(): string { return test()->user->createToken('mcp-read', ['read'])->plainTextToken; } function mcpReadCall(string $name, array $arguments = []) { // Ensure each call resolves the Bearer token freshly (no guard bleed between tokens). auth()->forgetGuards(); return test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.mcpReadToken(), ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => $name, 'arguments' => (object) $arguments, ], ]); } function mcpSensitiveReadCall(string $name, array $arguments = []) { // Ensure each call resolves the Bearer token freshly (no guard bleed between tokens). auth()->forgetGuards(); $token = test()->user->createToken('mcp-sensitive-read', ['read', 'read:sensitive'])->plainTextToken; return test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => $name, 'arguments' => (object) $arguments, ], ]); } function mcpReadJson($response): array { return json_decode($response->json('result.content.0.text'), true); } test('tools/list includes new read tools and lifecycle tools', function () { $token = mcpReadToken(); $response = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => (object) [], ]); $response->assertOk(); $names = collect($response->json('result.tools'))->pluck('name')->all(); expect($names)->toContain( 'get_project', 'get_environment', 'list_resources', 'list_deployments', 'get_deployment', 'get_logs', 'list_env_keys', 'list_storages', 'list_destinations', 'get_destination', 'get_server_domains', 'get_server_resources', 'list_tags', 'list_github_apps', 'get_current_team', 'list_team_members', 'list_database_backups', 'list_service_applications', 'list_service_databases', 'search_resources', 'list_unhealthy_resources', 'list_application_previews', 'list_shared_env_keys', 'coolify_help', 'control', 'deploy', 'cancel_deployment', ); }); test('database backup tools scope schedules by database type and id', function () { $postgres = StandalonePostgresql::create([ 'name' => 'postgres', 'postgres_password' => 'password', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $mysql = StandaloneMysql::create([ 'name' => 'mysql', 'mysql_root_password' => 'password', 'mysql_password' => 'password', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); expect($mysql->id)->toBe($postgres->id); $postgresBackup = ScheduledDatabaseBackup::create([ 'team_id' => $this->team->id, 'frequency' => '0 0 * * *', 'database_id' => $postgres->id, 'database_type' => $postgres->getMorphClass(), ]); $mysqlBackup = ScheduledDatabaseBackup::create([ 'team_id' => $this->team->id, 'frequency' => '0 0 * * *', 'database_id' => $mysql->id, 'database_type' => $mysql->getMorphClass(), ]); $response = mcpReadCall('list_database_backups', ['uuid' => $postgres->uuid]); $response->assertOk(); $backupUuids = collect(mcpReadJson($response)['data']['backups'])->pluck('uuid'); expect($backupUuids) ->toContain($postgresBackup->uuid) ->not->toContain($mysqlBackup->uuid); $response = mcpReadCall('list_backup_executions', [ 'database_uuid' => $postgres->uuid, 'scheduled_backup_uuid' => $mysqlBackup->uuid, ]); $response->assertOk(); expect($response->json('result.isError'))->toBeTrue(); }); test('list_backup_executions omits messages without sensitive read and redacts when included', function () { $postgres = StandalonePostgresql::create([ 'name' => 'backup-msg-db', 'postgres_password' => 'password', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $backup = ScheduledDatabaseBackup::create([ 'team_id' => $this->team->id, 'frequency' => '0 0 * * *', 'database_id' => $postgres->id, 'database_type' => $postgres->getMorphClass(), ]); $older = ScheduledDatabaseBackupExecution::create([ 'uuid' => (string) Str::uuid(), 'scheduled_database_backup_id' => $backup->id, 'status' => 'success', 'message' => 'older backup ok', 'size' => 100, 'filename' => 'old.sql.gz', 'created_at' => now()->subHours(2), 'updated_at' => now()->subHours(2), ]); $newer = ScheduledDatabaseBackupExecution::create([ 'uuid' => (string) Str::uuid(), 'scheduled_database_backup_id' => $backup->id, 'status' => 'failed', 'message' => "backup failed password=redactme01\n", 'size' => 0, 'filename' => 'new.sql.gz', 'created_at' => now()->subHour(), 'updated_at' => now()->subHour(), ]); $readOnly = mcpReadCall('list_backup_executions', [ 'database_uuid' => $postgres->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'page' => 1, 'per_page' => 1, ]); $readOnly->assertOk(); $readBody = mcpReadJson($readOnly); expect($readBody['data']['message_included'])->toBeFalse() ->and($readBody['data']['executions'])->toHaveCount(1) ->and($readBody['data']['executions'][0]['status'])->toBe('failed') ->and($readBody['data']['executions'][0]['filename'])->toBe('new.sql.gz') ->and($readBody['data']['executions'][0])->not->toHaveKey('message') ->and($readBody['_pagination']['total'])->toBe(2); $page2 = mcpReadCall('list_backup_executions', [ 'database_uuid' => $postgres->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'page' => 2, 'per_page' => 1, ]); $page2->assertOk(); expect(mcpReadJson($page2)['data']['executions'][0]['status'])->toBe('success'); $sensitive = mcpSensitiveReadCall('list_backup_executions', [ 'database_uuid' => $postgres->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'page' => 1, 'per_page' => 1, ]); $sensitive->assertOk(); $sensitiveBody = mcpReadJson($sensitive); $message = $sensitiveBody['data']['executions'][0]['message'] ?? ''; expect($sensitiveBody['data']['message_included'])->toBeTrue() ->and($message)->not->toContain('redactme01') ->and($message)->toContain('password=') ->and($message)->toContain(REDACTED); expect($older->id)->not->toBe($newer->id); }); test('get_project returns environments and counts for team project only', function () { $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $response = mcpReadCall('get_project', ['uuid' => $this->project->uuid]); $response->assertOk(); $body = mcpReadJson($response); expect($body['data']['uuid'])->toBe($this->project->uuid); expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(1); expect($body['data']['environments'])->not->toBeEmpty(); $denied = mcpReadCall('get_project', ['uuid' => $otherProject->uuid]); expect($denied->json('result.isError'))->toBeTrue(); }); test('get_environment is team scoped via project', function () { $response = mcpReadCall('get_environment', [ 'project_uuid' => $this->project->uuid, 'environment_name_or_uuid' => $this->environment->name, ]); $response->assertOk(); $body = mcpReadJson($response); expect($body['data']['uuid'])->toBe($this->environment->uuid); expect(collect($body['data']['applications'])->pluck('uuid'))->toContain($this->application->uuid); expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(1); expect($body['data']['truncated']['applications'])->toBeFalse(); $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = Environment::factory()->create(['project_id' => $otherProject->id]); $denied = mcpReadCall('get_environment', [ 'project_uuid' => $otherProject->uuid, 'environment_name_or_uuid' => $otherEnv->uuid, ]); expect($denied->json('result.isError'))->toBeTrue(); }); test('get_environment caps resource samples and points to list tools when truncated', function () { foreach (['env-app-a', 'env-app-b', 'env-app-c'] as $name) { Application::factory()->create([ 'name' => $name, 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); } $response = mcpReadCall('get_environment', [ 'project_uuid' => $this->project->uuid, 'environment_name_or_uuid' => $this->environment->uuid, 'sample_per_type' => 2, ]); $response->assertOk(); $body = mcpReadJson($response); // beforeEach already has one application in this environment. expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(4) ->and($body['data']['applications'])->toHaveCount(2) ->and($body['data']['truncated']['applications'])->toBeTrue() ->and(collect($body['data']['next_tools'])->pluck('tool')->all())->toContain('list_applications'); }); test('list_resources only returns team resources', function () { $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = Environment::factory()->create(['project_id' => $otherProject->id]); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); Application::factory()->create([ 'name' => 'OtherTeamApp', 'environment_id' => $otherEnv->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); $response = mcpReadCall('list_resources'); $response->assertOk(); $body = mcpReadJson($response); $uuids = collect($body['data'])->pluck('uuid'); $names = collect($body['data'])->pluck('name'); expect($uuids)->toContain($this->application->uuid); expect($names)->not->toContain('OtherTeamApp'); }); test('list_resources paginates sorts and filters at the query layer', function () { $this->application->update(['name' => 'Charlie App']); $alphaApp = Application::factory()->create([ 'name' => 'Alpha App', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $bravoService = Service::factory()->create([ 'name' => 'Bravo Service', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $deltaDb = StandalonePostgresql::create([ 'name' => 'Delta DB', 'postgres_password' => 'password', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $tag = Tag::create([ 'name' => 'mcp-listed', 'team_id' => $this->team->id, ]); $alphaApp->tags()->attach($tag->id); $bravoService->tags()->attach($tag->id); $otherProject = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Other Project']); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); Application::factory()->create([ 'name' => 'Zed Other Project App', 'environment_id' => $otherEnv->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $page1 = mcpReadCall('list_resources', ['page' => 1, 'per_page' => 2]); $page1->assertOk(); $body1 = mcpReadJson($page1); expect($body1['_pagination']['total'])->toBe(5) ->and($body1['_pagination']['per_page'])->toBe(2) ->and($body1['_pagination']['page'])->toBe(1) ->and(collect($body1['data'])->pluck('name')->all())->toBe(['Alpha App', 'Bravo Service']); $page2 = mcpReadCall('list_resources', ['page' => 2, 'per_page' => 2]); $page2->assertOk(); $body2 = mcpReadJson($page2); expect(collect($body2['data'])->pluck('name')->all())->toBe(['Charlie App', 'Delta DB']); $appsOnly = mcpReadCall('list_resources', ['type' => 'application']); $appsOnly->assertOk(); $appsBody = mcpReadJson($appsOnly); expect(collect($appsBody['data'])->pluck('type')->unique()->values()->all())->toBe(['application']) ->and($appsBody['_pagination']['total'])->toBe(3); $dbsOnly = mcpReadCall('list_resources', ['type' => 'database']); $dbsOnly->assertOk(); $dbsBody = mcpReadJson($dbsOnly); expect($dbsBody['_pagination']['total'])->toBe(1) ->and($dbsBody['data'][0]['uuid'])->toBe($deltaDb->uuid) ->and($dbsBody['data'][0]['type'])->toBe('standalone-postgresql'); $tagged = mcpReadCall('list_resources', ['tag' => 'mcp-listed']); $tagged->assertOk(); $taggedBody = mcpReadJson($tagged); expect(collect($taggedBody['data'])->pluck('uuid')->sort()->values()->all()) ->toBe(collect([$alphaApp->uuid, $bravoService->uuid])->sort()->values()->all()); $byProject = mcpReadCall('list_resources', ['project_uuid' => $otherProject->uuid]); $byProject->assertOk(); $projectBody = mcpReadJson($byProject); expect($projectBody['_pagination']['total'])->toBe(1) ->and($projectBody['data'][0]['name'])->toBe('Zed Other Project App') ->and($projectBody['data'][0]['project_uuid'])->toBe($otherProject->uuid); }); test('list_deployments and get_deployment are team scoped and scrub logs', function () { $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, 'deployment_uuid' => 'dep-'.fake()->uuid(), 'status' => 'in_progress', 'server_id' => $this->server->id, 'application_name' => $this->application->name, 'server_name' => $this->server->name, 'commit' => 'abc123', 'logs' => json_encode([['name' => 'build', 'output' => 'SECRET_TOKEN=redactme01']]), ]); $list = mcpReadCall('list_deployments'); $list->assertOk(); $listBody = mcpReadJson($list); expect(collect($listBody['data'])->pluck('deployment_uuid'))->toContain($deployment->deployment_uuid); expect(json_encode($listBody))->not->toContain('redactme01'); expect(json_encode($listBody))->not->toContain('"logs"'); $get = mcpReadCall('get_deployment', ['uuid' => $deployment->deployment_uuid]); $get->assertOk(); $getBody = mcpReadJson($get); expect($getBody['data']['deployment_uuid'])->toBe($deployment->deployment_uuid); expect($getBody['data']['application_uuid'])->toBe($this->application->uuid); expect(json_encode($getBody))->not->toContain('redactme01'); $otherTeam = Team::factory()->create(); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); $otherApp = Application::factory()->create([ 'environment_id' => $otherEnv->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); $otherDep = ApplicationDeploymentQueue::create([ 'application_id' => $otherApp->id, 'deployment_uuid' => 'dep-other-'.fake()->uuid(), 'status' => 'in_progress', 'server_id' => $otherServer->id, 'application_name' => $otherApp->name, 'server_name' => $otherServer->name, ]); $denied = mcpReadCall('get_deployment', ['uuid' => $otherDep->deployment_uuid]); expect($denied->json('result.isError'))->toBeTrue(); }); test('deployment listings and overview scope shared server deployments by application team', function () { $teamDeployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, 'deployment_uuid' => 'dep-team-'.fake()->uuid(), 'status' => 'in_progress', 'server_id' => $this->server->id, 'application_name' => $this->application->name, 'server_name' => $this->server->name, ]); $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnvironment = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherApplication = Application::factory()->create([ 'environment_id' => $otherEnvironment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $otherDeployment = ApplicationDeploymentQueue::create([ 'application_id' => $otherApplication->id, 'deployment_uuid' => 'dep-other-shared-server-'.fake()->uuid(), 'status' => 'in_progress', 'server_id' => $this->server->id, 'application_name' => $otherApplication->name, 'server_name' => $this->server->name, ]); $listBody = mcpReadJson(mcpReadCall('list_deployments')); expect(collect($listBody['data'])->pluck('deployment_uuid')) ->toContain($teamDeployment->deployment_uuid) ->not->toContain($otherDeployment->deployment_uuid); $overviewBody = mcpReadJson(mcpReadCall('get_infrastructure_overview')); expect($overviewBody['data']['counts']['open_deployments'])->toBe(1); }); test('list_env_keys never returns values and is team scoped', function () { EnvironmentVariable::create([ 'key' => 'DATABASE_URL', 'value' => 'postgres://secret@localhost/db', 'resourceable_type' => Application::class, 'resourceable_id' => $this->application->id, 'is_preview' => false, ]); $response = mcpReadCall('list_env_keys', [ 'resource' => 'application', 'uuid' => $this->application->uuid, ]); $response->assertOk(); $body = mcpReadJson($response); $raw = json_encode($body); expect(collect($body['data']['keys'])->pluck('key'))->toContain('DATABASE_URL'); expect($raw)->not->toContain('postgres://secret'); expect($raw)->not->toContain('"value"'); expect($raw)->not->toContain('real_value'); $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); $otherApp = Application::factory()->create([ 'environment_id' => $otherEnv->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); $denied = mcpReadCall('list_env_keys', [ 'resource' => 'application', 'uuid' => $otherApp->uuid, ]); expect($denied->json('result.isError'))->toBeTrue(); }); test('list_destinations and get_destination are team scoped', function () { $response = mcpReadCall('list_destinations'); $response->assertOk(); $body = mcpReadJson($response); expect(collect($body['data'])->pluck('uuid'))->toContain($this->destination->uuid); $get = mcpReadCall('get_destination', ['uuid' => $this->destination->uuid]); $get->assertOk(); expect(mcpReadJson($get)['data']['uuid'])->toBe($this->destination->uuid); $otherTeam = Team::factory()->create(); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); $denied = mcpReadCall('get_destination', ['uuid' => $otherDest->uuid]); expect($denied->json('result.isError'))->toBeTrue(); }); test('get_server_domains and get_server_resources are team scoped', function () { $domains = mcpReadCall('get_server_domains', ['uuid' => $this->server->uuid]); $domains->assertOk(); $domainBody = mcpReadJson($domains); expect($domainBody['data']['server_uuid'])->toBe($this->server->uuid); expect($domainBody['data']['domains'])->toHaveCount(1); expect($domainBody['data']['domains'][0]['resource_uuid'])->toBe($this->application->uuid); expect($domainBody['data']['domains'][0]['domains'])->toContain('app.example.com'); $resources = mcpReadCall('get_server_resources', ['uuid' => $this->server->uuid]); $resources->assertOk(); $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); expect(mcpReadCall('get_server_domains', ['uuid' => $otherServer->uuid])->json('result.isError'))->toBeTrue(); expect(mcpReadCall('get_server_resources', ['uuid' => $otherServer->uuid])->json('result.isError'))->toBeTrue(); }); test('get_server_domains filters polymorphic destinations by type and id', function () { // Other server gets the next standalone_dockers id (typically 2). $otherServer = Server::factory()->create(['team_id' => $this->team->id]); $otherStandalone = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); // Swarm on this server with the same numeric id as the other server's // standalone docker. Untyped whereIn(destination_id) would merge that id // and wrongly attribute the other server's app to this server. DB::table('swarm_dockers')->insert([ 'id' => $otherStandalone->id, 'uuid' => (string) Str::uuid(), 'server_id' => $this->server->id, 'name' => 'swarm-network', 'network' => 'swarm-network', 'created_at' => now(), 'updated_at' => now(), ]); $swarmOnThisServer = SwarmDocker::query()->findOrFail($otherStandalone->id); expect($swarmOnThisServer->id)->toBe($otherStandalone->id); expect($this->destination->id)->not->toBe($otherStandalone->id); $swarmApp = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $swarmOnThisServer->id, 'destination_type' => SwarmDocker::class, 'fqdn' => 'https://swarm.example.com', 'name' => 'swarm-app', ]); $otherServerApp = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $otherStandalone->id, 'destination_type' => StandaloneDocker::class, 'fqdn' => 'https://other-server.example.com', 'name' => 'other-server-app', ]); $domains = mcpReadCall('get_server_domains', ['uuid' => $this->server->uuid]); $domains->assertOk(); $domainBody = mcpReadJson($domains); $resourceUuids = collect($domainBody['data']['domains'])->pluck('resource_uuid'); expect($resourceUuids)->toContain($this->application->uuid); expect($resourceUuids)->toContain($swarmApp->uuid); expect($resourceUuids)->not->toContain($otherServerApp->uuid); }); test('list_applications list_databases list_services server_uuid filters use destination type', function () { // Other server gets the next standalone_dockers id (typically 2). $otherServer = Server::factory()->create(['team_id' => $this->team->id]); $otherStandalone = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); // Swarm on this server with the same numeric id as the other server's standalone docker. DB::table('swarm_dockers')->insert([ 'id' => $otherStandalone->id, 'uuid' => (string) Str::uuid(), 'server_id' => $this->server->id, 'name' => 'swarm-network-list-filter', 'network' => 'swarm-network-list-filter', 'created_at' => now(), 'updated_at' => now(), ]); $swarmOnThisServer = SwarmDocker::query()->findOrFail($otherStandalone->id); expect($swarmOnThisServer->id)->toBe($otherStandalone->id); $swarmApp = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $swarmOnThisServer->id, 'destination_type' => SwarmDocker::class, 'name' => 'swarm-list-app', ]); $otherServerApp = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $otherStandalone->id, 'destination_type' => StandaloneDocker::class, 'name' => 'other-server-list-app', ]); $swarmDb = StandalonePostgresql::create([ 'name' => 'swarm-list-db', 'status' => 'running:healthy', 'postgres_password' => 'password', 'environment_id' => $this->environment->id, 'destination_id' => $swarmOnThisServer->id, 'destination_type' => SwarmDocker::class, ]); $otherServerDb = StandalonePostgresql::create([ 'name' => 'other-server-list-db', 'status' => 'running:healthy', 'postgres_password' => 'password', 'environment_id' => $this->environment->id, 'destination_id' => $otherStandalone->id, 'destination_type' => StandaloneDocker::class, ]); $swarmService = Service::factory()->create([ 'name' => 'swarm-list-svc', 'environment_id' => $this->environment->id, 'server_id' => null, 'destination_id' => $swarmOnThisServer->id, 'destination_type' => SwarmDocker::class, ]); $otherServerService = Service::factory()->create([ 'name' => 'other-server-list-svc', 'environment_id' => $this->environment->id, 'server_id' => null, 'destination_id' => $otherStandalone->id, 'destination_type' => StandaloneDocker::class, ]); $apps = mcpReadCall('list_applications', ['server_uuid' => $this->server->uuid]); $apps->assertOk(); $appUuids = collect(mcpReadJson($apps)['data'])->pluck('uuid'); expect($appUuids)->toContain($this->application->uuid, $swarmApp->uuid) ->not->toContain($otherServerApp->uuid); $dbs = mcpReadCall('list_databases', ['server_uuid' => $this->server->uuid]); $dbs->assertOk(); $dbUuids = collect(mcpReadJson($dbs)['data'])->pluck('uuid'); expect($dbUuids)->toContain($swarmDb->uuid) ->not->toContain($otherServerDb->uuid); $services = mcpReadCall('list_services', ['server_uuid' => $this->server->uuid]); $services->assertOk(); $serviceUuids = collect(mcpReadJson($services)['data'])->pluck('uuid'); expect($serviceUuids)->toContain($swarmService->uuid) ->not->toContain($otherServerService->uuid); }); test('list_tags and get_current_team and list_team_members are team scoped', function () { Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); Tag::create(['name' => 'theirs', 'team_id' => Team::factory()->create()->id]); $tags = mcpReadCall('list_tags'); $tags->assertOk(); $tagNames = collect(mcpReadJson($tags)['data'])->pluck('name'); expect($tagNames)->toContain('prod'); expect($tagNames)->not->toContain('theirs'); $team = mcpReadCall('get_current_team'); $team->assertOk(); expect(mcpReadJson($team)['data']['name'])->toBe($this->team->name); $members = mcpReadCall('list_team_members'); $members->assertOk(); expect(collect(mcpReadJson($members)['data'])->pluck('email'))->toContain($this->user->email); }); test('list_github_apps is team scoped and scrubs secrets', function () { $app = GithubApp::create([ 'name' => 'Mine', 'team_id' => $this->team->id, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'app_id' => 1, 'installation_id' => 1, 'client_id' => 'client-id', 'client_secret' => 'super-client-secret', 'webhook_secret' => 'super-webhook-secret', 'is_public' => false, 'is_system_wide' => false, ]); GithubApp::create([ 'name' => 'Theirs', 'team_id' => Team::factory()->create()->id, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'app_id' => 2, 'installation_id' => 2, 'client_id' => 'other-client', 'client_secret' => 'other-secret', 'webhook_secret' => 'other-webhook', 'is_public' => false, 'is_system_wide' => false, ]); $response = mcpReadCall('list_github_apps'); $response->assertOk(); $body = mcpReadJson($response); $names = collect($body['data'])->pluck('name'); $raw = json_encode($body); expect($names)->toContain('Mine'); expect($names)->not->toContain('Theirs'); expect($raw)->not->toContain('super-client-secret'); expect($raw)->not->toContain('super-webhook-secret'); expect(collect($body['data'])->pluck('uuid'))->toContain($app->uuid); }); test('list_github_repositories rejects public github sources cleanly', function () { $publicApp = GithubApp::create([ 'name' => 'Public Source', 'uuid' => 'github-public-test', 'team_id' => $this->team->id, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'is_public' => true, 'is_system_wide' => false, ]); $response = mcpReadCall('list_github_repositories', [ 'github_app_uuid' => $publicApp->uuid, ]); $response->assertOk(); expect($response->json('result.isError'))->toBeTrue(); expect($response->json('result.content.0.text')) ->toContain('public or missing app installation credentials') ->not->toContain('private_key'); }); test('list_github_branches uses anonymous github api for public sources', function () { $publicApp = GithubApp::create([ 'name' => 'Public Source', 'uuid' => 'github-public-branches', 'team_id' => $this->team->id, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'is_public' => true, 'is_system_wide' => false, ]); Http::fake([ 'https://api.github.com/repos/coollabsio/coolify/branches*' => Http::response([ ['name' => 'v4.x', 'protected' => true, 'commit' => ['sha' => 'abc123']], ['name' => 'next', 'protected' => false, 'commit' => ['sha' => 'def456']], ], 200), ]); $response = mcpReadCall('list_github_branches', [ 'github_app_uuid' => $publicApp->uuid, 'owner' => 'coollabsio', 'repo' => 'coolify', ]); $response->assertOk(); expect($response->json('result.isError'))->toBeFalse(); $body = mcpReadJson($response); expect(collect($body['data']['branches'])->pluck('name')->all())->toContain('v4.x', 'next'); expect($body['data']['branches'][0]['commit_sha'])->toBe('abc123'); }); test('list_github_branches rejects private apps missing installation credentials', function () { $privateApp = GithubApp::create([ 'name' => 'Incomplete Private App', 'uuid' => 'github-private-incomplete', 'team_id' => $this->team->id, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'app_id' => null, 'installation_id' => null, 'private_key_id' => null, 'is_public' => false, 'is_system_wide' => false, ]); Http::fake(); $response = mcpReadCall('list_github_branches', [ 'github_app_uuid' => $privateApp->uuid, 'owner' => 'coollabsio', 'repo' => 'coolify', ]); $response->assertOk(); expect($response->json('result.isError'))->toBeTrue(); expect($response->json('result.content.0.text')) ->toContain('missing installation credentials') ->not->toContain('private_key'); Http::assertNothingSent(); }); test('list_github_branches rejects owner or repo path segment injection', function () { $publicApp = GithubApp::create([ 'name' => 'Public Source Path Check', 'uuid' => 'github-public-path-check', 'team_id' => $this->team->id, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'is_public' => true, 'is_system_wide' => false, ]); Http::fake(); $badOwner = mcpReadCall('list_github_branches', [ 'github_app_uuid' => $publicApp->uuid, 'owner' => 'cool/../labs', 'repo' => 'coolify', ]); $badOwner->assertOk(); expect($badOwner->json('result.isError'))->toBeTrue(); expect($badOwner->json('result.content.0.text'))->toContain('valid GitHub login'); $badRepo = mcpReadCall('list_github_branches', [ 'github_app_uuid' => $publicApp->uuid, 'owner' => 'coollabsio', 'repo' => 'coolify/extra', ]); $badRepo->assertOk(); expect($badRepo->json('result.isError'))->toBeTrue(); expect($badRepo->json('result.content.0.text'))->toContain('valid GitHub repository'); Http::assertNothingSent(); }); test('list_applications project_uuid filter is team scoped', function () { $otherProject = Project::factory()->create(['team_id' => $this->team->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); Application::factory()->create([ 'name' => 'OtherProjectApp', 'environment_id' => $otherEnv->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $response = mcpReadCall('list_applications', ['project_uuid' => $this->project->uuid]); $response->assertOk(); $body = mcpReadJson($response); $names = collect($body['data'])->pluck('name'); expect($names)->toContain($this->application->name); expect($names)->not->toContain('OtherProjectApp'); }); test('get_logs rejects other team application uuid', function () { $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); $otherApp = Application::factory()->create([ 'environment_id' => $otherEnv->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); $response = mcpSensitiveReadCall('get_logs', [ 'resource' => 'application', 'uuid' => $otherApp->uuid, ]); expect($response->json('result.isError'))->toBeTrue(); }); test('search_resources finds team app by name and domain and excludes other team', function () { $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); Application::factory()->create([ 'name' => 'SecretOtherApp', 'fqdn' => 'https://app.example.com', 'environment_id' => $otherEnv->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); $byName = mcpReadCall('search_resources', ['query' => $this->application->name]); $byName->assertOk(); $names = collect(mcpReadJson($byName)['data']['results'])->pluck('name'); expect($names)->toContain($this->application->name); expect($names)->not->toContain('SecretOtherApp'); $byDomain = mcpReadCall('search_resources', ['query' => 'app.example.com', 'types' => 'application']); $byDomain->assertOk(); $uuids = collect(mcpReadJson($byDomain)['data']['results'])->pluck('uuid'); expect($uuids)->toContain($this->application->uuid); }); test('list_unhealthy_resources includes non-running apps and is team scoped', function () { $this->application->update(['status' => 'exited:unhealthy']); $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); Application::factory()->create([ 'name' => 'OtherDown', 'status' => 'exited:unhealthy', 'environment_id' => $otherEnv->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); $response = mcpReadCall('list_unhealthy_resources'); $response->assertOk(); $body = mcpReadJson($response); $names = collect($body['data']['unhealthy'])->pluck('name'); expect($names)->toContain($this->application->name); expect($names)->not->toContain('OtherDown'); }); test('get_infrastructure_overview health_hints and project counts stay accurate', function () { $this->application->update(['status' => 'exited:unhealthy']); Application::factory()->create([ 'name' => 'HealthyApp', 'status' => 'running:healthy', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); StandalonePostgresql::create([ 'name' => 'DownDb', 'postgres_password' => 'password', 'status' => 'exited:unhealthy', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); StandalonePostgresql::create([ 'name' => 'UpDb', 'postgres_password' => 'password', 'status' => 'running:healthy', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); Service::factory()->create([ 'name' => 'EmptyService', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); Application::factory()->create([ 'name' => 'OtherTeamDown', 'status' => 'exited:unhealthy', 'environment_id' => $otherEnv->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); StandalonePostgresql::create([ 'name' => 'OtherTeamDb', 'postgres_password' => 'password', 'status' => 'exited:unhealthy', 'environment_id' => $otherEnv->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); $response = mcpReadCall('get_infrastructure_overview'); $response->assertOk(); $body = mcpReadJson($response); $data = $body['data']; expect($data['counts']['applications'])->toBe(2) ->and($data['counts']['services'])->toBe(1) ->and($data['counts']['databases'])->toBe(2) ->and($data['projects'][0]['counts']['applications'])->toBe(2) ->and($data['projects'][0]['counts']['services'])->toBe(1) ->and($data['projects'][0]['counts']['databases'])->toBe(2) ->and($data['health_hints']['applications_not_running'])->toBe(1) ->and($data['health_hints']['databases_not_running'])->toBe(1) // Empty service has no containers → aggregated status is not healthy. ->and($data['health_hints']['services_not_running'])->toBe(1); }); test('list_application_previews is team scoped', function () { $preview = ApplicationPreview::create([ 'application_id' => $this->application->id, 'pull_request_id' => 42, 'pull_request_html_url' => 'https://github.com/org/repo/pull/42', 'fqdn' => 'https://pr-42.example.com', 'status' => 'running:healthy', ]); $response = mcpReadCall('list_application_previews', ['uuid' => $this->application->uuid]); $response->assertOk(); $body = mcpReadJson($response); expect(collect($body['data']['previews'])->pluck('uuid'))->toContain($preview->uuid); expect(collect($body['data']['previews'])->pluck('pull_request_id'))->toContain(42); $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); $otherApp = Application::factory()->create([ 'environment_id' => $otherEnv->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); expect(mcpReadCall('list_application_previews', ['uuid' => $otherApp->uuid])->json('result.isError'))->toBeTrue(); }); test('list_shared_env_keys returns names without values and is team scoped', function () { SharedEnvironmentVariable::create([ 'key' => 'SHARED_API_URL', 'value' => 'https://secret.example.com', 'type' => 'project', 'team_id' => $this->team->id, 'project_id' => $this->project->id, ]); $response = mcpReadCall('list_shared_env_keys', [ 'scope' => 'project', 'uuid' => $this->project->uuid, ]); $response->assertOk(); $body = mcpReadJson($response); $raw = json_encode($body); expect(collect($body['data']['keys'])->pluck('key'))->toContain('SHARED_API_URL'); expect($raw)->not->toContain('secret.example.com'); expect($raw)->not->toContain('"value"'); $otherProject = Project::factory()->create(['team_id' => Team::factory()->create()->id]); expect(mcpReadCall('list_shared_env_keys', [ 'scope' => 'project', 'uuid' => $otherProject->uuid, ])->json('result.isError'))->toBeTrue(); }); test('get_deployment include_log_summary requires sensitive read ability', function () { $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, 'deployment_uuid' => 'dep-log-'.fake()->uuid(), 'status' => 'failed', 'server_id' => $this->server->id, 'application_name' => $this->application->name, 'server_name' => $this->server->name, 'commit' => 'deadbeef', 'logs' => json_encode([ ['output' => 'unstructured-sensitive-build-output', 'type' => 'stdout', 'hidden' => false], ]), ]); $response = mcpReadCall('get_deployment', [ 'uuid' => $deployment->deployment_uuid, 'include_log_summary' => true, ]); $response->assertOk(); expect($response->json('result.isError'))->toBeTrue() ->and($response->json('result.content.0.text'))->toContain('read:sensitive') ->and($response->json('result.content.0.text'))->not->toContain('unstructured-sensitive-build-output'); }); test('get_deployment include_log_summary returns capped redacted text with sensitive read ability', function () { $logs = json_encode([ ['output' => 'step 1 ok', 'type' => 'stdout', 'hidden' => false], ['output' => 'password=redactme01', 'type' => 'stderr', 'hidden' => false], ['output' => 'done', 'type' => 'stdout', 'hidden' => false], ]); $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, 'deployment_uuid' => 'dep-log-'.fake()->uuid(), 'status' => 'failed', 'server_id' => $this->server->id, 'application_name' => $this->application->name, 'server_name' => $this->server->name, 'commit' => 'deadbeef', 'logs' => $logs, ]); $response = mcpSensitiveReadCall('get_deployment', [ 'uuid' => $deployment->deployment_uuid, 'include_log_summary' => true, 'log_lines' => 10, ]); $response->assertOk(); $body = mcpReadJson($response); expect($body['data']['log_summary']['available'])->toBeTrue(); expect($body['data']['log_summary']['text'])->toContain('step 1 ok'); expect($body['data']['log_summary']['text'])->not->toContain('redactme01'); expect($body['data']['log_summary']['text'])->toContain('password='); // Full logs field still scrubbed from root payload expect(json_encode($body))->not->toContain('"logs":'); }); test('get_deployment plain-text log summary respects the requested line limit', function () { $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, 'deployment_uuid' => 'dep-log-'.fake()->uuid(), 'status' => 'failed', 'server_id' => $this->server->id, 'application_name' => $this->application->name, 'server_name' => $this->server->name, 'commit' => 'deadbeef', 'logs' => "first line\nsecond line token=redactme01\nlast line", ]); $response = mcpSensitiveReadCall('get_deployment', [ 'uuid' => $deployment->deployment_uuid, 'include_log_summary' => true, 'log_lines' => 1, ]); $response->assertOk(); $summary = mcpReadJson($response)['data']['log_summary']; expect($summary['lines'])->toBe(1) ->and($summary['truncated'])->toBeTrue() ->and($summary['text'])->toBe('last line') ->and($summary['text'])->not->toContain('redactme01'); }); test('list_servers reachable filter works', function () { $this->server->settings->forceFill(['is_reachable' => true])->saveQuietly(); $response = mcpReadCall('list_servers', ['reachable' => true]); $response->assertOk(); $uuids = collect(mcpReadJson($response)['data'])->pluck('uuid'); expect($uuids)->toContain($this->server->uuid); $none = mcpReadCall('list_servers', ['reachable' => false]); $none->assertOk(); expect(collect(mcpReadJson($none)['data'])->pluck('uuid'))->not->toContain($this->server->uuid); }); test('list_applications status and server_uuid filters work', function () { $this->application->update(['status' => 'running:healthy']); $byStatus = mcpReadCall('list_applications', ['status' => 'running']); $byStatus->assertOk(); expect(collect(mcpReadJson($byStatus)['data'])->pluck('uuid'))->toContain($this->application->uuid); $byServer = mcpReadCall('list_applications', ['server_uuid' => $this->server->uuid]); $byServer->assertOk(); expect(collect(mcpReadJson($byServer)['data'])->pluck('uuid'))->toContain($this->application->uuid); $missingServer = mcpReadCall('list_applications', ['server_uuid' => 'no-such-server']); $missingServer->assertOk(); expect(mcpReadJson($missingServer)['data'])->toBe([]); }); test('list_applications paginates with stable name order and disjoint pages', function () { $this->application->update(['name' => 'app-z-original']); foreach (['app-a', 'app-b', 'app-c'] as $name) { Application::factory()->create([ 'name' => $name, 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); } $page1 = mcpReadCall('list_applications', ['page' => 1, 'per_page' => 2]); $page1->assertOk(); $page1Body = mcpReadJson($page1); $page1Names = collect($page1Body['data'])->pluck('name')->all(); $page1Uuids = collect($page1Body['data'])->pluck('uuid')->all(); expect($page1Names)->toBe(collect($page1Names)->sort()->values()->all()) ->and($page1Body['_pagination']['total'])->toBeGreaterThanOrEqual(4) ->and($page1Body['_pagination']['next']['args']['page'] ?? null)->toBe(2); $page2 = mcpReadCall('list_applications', ['page' => 2, 'per_page' => 2]); $page2->assertOk(); $page2Uuids = collect(mcpReadJson($page2)['data'])->pluck('uuid')->all(); expect(array_intersect($page1Uuids, $page2Uuids))->toBe([]); }); test('list_databases filters by project, name, status, and server and is team scoped', function () { $matching = StandalonePostgresql::create([ 'name' => 'prod-postgres', 'status' => 'running:healthy', 'postgres_password' => 'password', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $otherName = StandalonePostgresql::create([ 'name' => 'dev-redis-like', 'status' => 'exited:unhealthy', 'postgres_password' => 'password', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $otherProject = Project::factory()->create(['team_id' => $this->team->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); StandalonePostgresql::create([ 'name' => 'other-project-db', 'status' => 'running:healthy', 'postgres_password' => 'password', 'environment_id' => $otherEnv->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $otherTeam = Team::factory()->create(); $otherTeamProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherTeamEnv = $otherTeamProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherTeamProject->id]); $otherTeamServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherTeamDest = StandaloneDocker::query()->where('server_id', $otherTeamServer->id)->firstOrFail(); StandalonePostgresql::create([ 'name' => 'foreign-db', 'status' => 'running:healthy', 'postgres_password' => 'password', 'environment_id' => $otherTeamEnv->id, 'destination_id' => $otherTeamDest->id, 'destination_type' => $otherTeamDest->getMorphClass(), ]); $all = mcpReadCall('list_databases'); $all->assertOk(); $allUuids = collect(mcpReadJson($all)['data'])->pluck('uuid'); expect($allUuids) ->toContain($matching->uuid, $otherName->uuid) ->not->toContain(StandalonePostgresql::where('name', 'foreign-db')->value('uuid')); $byProject = mcpReadCall('list_databases', ['project_uuid' => $this->project->uuid]); $byProject->assertOk(); $projectUuids = collect(mcpReadJson($byProject)['data'])->pluck('uuid'); expect($projectUuids) ->toContain($matching->uuid) ->not->toContain(StandalonePostgresql::where('name', 'other-project-db')->value('uuid')); $byName = mcpReadCall('list_databases', ['name' => 'prod-']); $byName->assertOk(); expect(collect(mcpReadJson($byName)['data'])->pluck('uuid')) ->toContain($matching->uuid) ->not->toContain($otherName->uuid); $byStatus = mcpReadCall('list_databases', ['status' => 'exited']); $byStatus->assertOk(); expect(collect(mcpReadJson($byStatus)['data'])->pluck('uuid')) ->toContain($otherName->uuid) ->not->toContain($matching->uuid); $byServer = mcpReadCall('list_databases', ['server_uuid' => $this->server->uuid]); $byServer->assertOk(); expect(collect(mcpReadJson($byServer)['data'])->pluck('uuid'))->toContain($matching->uuid); $missingServer = mcpReadCall('list_databases', ['server_uuid' => 'no-such-server']); $missingServer->assertOk(); expect(mcpReadJson($missingServer)['data'])->toBe([]); $row = collect(mcpReadJson($byProject)['data'])->firstWhere('uuid', $matching->uuid); expect($row) ->toHaveKeys(['uuid', 'name', 'status', 'type', 'project_uuid', 'project_name']) ->and($row['project_uuid'])->toBe($this->project->uuid) ->and($row['type'])->toBe('standalone-postgresql'); }); test('list_services filters by its computed status before pagination', function () { Service::factory()->create([ 'name' => 'Matching service', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); Service::factory()->create([ 'name' => 'Another service', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $response = mcpReadCall('list_services', [ 'status' => 'unknown', 'per_page' => 1, ]); $response->assertOk(); $body = mcpReadJson($response); expect($body['_pagination']['total'])->toBe(2) ->and($body['data'])->toHaveCount(1) ->and($body['data'][0]['status'])->toContain('unknown'); }); test('get_service_application returns a field whitelist and is team scoped', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", ]); $app = ServiceApplication::create([ 'uuid' => (string) Str::uuid(), 'name' => 'web', 'human_name' => 'Web', 'description' => 'Frontend container', 'service_id' => $service->id, 'image' => 'nginx:alpine', 'fqdn' => 'https://web.example.com', 'status' => 'running:healthy', ]); $response = mcpReadCall('get_service_application', [ 'service_uuid' => $service->uuid, 'uuid' => $app->uuid, ]); $response->assertOk(); $body = mcpReadJson($response); $data = $body['data']; expect($data['uuid'])->toBe($app->uuid) ->and($data['service_uuid'])->toBe($service->uuid) ->and($data['name'])->toBe('web') ->and($data['human_name'])->toBe('Web') ->and($data['status'])->toBe('running:healthy') ->and($data['fqdn'])->toBe('https://web.example.com') ->and($data['image'])->toBe('nginx:alpine') ->and($data)->toHaveKeys([ 'uuid', 'service_uuid', 'name', 'human_name', 'description', 'status', 'fqdn', 'ports', 'exposes', 'image', 'exclude_from_status', 'required_fqdn', 'is_log_drain_enabled', 'is_include_timestamps', 'is_gzip_enabled', 'is_stripprefix_enabled', 'last_online_at', 'created_at', 'updated_at', ]) ->and($data)->not->toHaveKey('id') ->and($data)->not->toHaveKey('service_id') ->and($data)->not->toHaveKey('is_migrated'); $otherTeam = Team::factory()->create(); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); $otherService = Service::factory()->create([ 'environment_id' => $otherEnv->id, 'server_id' => $otherServer->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); $otherApp = ServiceApplication::create([ 'uuid' => (string) Str::uuid(), 'name' => 'theirs', 'service_id' => $otherService->id, 'image' => 'nginx:alpine', ]); $denied = mcpReadCall('get_service_application', [ 'service_uuid' => $otherService->uuid, 'uuid' => $otherApp->uuid, ]); expect($denied->json('result.isError'))->toBeTrue(); }); test('MCP lists prompts for troubleshooting workflows', function () { $token = mcpReadToken(); $response = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'prompts/list', 'params' => (object) [], ]); $response->assertOk(); $names = collect($response->json('result.prompts'))->pluck('name')->all(); expect($names)->toContain('troubleshoot_application', 'explain_failed_deploy'); }); test('get_logs requires sensitive read ability', function () { $this->application->update(['status' => 'running:healthy']); $response = mcpReadCall('get_logs', [ 'resource' => 'application', 'uuid' => $this->application->uuid, ]); $response->assertOk(); expect($response->json('result.isError'))->toBeTrue() ->and($response->json('result.content.0.text'))->toContain('read:sensitive'); }); test('team members cannot retrieve logs with sensitive read ability', function () { $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); $response = mcpSensitiveReadCall('get_logs', [ 'resource' => 'application', 'uuid' => $this->application->uuid, ]); // Elevated member tokens are rejected as JSON-RPC errors (HTTP 200) so MCP clients can parse them. $response->assertOk(); expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '') ->toMatch('/team role|Missing required/i'); }); test('get_logs returns structured next_tools when application is not running', function () { $this->application->update(['status' => 'exited:unhealthy']); $response = mcpSensitiveReadCall('get_logs', [ 'resource' => 'application', 'uuid' => $this->application->uuid, ]); $response->assertOk(); $body = mcpReadJson($response); expect($body['data']['ok'])->toBeFalse(); expect($body['data']['reason'])->toBe('not_running'); expect($body['data']['next_tools'])->not->toBeEmpty(); expect(collect($body['data']['next_tools'])->pluck('tool'))->toContain('list_deployments', 'list_unhealthy_resources'); }); test('get_logs returns structured choices when service has multiple containers', function () { $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $service = Service::factory()->create([ 'name' => 'multi-container-svc', 'environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $childApp = ServiceApplication::create([ 'uuid' => (string) Str::uuid(), 'name' => 'web', 'service_id' => $service->id, 'status' => 'running:healthy', 'image' => 'nginx:latest', ]); $childDb = ServiceDatabase::create([ 'uuid' => (string) Str::uuid(), 'name' => 'db', 'service_id' => $service->id, 'status' => 'running:healthy', 'image' => 'postgres:16', ]); $response = mcpSensitiveReadCall('get_logs', [ 'resource' => 'service', 'uuid' => $service->uuid, ]); $response->assertOk(); $body = mcpReadJson($response); expect($body['data']['ok'])->toBeFalse() ->and($body['data']['reason'])->toBe('multiple_containers') ->and($body['data']['choices'])->toBeArray() ->and(collect($body['data']['choices'])->pluck('uuid')->all()) ->toContain($childApp->uuid, $childDb->uuid) ->and(json_encode($body['data']['message'] ?? ''))->not->toContain('"uuid"'); }); test('coolify_help returns catalog intents', function () { $response = mcpReadCall('coolify_help', ['intent' => 'essentials']); $response->assertOk(); $body = mcpReadJson($response); expect($body['data']['catalog']['essentials']['tools'])->toContain('search_resources', 'control'); }); test('list_unhealthy_resources sample_only returns summary and samples', function () { $this->application->update(['status' => 'exited:unhealthy']); $response = mcpReadCall('list_unhealthy_resources', ['sample_only' => true, 'sample_per_type' => 3]); $response->assertOk(); $body = mcpReadJson($response); expect($body['data']['sample_only'])->toBeTrue(); expect($body['data']['summary'])->toHaveKeys(['total', 'applications', 'servers']); expect($body['data']['samples'])->toHaveKeys(['applications', 'servers', 'services', 'databases']); }); test('control and deploy require deploy ability', function () { $denied = mcpReadCall('control', [ 'resource' => 'application', 'action' => 'start', 'uuid' => $this->application->uuid, ]); $denied->assertOk(); expect($denied->json('result.isError'))->toBeTrue(); expect($denied->json('result.content.0.text'))->toContain('Missing required permissions'); $deployDenied = mcpReadCall('deploy', ['uuid' => $this->application->uuid]); expect($deployDenied->json('result.isError'))->toBeTrue(); }); test('control stop requires confirm', function () { $token = test()->user->createToken('mcp-deploy', ['read', 'deploy'])->plainTextToken; $response = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'control', 'arguments' => (object) [ 'resource' => 'application', 'action' => 'stop', 'uuid' => $this->application->uuid, ], ], ]); expect($response->json('result.isError'))->toBeTrue(); expect($response->json('result.content.0.text'))->toContain('confirm=true'); }); test('team member with deploy ability cannot call lifecycle tools', function () { $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); $token = $this->user->createToken('mcp-member-deploy', ['read', 'deploy'])->plainTextToken; $response = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'control', 'arguments' => (object) [ 'resource' => 'application', 'action' => 'start', 'uuid' => $this->application->uuid, ], ], ]); // Middleware returns a JSON-RPC error envelope (HTTP 200) for MCP clients. $response->assertOk(); expect($response->json('jsonrpc'))->toBe('2.0'); expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '') ->toMatch('/team role|Missing required/i'); }); test('control start with deploy ability queues application deployment', function () { Bus::fake(); $token = $this->user->createToken('mcp-deploy-start', ['read', 'deploy'])->plainTextToken; $response = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'control', 'arguments' => (object) [ 'resource' => 'application', 'action' => 'start', 'uuid' => $this->application->uuid, ], ], ]); $response->assertOk(); expect($response->json('result.isError'))->toBeFalse(); $body = mcpReadJson($response); expect($body['data']['ok'])->toBeTrue() ->and($body['data']['action'])->toBe('start') ->and($body['data']['deployment_uuid'])->not->toBeEmpty(); }); test('deploy tool queues application deployment', function () { Bus::fake(); $token = $this->user->createToken('mcp-deploy-tool', ['read', 'deploy'])->plainTextToken; $response = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'deploy', 'arguments' => (object) [ 'uuid' => $this->application->uuid, 'force' => false, ], ], ]); $response->assertOk(); expect($response->json('result.isError'))->toBeFalse(); $body = mcpReadJson($response); expect($body['data']['ok'])->toBeTrue() ->and($body['data']['deployment_uuid'])->not->toBeEmpty(); expect(ApplicationDeploymentQueue::where('deployment_uuid', $body['data']['deployment_uuid'])->exists())->toBeTrue(); }); test('cancel_deployment cancels team deployment and rejects other team', function () { // Avoid real SSH via instant_remote_process during cancellation cleanup. Process::fake([ '*' => Process::result(output: ''), ]); Queue::fake(); $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, 'deployment_uuid' => 'dep-cancel-'.fake()->uuid(), 'status' => 'in_progress', 'server_id' => $this->server->id, 'application_name' => $this->application->name, 'server_name' => $this->server->name, 'commit' => 'abc', 'current_process_id' => '12345', ]); $nextDeployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, 'deployment_uuid' => 'dep-next-'.fake()->uuid(), 'status' => 'queued', 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'application_name' => $this->application->name, 'server_name' => $this->server->name, 'commit' => 'def', 'pull_request_id' => 0, ]); $token = $this->user->createToken('mcp-cancel', ['read', 'deploy'])->plainTextToken; $ok = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'cancel_deployment', 'arguments' => (object) ['uuid' => $deployment->deployment_uuid], ], ]); $ok->assertOk(); expect($ok->json('result.isError'))->toBeFalse(); $body = mcpReadJson($ok); expect($body['data']['ok'])->toBeTrue() ->and($body['data']['status'])->toBe('cancelled-by-user'); expect($deployment->fresh()->status)->toBe('cancelled-by-user'); expect($nextDeployment->fresh()->status)->toBe('in_progress'); Queue::assertPushed(ApplicationDeploymentJob::class, fn (ApplicationDeploymentJob $job) => $job->application_deployment_queue_id === $nextDeployment->id); $otherTeam = Team::factory()->create(); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); $otherApp = Application::factory()->create([ 'environment_id' => $otherEnv->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); $otherDep = ApplicationDeploymentQueue::create([ 'application_id' => $otherApp->id, 'deployment_uuid' => 'dep-other-cancel-'.fake()->uuid(), 'status' => 'in_progress', 'server_id' => $otherServer->id, 'application_name' => $otherApp->name, 'server_name' => $otherServer->name, ]); $denied = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'cancel_deployment', 'arguments' => (object) ['uuid' => $otherDep->deployment_uuid], ], ]); expect($denied->json('result.isError'))->toBeTrue(); expect($otherDep->fresh()->status)->toBe('in_progress'); }); test('cancel_deployment rejects other team deployment even on owned server', function () { // Shared-server case: caller's team owns the host server, but the application belongs to another team. $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherApp = Application::factory()->create([ 'environment_id' => $otherEnv->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $sharedServerDep = ApplicationDeploymentQueue::create([ 'application_id' => $otherApp->id, 'deployment_uuid' => 'dep-shared-server-cancel-'.fake()->uuid(), 'status' => 'in_progress', 'server_id' => $this->server->id, 'application_name' => $otherApp->name, 'server_name' => $this->server->name, ]); $token = $this->user->createToken('mcp-shared-server-cancel', ['read', 'deploy'])->plainTextToken; $denied = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'cancel_deployment', 'arguments' => (object) ['uuid' => $sharedServerDep->deployment_uuid], ], ]); expect($denied->json('result.isError'))->toBeTrue(); expect($sharedServerDep->fresh()->status)->toBe('in_progress'); }); test('cancel_deployment updates only a still cancellable deployment', function () { // Avoid real SSH via instant_remote_process during cancellation cleanup. Process::fake([ '*' => Process::result(output: ''), ]); $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, 'deployment_uuid' => 'dep-atomic-cancel-'.fake()->uuid(), 'status' => 'in_progress', 'server_id' => $this->server->id, 'application_name' => $this->application->name, 'server_name' => $this->server->name, ]); $updates = []; DB::listen(function ($query) use (&$updates) { if (str_starts_with(strtolower(ltrim($query->sql)), 'update')) { $updates[] = strtolower($query->sql); } }); $token = $this->user->createToken('mcp-atomic-cancel', ['read', 'deploy'])->plainTextToken; $response = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'cancel_deployment', 'arguments' => (object) ['uuid' => $deployment->deployment_uuid], ], ]); $response->assertOk(); expect(collect($updates)->contains( fn (string $sql) => str_contains($sql, 'application_deployment_queues') && str_contains($sql, 'status') && str_contains($sql, ' in '), ))->toBeTrue(); }); test('MCP resources list includes overview and application template', function () { $token = mcpReadToken(); $response = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'resources/list', 'params' => (object) [], ]); $response->assertOk(); $uris = collect($response->json('result.resources'))->pluck('uri')->filter()->all(); $templates = collect($response->json('result.resources'))->pluck('uriTemplate')->filter()->all(); // Static resource may appear under resources; templates under list or templates/list depending on server. $all = collect($uris)->merge($templates)->implode(' '); expect($all)->toContain('coolify://'); }); test('MCP overview resource returns batched project resource counts', function () { StandalonePostgresql::create([ 'name' => 'overview-postgres', 'postgres_password' => 'password', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); Service::create([ 'name' => 'overview-service', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => 'services: {}', ]); $token = mcpReadToken(); $response = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$token, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'resources/read', 'params' => [ 'uri' => 'coolify://overview', ], ]); $response->assertOk(); $text = collect($response->json('result.contents'))->pluck('text')->first(); expect($text)->not->toBeNull(); $body = json_decode($text, true); expect($body)->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']); expect($body['counts']['projects'])->toBe(1); $project = collect($body['projects'])->firstWhere('uuid', $this->project->uuid); expect($project)->not->toBeNull(); expect($project['counts'])->toMatchArray([ 'applications' => 1, 'services' => 1, 'databases' => 1, ]); }); test('get_deployment and cancel_deployment work for soft-deleted applications', function () { Process::fake([ '*' => Process::result(output: ''), ]); $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, 'deployment_uuid' => 'dep-soft-delete-'.fake()->uuid(), 'status' => 'in_progress', 'server_id' => $this->server->id, 'application_name' => $this->application->name, 'server_name' => $this->server->name, 'commit' => 'abc123', ]); $deployToken = $this->user->createToken('mcp-soft-cancel', ['read', 'deploy'])->plainTextToken; $this->application->delete(); expect(Application::withTrashed()->find($this->application->id))->not->toBeNull(); expect(Application::find($this->application->id))->toBeNull(); // get_deployment still resolves soft-deleted applications for the team. $get = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$deployToken, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'get_deployment', 'arguments' => (object) ['uuid' => $deployment->deployment_uuid], ], ]); $get->assertOk(); expect($get->json('result.isError'))->toBeFalse(); $getBody = mcpReadJson($get); expect($getBody['data']['deployment_uuid'])->toBe($deployment->deployment_uuid) ->and($getBody['data']['application_uuid'])->toBe($this->application->uuid); $cancel = test()->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer '.$deployToken, ])->postJson('/mcp', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'cancel_deployment', 'arguments' => (object) ['uuid' => $deployment->deployment_uuid], ], ]); $cancel->assertOk(); expect($cancel->json('result.isError'))->toBeFalse(); expect($deployment->fresh()->status)->toBe('cancelled-by-user'); }); test('list_databases paginates at the query layer', function () { foreach (['alpha-db', 'beta-db', 'gamma-db'] as $name) { StandalonePostgresql::create([ 'name' => $name, 'status' => 'running:healthy', 'postgres_password' => 'password', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); } $page1 = mcpReadCall('list_databases', ['per_page' => 2, 'page' => 1]); $page1->assertOk(); $body1 = mcpReadJson($page1); expect($body1['_pagination']['total'])->toBe(3) ->and($body1['data'])->toHaveCount(2) ->and($body1['data'][0]['name'])->toBe('alpha-db') ->and($body1['data'][1]['name'])->toBe('beta-db'); $page2 = mcpReadCall('list_databases', ['per_page' => 2, 'page' => 2]); $page2->assertOk(); $body2 = mcpReadJson($page2); expect($body2['data'])->toHaveCount(1) ->and($body2['data'][0]['name'])->toBe('gamma-db'); $page1Uuids = collect($body1['data'])->pluck('uuid')->all(); $page2Uuids = collect($body2['data'])->pluck('uuid')->all(); expect(array_intersect($page1Uuids, $page2Uuids))->toBe([]); }); test('list_unhealthy_resources full mode paginates without dropping summary totals', function () { $this->application->update(['name' => 'AppA', 'status' => 'exited:unhealthy']); Application::factory()->create([ 'name' => 'AppB', 'status' => 'exited:unhealthy', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); Application::factory()->create([ 'name' => 'AppC', 'status' => 'exited:unhealthy', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); // Ensure the default server is not counted as unhealthy (factory settings vary). $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $page1 = mcpReadCall('list_unhealthy_resources', [ 'sample_only' => false, 'per_page' => 2, 'page' => 1, ]); $page1->assertOk(); $body1 = mcpReadJson($page1); expect($body1['data']['summary']['applications'])->toBe(3) ->and($body1['data']['summary']['servers'])->toBe(0) ->and($body1['_pagination']['total'])->toBe(3) ->and($body1['data']['unhealthy'])->toHaveCount(2); $page1Names = collect($body1['data']['unhealthy'])->pluck('name')->all(); expect($page1Names)->toBe(['AppA', 'AppB']); $page2 = mcpReadCall('list_unhealthy_resources', [ 'sample_only' => false, 'per_page' => 2, 'page' => 2, ]); $page2->assertOk(); $body2 = mcpReadJson($page2); expect($body2['data']['unhealthy'])->toHaveCount(1) ->and($body2['data']['unhealthy'][0]['name'])->toBe('AppC'); }); test('list_unhealthy_resources full mode paginates services without dropping summary totals', function () { // Keep apps/servers healthy so the page window is pure services. $this->application->update(['status' => 'running:healthy']); $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); // Empty services have no running status → treated as unhealthy by the status scan. foreach (['SvcA', 'SvcB', 'SvcC', 'SvcD', 'SvcE'] as $name) { Service::factory()->create([ 'name' => $name, 'environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); } $page1 = mcpReadCall('list_unhealthy_resources', [ 'sample_only' => false, 'per_page' => 2, 'page' => 1, ]); $page1->assertOk(); $body1 = mcpReadJson($page1); expect($body1['data']['summary']['services'])->toBe(5) ->and($body1['data']['summary']['total'])->toBe(5) ->and($body1['_pagination']['total'])->toBe(5) ->and($body1['data']['unhealthy'])->toHaveCount(2) ->and(collect($body1['data']['unhealthy'])->pluck('name')->all())->toBe(['SvcA', 'SvcB']) ->and(collect($body1['data']['unhealthy'])->pluck('type')->unique()->all())->toBe(['service']); $page3 = mcpReadCall('list_unhealthy_resources', [ 'sample_only' => false, 'per_page' => 2, 'page' => 3, ]); $page3->assertOk(); $body3 = mcpReadJson($page3); expect($body3['data']['summary']['services'])->toBe(5) ->and($body3['data']['unhealthy'])->toHaveCount(1) ->and($body3['data']['unhealthy'][0]['name'])->toBe('SvcE'); }); test('get_service_database returns a field whitelist and is team scoped', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services:\n db:\n image: postgres:16\n", ]); $db = ServiceDatabase::create([ 'uuid' => (string) Str::uuid(), 'name' => 'db', 'human_name' => 'Database', 'description' => 'Primary DB', 'service_id' => $service->id, 'image' => 'postgres:16', 'status' => 'running:healthy', ]); $response = mcpReadCall('get_service_database', [ 'service_uuid' => $service->uuid, 'uuid' => $db->uuid, ]); $response->assertOk(); $data = mcpReadJson($response)['data']; expect($data['uuid'])->toBe($db->uuid) ->and($data['service_uuid'])->toBe($service->uuid) ->and($data['name'])->toBe('db') ->and($data)->toHaveKeys([ 'uuid', 'service_uuid', 'name', 'human_name', 'description', 'status', 'image', 'created_at', 'updated_at', ]) ->and($data)->not->toHaveKey('id') ->and($data)->not->toHaveKey('service_id') ->and($data)->not->toHaveKey('is_migrated'); $otherTeam = Team::factory()->create(); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnv = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]); $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); $otherService = Service::factory()->create([ 'environment_id' => $otherEnv->id, 'server_id' => $otherServer->id, 'destination_id' => $otherDest->id, 'destination_type' => $otherDest->getMorphClass(), ]); $otherDb = ServiceDatabase::create([ 'uuid' => (string) Str::uuid(), 'name' => 'theirs', 'service_id' => $otherService->id, 'image' => 'postgres:16', ]); $denied = mcpReadCall('get_service_database', [ 'service_uuid' => $otherService->uuid, 'uuid' => $otherDb->uuid, ]); expect($denied->json('result.isError'))->toBeTrue(); }); test('list_scheduled_tasks omits command without sensitive read ability', function () { ScheduledTask::create([ 'uuid' => (string) Str::uuid(), 'name' => 'nightly-backup', 'command' => 'pg_dump --flag=redactme01', 'frequency' => '0 2 * * *', 'enabled' => true, 'timeout' => 3600, 'team_id' => $this->team->id, 'application_id' => $this->application->id, ]); $readOnly = mcpReadCall('list_scheduled_tasks', [ 'resource' => 'application', 'uuid' => $this->application->uuid, ]); $readOnly->assertOk(); $tasks = mcpReadJson($readOnly)['data']['tasks']; expect($tasks)->toHaveCount(1) ->and($tasks[0]['name'])->toBe('nightly-backup') ->and($tasks[0]['command_included'])->toBeFalse() ->and($tasks[0])->not->toHaveKey('command'); expect(json_encode($tasks))->not->toContain('redactme01'); $sensitive = mcpSensitiveReadCall('list_scheduled_tasks', [ 'resource' => 'application', 'uuid' => $this->application->uuid, ]); $sensitive->assertOk(); expect($sensitive->json('result.isError'))->toBeFalse(); $sensitiveBody = mcpReadJson($sensitive); expect($sensitiveBody['data']['command_included'])->toBeTrue(); $sensitiveTasks = $sensitiveBody['data']['tasks']; expect($sensitiveTasks[0]['command_included'])->toBeTrue() ->and($sensitiveTasks[0]['command'])->toContain('pg_dump'); }); test('list_scheduled_task_executions returns newest first across pages', function () { $task = ScheduledTask::create([ 'uuid' => (string) Str::uuid(), 'name' => 'exec-history', 'command' => 'echo ok', 'frequency' => '0 1 * * *', 'enabled' => true, 'timeout' => 60, 'team_id' => $this->team->id, 'application_id' => $this->application->id, ]); $older = ScheduledTaskExecution::create([ 'scheduled_task_id' => $task->id, 'status' => 'success', 'message' => 'older-run', 'started_at' => now()->subHours(2), 'finished_at' => now()->subHours(2)->addMinute(), 'created_at' => now()->subHours(2), 'updated_at' => now()->subHours(2), ]); $newer = ScheduledTaskExecution::create([ 'scheduled_task_id' => $task->id, 'status' => 'failed', 'message' => 'newer-run', 'started_at' => now()->subHour(), 'finished_at' => now()->subHour()->addMinute(), 'created_at' => now()->subHour(), 'updated_at' => now()->subHour(), ]); $page1 = mcpReadCall('list_scheduled_task_executions', [ 'resource' => 'application', 'uuid' => $this->application->uuid, 'task_uuid' => $task->uuid, 'page' => 1, 'per_page' => 1, ]); $page1->assertOk(); $page1Body = mcpReadJson($page1); expect($page1Body['data']['executions'])->toHaveCount(1) ->and($page1Body['data']['message_included'])->toBeFalse() ->and($page1Body['data']['executions'][0])->not->toHaveKey('message') ->and($page1Body['data']['executions'][0]['status'])->toBe('failed') ->and($page1Body['_pagination']['total'])->toBe(2); $page2 = mcpReadCall('list_scheduled_task_executions', [ 'resource' => 'application', 'uuid' => $this->application->uuid, 'task_uuid' => $task->uuid, 'page' => 2, 'per_page' => 1, ]); $page2->assertOk(); $page2Body = mcpReadJson($page2); expect($page2Body['data']['executions'][0]['status'])->toBe('success') ->and($page2Body['data']['executions'][0])->not->toHaveKey('message'); $sensitive = mcpSensitiveReadCall('list_scheduled_task_executions', [ 'resource' => 'application', 'uuid' => $this->application->uuid, 'task_uuid' => $task->uuid, 'page' => 1, 'per_page' => 1, ]); $sensitive->assertOk(); $sensitiveBody = mcpReadJson($sensitive); expect($sensitiveBody['data']['message_included'])->toBeTrue() ->and($sensitiveBody['data']['executions'][0]['message'])->toBe('newer-run'); // Silence unused variable analysis when timestamps are forced via create attributes. expect($older->id)->not->toBe($newer->id); }); test('list_scheduled_task_executions redacts secret-like values in messages with sensitive read', function () { $task = ScheduledTask::create([ 'uuid' => (string) Str::uuid(), 'name' => 'exec-redact', 'command' => 'echo ok', 'frequency' => '0 1 * * *', 'enabled' => true, 'timeout' => 60, 'team_id' => $this->team->id, 'application_id' => $this->application->id, ]); ScheduledTaskExecution::create([ 'scheduled_task_id' => $task->id, 'status' => 'failed', 'message' => "backup failed password=redactme01\n", 'started_at' => now()->subMinute(), 'finished_at' => now(), ]); $response = mcpSensitiveReadCall('list_scheduled_task_executions', [ 'resource' => 'application', 'uuid' => $this->application->uuid, 'task_uuid' => $task->uuid, ]); $response->assertOk(); $body = mcpReadJson($response); $message = $body['data']['executions'][0]['message'] ?? ''; expect($body['data']['message_included'])->toBeTrue() ->and($message)->not->toContain('redactme01') ->and($message)->toContain('password=') ->and($message)->toContain(REDACTED); }); test('get_logs redacts secret-like values in container output', function () { // Preflight fails for non-running apps, so exercise redaction via the shared helper path // through get_deployment (already covered) and unit-level BuildsResponse redaction. // Use low-entropy test markers so secret scanners do not flag fixtures. $trait = new class { use BuildsResponse; public function redact(string $text): string { return $this->redactLogText($text); } }; $redacted = $trait->redact("boot ok\npassword=redactme01\nAPI_TOKEN=redactme02\n"); expect($redacted)->toContain('boot ok') ->and($redacted)->not->toContain('redactme01') ->and($redacted)->not->toContain('redactme02') ->and($redacted)->toContain('password=') ->and($redacted)->toContain(REDACTED); }); test('redactLogText redacts JSON secret fields in log lines', function () { $trait = new class { use BuildsResponse; public function redact(string $text): string { return $this->redactLogText($text); } }; $jsonLine = '{"token":"redactme01","API_KEY":"redactme02","status":"ok"}'; $redacted = $trait->redact("request failed: {$jsonLine}"); expect($redacted)->toContain('status') ->and($redacted)->toContain('ok') ->and($redacted)->not->toContain('redactme01') ->and($redacted)->not->toContain('redactme02') ->and($redacted)->toContain('token=') ->and($redacted)->toContain('API_KEY=') ->and($redacted)->toContain(REDACTED); // Shell-style still works alongside JSON $mixed = $trait->redact('password=redactme01 {"client_secret":"redactme02"} export DB_PASSWORD=redactme03'); expect($mixed)->not->toContain('redactme01') ->and($mixed)->not->toContain('redactme02') ->and($mixed)->not->toContain('redactme03') ->and($mixed)->toContain(REDACTED); });