diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 17c5a884a..f17a4e46b 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -153,7 +153,7 @@ public function disable_api(Request $request) return response()->json(['message' => 'API disabled.'], 200); } - #[OA\Get( + #[OA\Post( summary: 'Enable MCP Server', description: 'Enable the MCP server endpoint at /mcp (only with root permissions).', path: '/mcp/enable', @@ -209,7 +209,7 @@ public function enable_mcp(Request $request) return response()->json(['message' => 'MCP server enabled.'], 200); } - #[OA\Get( + #[OA\Post( summary: 'Disable MCP Server', description: 'Disable the MCP server endpoint at /mcp (only with root permissions).', path: '/mcp/disable', diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php index 947d49ed1..815edd61a 100644 --- a/app/Mcp/Tools/ListApplications.php +++ b/app/Mcp/Tools/ListApplications.php @@ -31,10 +31,13 @@ public function handle(Request $request): Response } $tagName = $request->get('tag'); + if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) { + return Response::error('tag argument must be a non-empty string.'); + } $args = $this->paginationArgs($request); $query = Application::ownedByCurrentTeamAPI($teamId) - ->when($tagName, function ($query, $tagName) { + ->when($tagName !== null, function ($query) use ($tagName) { $query->whereHas('tags', fn ($q) => $q->where('name', $tagName)); }); diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php index 4b33231ba..b0bff4fad 100644 --- a/app/Mcp/Tools/ListServices.php +++ b/app/Mcp/Tools/ListServices.php @@ -4,7 +4,7 @@ use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; -use App\Models\Project; +use App\Models\Service; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -32,17 +32,15 @@ public function handle(Request $request): Response $args = $this->paginationArgs($request); - $projects = Project::where('team_id', $teamId)->get(); - $services = collect(); - foreach ($projects as $project) { - $services = $services->merge($project->services()->get()); - } + $query = Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId)); - $total = $services->count(); + $total = (clone $query)->count(); - $summaries = $services - ->sortBy('name') - ->slice($args['offset'], $args['per_page']) + $summaries = $query + ->orderBy('name') + ->skip($args['offset']) + ->take($args['per_page']) + ->get() ->map(fn ($svc) => [ 'uuid' => $svc->uuid, 'name' => $svc->name, diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index d6b4d1a1c..d12a15a7c 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -134,8 +134,11 @@ public function databases() $mongodbs = $this->mongodbs; $mysqls = $this->mysqls; $mariadbs = $this->mariadbs; + $keydbs = $this->keydbs; + $dragonflies = $this->dragonflies; + $clickhouses = $this->clickhouses; - return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs); + return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses); } public function attachedTo() diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index bae2573de..043a3346d 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -1,7 +1,26 @@ '; const DATABASE_TYPES = ['postgresql', 'redis', 'mongodb', 'mysql', 'mariadb', 'keydb', 'dragonfly', 'clickhouse']; +const STANDALONE_DATABASE_MODELS = [ + 'postgresql' => StandalonePostgresql::class, + 'redis' => StandaloneRedis::class, + 'mongodb' => StandaloneMongodb::class, + 'mysql' => StandaloneMysql::class, + 'mariadb' => StandaloneMariadb::class, + 'keydb' => StandaloneKeydb::class, + 'dragonfly' => StandaloneDragonfly::class, + 'clickhouse' => StandaloneClickhouse::class, +]; const VALID_CRON_STRINGS = [ 'every_minute' => '* * * * *', 'hourly' => '0 * * * *', diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 9f0f2cd73..f76995c6f 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1058,44 +1058,17 @@ function getResourceByUuid(string $uuid, ?int $teamId = null) } function queryDatabaseByUuidWithinTeam(string $uuid, string $teamId) { - $postgresql = StandalonePostgresql::whereUuid($uuid)->first(); - if ($postgresql && $postgresql->team()->id == $teamId) { - return $postgresql->unsetRelation('environment'); - } - $redis = StandaloneRedis::whereUuid($uuid)->first(); - if ($redis && $redis->team()->id == $teamId) { - return $redis->unsetRelation('environment'); - } - $mongodb = StandaloneMongodb::whereUuid($uuid)->first(); - if ($mongodb && $mongodb->team()->id == $teamId) { - return $mongodb->unsetRelation('environment'); - } - $mysql = StandaloneMysql::whereUuid($uuid)->first(); - if ($mysql && $mysql->team()->id == $teamId) { - return $mysql->unsetRelation('environment'); - } - $mariadb = StandaloneMariadb::whereUuid($uuid)->first(); - if ($mariadb && $mariadb->team()->id == $teamId) { - return $mariadb->unsetRelation('environment'); - } - $keydb = StandaloneKeydb::whereUuid($uuid)->first(); - if ($keydb && $keydb->team()->id == $teamId) { - return $keydb->unsetRelation('environment'); - } - $dragonfly = StandaloneDragonfly::whereUuid($uuid)->first(); - if ($dragonfly && $dragonfly->team()->id == $teamId) { - return $dragonfly->unsetRelation('environment'); - } - $clickhouse = StandaloneClickhouse::whereUuid($uuid)->first(); - if ($clickhouse && $clickhouse->team()->id == $teamId) { - return $clickhouse->unsetRelation('environment'); + foreach (STANDALONE_DATABASE_MODELS as $modelClass) { + $database = $modelClass::whereUuid($uuid)->first(); + if ($database && $database->team()->id == $teamId) { + return $database->unsetRelation('environment'); + } } return null; } function queryResourcesByUuid(string $uuid) { - $resource = null; $application = Application::whereUuid($uuid)->first(); if ($application) { return $application; @@ -1104,37 +1077,11 @@ function queryResourcesByUuid(string $uuid) if ($service) { return $service; } - $postgresql = StandalonePostgresql::whereUuid($uuid)->first(); - if ($postgresql) { - return $postgresql; - } - $redis = StandaloneRedis::whereUuid($uuid)->first(); - if ($redis) { - return $redis; - } - $mongodb = StandaloneMongodb::whereUuid($uuid)->first(); - if ($mongodb) { - return $mongodb; - } - $mysql = StandaloneMysql::whereUuid($uuid)->first(); - if ($mysql) { - return $mysql; - } - $mariadb = StandaloneMariadb::whereUuid($uuid)->first(); - if ($mariadb) { - return $mariadb; - } - $keydb = StandaloneKeydb::whereUuid($uuid)->first(); - if ($keydb) { - return $keydb; - } - $dragonfly = StandaloneDragonfly::whereUuid($uuid)->first(); - if ($dragonfly) { - return $dragonfly; - } - $clickhouse = StandaloneClickhouse::whereUuid($uuid)->first(); - if ($clickhouse) { - return $clickhouse; + foreach (STANDALONE_DATABASE_MODELS as $modelClass) { + $database = $modelClass::whereUuid($uuid)->first(); + if ($database) { + return $database; + } } // Check for ServiceDatabase by its own UUID @@ -1143,7 +1090,7 @@ function queryResourcesByUuid(string $uuid) return $serviceDatabase; } - return $resource; + return null; } function generateTagDeployWebhook($tag_name) { diff --git a/openapi.json b/openapi.json index c35c0cdd6..711c7d8f3 100644 --- a/openapi.json +++ b/openapi.json @@ -8651,7 +8651,7 @@ } }, "\/mcp\/enable": { - "get": { + "post": { "summary": "Enable MCP Server", "description": "Enable the MCP server endpoint at \/mcp (only with root permissions).", "operationId": "enable-mcp", @@ -8703,7 +8703,7 @@ } }, "\/mcp\/disable": { - "get": { + "post": { "summary": "Disable MCP Server", "description": "Disable the MCP server endpoint at \/mcp (only with root permissions).", "operationId": "disable-mcp", diff --git a/openapi.yaml b/openapi.yaml index eab531674..fef77f5a7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5485,7 +5485,7 @@ paths: - bearerAuth: [] /mcp/enable: - get: + post: summary: 'Enable MCP Server' description: 'Enable the MCP server endpoint at /mcp (only with root permissions).' operationId: enable-mcp @@ -5514,7 +5514,7 @@ paths: - bearerAuth: [] /mcp/disable: - get: + post: summary: 'Disable MCP Server' description: 'Disable the MCP server endpoint at /mcp (only with root permissions).' operationId: disable-mcp diff --git a/routes/api.php b/routes/api.php index f38576d4d..38ded350a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -35,8 +35,8 @@ ], function () { Route::get('/enable', [OtherController::class, 'enable_api']); Route::get('/disable', [OtherController::class, 'disable_api']); - Route::get('/mcp/enable', [OtherController::class, 'enable_mcp']); - Route::get('/mcp/disable', [OtherController::class, 'disable_mcp']); + Route::post('/mcp/enable', [OtherController::class, 'enable_mcp']); + Route::post('/mcp/disable', [OtherController::class, 'disable_mcp']); }); Route::group([ 'middleware' => ['auth:sanctum', ApiAllowed::class, 'api.sensitive'], diff --git a/tests/Feature/Mcp/McpToggleApiTest.php b/tests/Feature/Mcp/McpToggleApiTest.php index 2700f9b7b..68d5d335a 100644 --- a/tests/Feature/Mcp/McpToggleApiTest.php +++ b/tests/Feature/Mcp/McpToggleApiTest.php @@ -43,25 +43,25 @@ function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write' return $token->plainTextToken; } -test('GET /api/v1/mcp/enable enables MCP server with root token', function () { +test('POST /api/v1/mcp/enable enables MCP server with root token', function () { $token = makeRootMcpToken($this->user); $response = test()->withHeaders([ 'Authorization' => 'Bearer '.$token, - ])->getJson('/api/v1/mcp/enable'); + ])->postJson('/api/v1/mcp/enable'); $response->assertOk(); $response->assertJson(['message' => 'MCP server enabled.']); expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); }); -test('GET /api/v1/mcp/disable disables MCP server with root token', function () { +test('POST /api/v1/mcp/disable disables MCP server with root token', function () { InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]); $token = makeRootMcpToken($this->user); $response = test()->withHeaders([ 'Authorization' => 'Bearer '.$token, - ])->getJson('/api/v1/mcp/disable'); + ])->postJson('/api/v1/mcp/disable'); $response->assertOk(); $response->assertJson(['message' => 'MCP server disabled.']); @@ -73,7 +73,7 @@ function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write' $response = test()->withHeaders([ 'Authorization' => 'Bearer '.$token, - ])->getJson('/api/v1/mcp/enable'); + ])->postJson('/api/v1/mcp/enable'); $response->assertStatus(403); expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); @@ -85,14 +85,14 @@ function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write' $response = test()->withHeaders([ 'Authorization' => 'Bearer '.$token, - ])->getJson('/api/v1/mcp/disable'); + ])->postJson('/api/v1/mcp/disable'); $response->assertStatus(403); expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); }); test('unauthenticated request to /api/v1/mcp/enable returns 401', function () { - $response = test()->getJson('/api/v1/mcp/enable'); + $response = test()->postJson('/api/v1/mcp/enable'); $response->assertStatus(401); }); @@ -101,7 +101,7 @@ function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write' $response = test()->withHeaders([ 'Authorization' => 'Bearer '.$token, - ])->getJson('/api/v1/mcp/enable'); + ])->postJson('/api/v1/mcp/enable'); $response->assertStatus(403); }); diff --git a/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php b/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php new file mode 100644 index 000000000..db7eb16b2 --- /dev/null +++ b/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php @@ -0,0 +1,70 @@ +teamA = Team::factory()->create(); + $this->teamB = Team::factory()->create(); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->first(); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->envA = Environment::factory()->create(['project_id' => $this->projectA->id]); +}); + +test('queryDatabaseByUuidWithinTeam returns database when team owns it', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg-team-a', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->envA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamA->id); + + expect($found)->not->toBeNull(); + expect($found->uuid)->toBe($database->uuid); + expect($found)->toBeInstanceOf(StandalonePostgresql::class); +}); + +test('queryDatabaseByUuidWithinTeam returns null when team does not own the database', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg-team-a', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->envA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamB->id); + + expect($found)->toBeNull(); +}); + +test('queryDatabaseByUuidWithinTeam returns null for unknown uuid', function () { + $found = queryDatabaseByUuidWithinTeam('does-not-exist', (string) $this->teamA->id); + + expect($found)->toBeNull(); +}); + +test('queryDatabaseByUuidWithinTeam can query every registered standalone database type without error', function () { + foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) { + $count = $modelClass::query()->whereUuid('non-existent-uuid')->count(); + expect($count)->toBe(0, "{$modelClass} ({$slug}) failed whereUuid() smoke query"); + } +}); diff --git a/tests/Feature/StandaloneDockerDatabasesTest.php b/tests/Feature/StandaloneDockerDatabasesTest.php new file mode 100644 index 000000000..8d7889149 --- /dev/null +++ b/tests/Feature/StandaloneDockerDatabasesTest.php @@ -0,0 +1,71 @@ +team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function attachDb(string $modelClass, array $extra, $destination, $environment) +{ + return $modelClass::create(array_merge([ + 'name' => 'test-'.strtolower(class_basename($modelClass)), + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ], $extra)); +} + +test('StandaloneDocker::databases() includes attached keydb', function () { + attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(1); + expect($this->destination->attachedTo())->toBeTrue(); +}); + +test('StandaloneDocker::databases() includes attached dragonfly', function () { + attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(1); + expect($this->destination->attachedTo())->toBeTrue(); +}); + +test('StandaloneDocker::databases() includes attached clickhouse', function () { + attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(1); + expect($this->destination->attachedTo())->toBeTrue(); +}); + +test('StandaloneDocker::databases() includes all 8 standalone database types', function () { + attachDb(StandalonePostgresql::class, ['postgres_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneRedis::class, ['redis_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneMongodb::class, ['mongo_initdb_root_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneMysql::class, ['mysql_root_password' => 'pw', 'mysql_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneMariadb::class, ['mariadb_root_password' => 'pw', 'mariadb_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(8); + expect($this->destination->attachedTo())->toBeTrue(); +}); diff --git a/tests/Unit/StandaloneDatabaseRegistryTest.php b/tests/Unit/StandaloneDatabaseRegistryTest.php new file mode 100644 index 000000000..7c56d5f8d --- /dev/null +++ b/tests/Unit/StandaloneDatabaseRegistryTest.php @@ -0,0 +1,45 @@ +not->toBeEmpty(); + + $onDisk = collect($files) + ->map(fn (string $path) => 'App\\Models\\'.basename($path, '.php')) + ->reject(fn (string $class) => $class === StandaloneDocker::class) + ->sort() + ->values() + ->all(); + + $registered = collect(STANDALONE_DATABASE_MODELS)->values()->sort()->values()->all(); + + expect($registered)->toBe( + $onDisk, + 'STANDALONE_DATABASE_MODELS in bootstrap/helpers/constants.php is out of sync with the App\\Models\\Standalone* classes on disk. ' + .'Add the missing model(s) to the registry (and to DATABASE_TYPES) so MCP/API helpers can resolve them.' + ); +}); + +test('STANDALONE_DATABASE_MODELS keys mirror DATABASE_TYPES', function () { + expect(array_keys(STANDALONE_DATABASE_MODELS))->toEqualCanonicalizing(DATABASE_TYPES); +}); + +test('every STANDALONE_DATABASE_MODELS entry is an Eloquent model with whereUuid scope', function () { + foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) { + expect(class_exists($modelClass))->toBeTrue("{$slug} maps to non-existent class {$modelClass}"); + expect(is_subclass_of($modelClass, Model::class)) + ->toBeTrue("{$modelClass} is not an Eloquent model"); + expect(method_exists($modelClass, 'team')) + ->toBeTrue("{$modelClass} is missing team() accessor required by queryDatabaseByUuidWithinTeam()"); + } +});