Merge remote-tracking branch 'origin/next' into 9916-investigate-undefined-relationship

This commit is contained in:
Andras Bacsai committed 2026-05-06 14:32:16 +02:00
commit 29d41ba041
34 files changed
+2004 -174

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
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]);
});
function mcpPost(array $payload, ?string $token = null)
{
$headers = [
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
];
if ($token) {
$headers['Authorization'] = 'Bearer '.$token;
}
return test()->withHeaders($headers)->postJson('/mcp', $payload);
}
function mcpListTools(string $token)
{
return mcpPost([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/list',
'params' => (object) [],
], $token);
}
function mcpCallTool(string $token, string $name, array $arguments = [])
{
return mcpPost([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => $name,
'arguments' => (object) $arguments,
],
], $token);
}
function mcpToolJson($response): array
{
return json_decode($response->json('result.content.0.text'), true);
}
test('MCP endpoint returns 404 when the instance setting is disabled', function () {
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]);
Once::flush();
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(404);
});
test('MCP endpoint rejects unauthenticated requests', function () {
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(401);
});
test('MCP endpoint lists tools for an authenticated token', function () {
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpListTools($token);
$response->assertOk();
$toolNames = collect($response->json('result.tools'))->pluck('name')->all();
expect($toolNames)->toContain(
'get_infrastructure_overview',
'list_servers',
'get_server',
'list_projects',
'list_applications',
'get_application',
'list_databases',
'get_database',
'list_services',
'get_service',
);
expect($toolNames)->not->toContain('get_resource_status');
});
test('list_projects returns summary + pagination scoped to the token team', function () {
$project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]);
$otherTeam = Team::factory()->create();
Project::create(['name' => 'Theirs', 'team_id' => $otherTeam->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'list_projects');
$response->assertOk();
$body = mcpToolJson($response);
expect($body)->toHaveKey('data');
expect($body)->toHaveKey('_pagination');
expect($body['_pagination']['total'])->toBe(1);
expect($body['_pagination']['per_page'])->toBe(50);
expect($body['_pagination'])->not->toHaveKey('next');
$uuids = collect($body['data'])->pluck('uuid')->all();
$names = collect($body['data'])->pluck('name')->all();
expect($uuids)->toContain($project->uuid);
expect($names)->not->toContain('Theirs');
expect($body['data'][0])->toHaveKeys(['uuid', 'name', 'description']);
});
test('list_projects paginates with per_page cap at 100', function () {
for ($i = 0; $i < 3; $i++) {
Project::create(['name' => "P{$i}", 'team_id' => $this->team->id]);
}
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'list_projects', ['per_page' => 2, 'page' => 1]);
$body = mcpToolJson($response);
expect($body['_pagination']['total'])->toBe(3);
expect($body['_pagination']['total_pages'])->toBe(2);
expect($body['_pagination']['next']['args'])->toMatchArray(['page' => 2, 'per_page' => 2]);
expect($body['data'])->toHaveCount(2);
// Verify max cap
$capped = mcpCallTool($token, 'list_projects', ['per_page' => 500]);
$cappedBody = mcpToolJson($capped);
expect($cappedBody['_pagination']['per_page'])->toBe(100);
});
test('get_infrastructure_overview returns counts', function () {
Project::create(['name' => 'One', 'team_id' => $this->team->id]);
Project::create(['name' => 'Two', 'team_id' => $this->team->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'get_infrastructure_overview');
$response->assertOk();
$body = mcpToolJson($response);
expect($body)->toHaveKey('data');
expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']);
expect($body['data']['counts']['projects'])->toBe(2);
expect($body['data']['projects'])->toHaveCount(2);
expect($body['data']['projects'][0])->toHaveKey('counts');
});
test('get_server scrubs sensitive nested data and exposes connection_timeout', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
// creating hook auto-generates a sentinel_token; bump connection_timeout
// via saveQuietly to avoid triggering restartSentinel.
$server->settings->forceFill(['connection_timeout' => 42])->saveQuietly();
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'get_server', ['uuid' => $server->uuid]);
$response->assertOk();
$body = mcpToolJson($response);
$raw = json_encode($body);
expect($raw)->not->toContain('sentinel_token');
expect($raw)->not->toContain('"team_id"');
expect($raw)->not->toContain('"private_key_id"');
expect($body['data']['connection_timeout'])->toBe(42);
expect($body['data']['uuid'])->toBe($server->uuid);
});
test('tool calls fail when the token lacks the read ability', function () {
$token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken;
$response = mcpCallTool($token, 'list_projects');
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue();
expect($response->json('result.content.0.text'))->toContain('Missing required permissions');
});
+107
View File
@@ -0,0 +1,107 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->delete();
$settings = new InstanceSettings([
'is_mcp_server_enabled' => false,
'is_api_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]);
});
function makeRootMcpToken(User $user): string
{
$token = $user->createToken('mcp-root', ['root']);
DB::table('personal_access_tokens')
->where('id', $token->accessToken->id)
->update(['team_id' => '0']);
return $token->plainTextToken;
}
function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write']): string
{
$token = $user->createToken('mcp-write', $abilities);
DB::table('personal_access_tokens')
->where('id', $token->accessToken->id)
->update(['team_id' => (string) $team->id]);
return $token->plainTextToken;
}
test('POST /api/v1/mcp/enable enables MCP server with root token', function () {
$token = makeRootMcpToken($this->user);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/enable');
$response->assertOk();
$response->assertJson(['message' => 'MCP server enabled.']);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
});
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,
])->postJson('/api/v1/mcp/disable');
$response->assertOk();
$response->assertJson(['message' => 'MCP server disabled.']);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
});
test('non-root token cannot enable MCP server', function () {
$token = makeNonRootMcpToken($this->user, $this->team);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/enable');
$response->assertStatus(403);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
});
test('non-root token cannot disable MCP server', function () {
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
$token = makeNonRootMcpToken($this->user, $this->team);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->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()->postJson('/api/v1/mcp/enable');
$response->assertStatus(401);
});
test('read-only token cannot toggle MCP server (lacks write ability)', function () {
$token = makeNonRootMcpToken($this->user, $this->team, ['read']);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/enable');
$response->assertStatus(403);
});
@@ -0,0 +1,70 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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");
}
});
@@ -0,0 +1,71 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});
@@ -0,0 +1,45 @@
<?php
use App\Models\StandaloneDocker;
use Illuminate\Database\Eloquent\Model;
/**
* Guards STANDALONE_DATABASE_MODELS against drift.
*
* MCP and API endpoints rely on this registry for team-scoped UUID lookups.
* If a new App\Models\Standalone* model lands without a registry entry, the
* helpers in bootstrap/helpers/shared.php silently fail to resolve it.
*/
test('STANDALONE_DATABASE_MODELS contains every Standalone* model on disk', function () {
$files = glob(dirname(__DIR__, 2).'/app/Models/Standalone*.php');
expect($files)->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()");
}
});