mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
1015 lines
54 KiB
PHP
1015 lines
54 KiB
PHP
<?php
|
|
|
|
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
|
|
use App\Jobs\DockerCleanupJob;
|
|
use App\Jobs\RestartProxyJob;
|
|
use App\Models\Application;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\CloudProviderToken;
|
|
use App\Models\DiscordNotificationSettings;
|
|
use App\Models\DnsProviderZone;
|
|
use App\Models\Environment;
|
|
use App\Models\GithubApp;
|
|
use App\Models\GitlabApp;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\IntegrationToken;
|
|
use App\Models\ManagedDnsRecord;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\S3Storage;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use App\Models\TelegramNotificationSettings;
|
|
use App\Models\User;
|
|
use App\Services\SentinelMetricsClient;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Once;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
const MCP_SRV_NEW_PRIVATE_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----
|
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
|
QyNTUxOQAAACAuIzppW7xQGiSHBiMiQxJlsTq+XfUhnrUCHwYbbaLF/QAAAJDAOcCswDnA
|
|
rAAAAAtzc2gtZWQyNTUxOQAAACAuIzppW7xQGiSHBiMiQxJlsTq+XfUhnrUCHwYbbaLF/Q
|
|
AAAECKtKd242pj0qdKWaOpXAQFI2ZGLQOg9vCP2+o+LPLMtS4jOmlbvFAaJIcGIyJDEmWx
|
|
Or5d9SGetQIfBhttosX9AAAACG1jcC10ZXN0AQIDBAU=
|
|
-----END OPENSSH PRIVATE KEY-----';
|
|
|
|
class McpSrvStubMetricsClient extends SentinelMetricsClient
|
|
{
|
|
public static int $calls = 0;
|
|
|
|
public function summary(): ?array
|
|
{
|
|
self::$calls++;
|
|
|
|
return [
|
|
'online' => true, 'cpu' => 42.5, 'memUsed' => 1024, 'memTotal' => 4096,
|
|
'diskUsed' => 10, 'diskTotal' => 100, 'diskPercent' => 10.0,
|
|
'load1' => 0.5, 'netRx' => 5.0, 'netTx' => 1.0,
|
|
];
|
|
}
|
|
|
|
public function history(string $metric, string $range): array
|
|
{
|
|
self::$calls++;
|
|
if ($metric === 'disk') {
|
|
throw new RuntimeException('Disk endpoint unavailable.');
|
|
}
|
|
|
|
return [
|
|
[now()->subHours(3)->getTimestampMs(), 1.0],
|
|
[now()->subMinutes(10)->getTimestampMs(), $metric === 'cpu' ? 42.5 : 1024.0],
|
|
];
|
|
}
|
|
|
|
public function networkHistory(string $range): array
|
|
{
|
|
self::$calls++;
|
|
|
|
return ['rx' => [[now()->subMinutes(5)->getTimestampMs(), 5.0]], 'tx' => [[now()->subMinutes(5)->getTimestampMs(), 1.0]]];
|
|
}
|
|
}
|
|
|
|
beforeEach(function () {
|
|
Server::flushIdentityMap();
|
|
Queue::fake();
|
|
Process::fake();
|
|
Storage::fake('ssh-keys');
|
|
Storage::fake('ssh-mux');
|
|
Http::preventStrayRequests();
|
|
config()->set('app.maintenance.store', 'array');
|
|
config()->set('cache.default', 'array');
|
|
config()->set('constants.ssh.mux_enabled', false);
|
|
|
|
InstanceSettings::query()->delete();
|
|
$settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]);
|
|
$settings->id = 0;
|
|
$settings->save();
|
|
Once::flush();
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->user = User::factory()->create();
|
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
|
|
|
$this->otherTeam = Team::factory()->create();
|
|
$this->otherUser = User::factory()->create();
|
|
$this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']);
|
|
|
|
$this->key = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
|
$this->otherKey = PrivateKey::factory()->create(['team_id' => $this->otherTeam->id]);
|
|
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->key->id]);
|
|
$this->otherServer = Server::factory()->create(['team_id' => $this->otherTeam->id, 'private_key_id' => $this->otherKey->id, 'name' => 'Theirs']);
|
|
|
|
session(['currentTeam' => $this->team]);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Server::flushIdentityMap();
|
|
});
|
|
|
|
function mcpSrvToken(User $user, array $abilities = ['read', 'write', 'delete']): string
|
|
{
|
|
return $user->createToken('mcp-srv', $abilities)->plainTextToken;
|
|
}
|
|
|
|
function mcpSrvCall(string $token, string $tool, array $arguments = [])
|
|
{
|
|
return test()->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json, text/event-stream',
|
|
])->postJson('/mcp', [
|
|
'jsonrpc' => '2.0',
|
|
'id' => 1,
|
|
'method' => 'tools/call',
|
|
'params' => ['name' => $tool, 'arguments' => (object) $arguments],
|
|
]);
|
|
}
|
|
|
|
function mcpSrvText($response): string
|
|
{
|
|
return (string) ($response->json('result.content.0.text') ?? $response->json('error.message'));
|
|
}
|
|
|
|
function mcpSrvIsError($response): bool
|
|
{
|
|
return $response->json('result.isError') === true || $response->json('error') !== null;
|
|
}
|
|
|
|
function mcpSrvData($response): mixed
|
|
{
|
|
return json_decode(mcpSrvText($response), true)['data'] ?? null;
|
|
}
|
|
|
|
function mcpSrvMakeFunctional(Server $server): void
|
|
{
|
|
$server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
|
Server::flushIdentityMap();
|
|
}
|
|
|
|
function mcpSrvGithubApp(Team $team, PrivateKey $key, array $overrides = []): GithubApp
|
|
{
|
|
return GithubApp::create(array_merge([
|
|
'name' => 'gh-'.$team->id,
|
|
'api_url' => 'https://api.github.com',
|
|
'html_url' => 'https://github.com',
|
|
'app_id' => 1234,
|
|
'installation_id' => 5678,
|
|
'client_id' => 'client-id',
|
|
'client_secret' => 'GH_CLIENT_SECRET_VALUE',
|
|
'webhook_secret' => 'GH_WEBHOOK_SECRET_VALUE',
|
|
'private_key_id' => $key->id,
|
|
'team_id' => $team->id,
|
|
'is_system_wide' => false,
|
|
], $overrides));
|
|
}
|
|
|
|
function mcpSrvS3(Team $team, array $overrides = []): S3Storage
|
|
{
|
|
return S3Storage::create(array_merge([
|
|
'team_id' => $team->id,
|
|
'name' => 'S3 '.$team->id,
|
|
'region' => 'us-east-1',
|
|
'key' => 'S3_ACCESS_KEY_VALUE',
|
|
'secret' => 'S3_SECRET_KEY_VALUE',
|
|
'bucket' => 'test-bucket',
|
|
'endpoint' => 'https://s3.amazonaws.com',
|
|
'is_usable' => false,
|
|
], $overrides));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- shared checks
|
|
|
|
test('server group write actions reject read-only tokens', function (string $tool, array $arguments) {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read']), $tool, $arguments + ['confirm' => true]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(mcpSrvText($response))->toMatch('/Missing required permissions: (write|deploy)|Tool \[\w+\] not found/');
|
|
})->with([
|
|
'manage_server create' => ['manage_server', ['action' => 'create', 'data' => ['ip' => '203.0.113.50']]],
|
|
'manage_proxy restart' => ['manage_proxy', ['action' => 'restart', 'uuid' => 'x']],
|
|
'docker_cleanup run' => ['docker_cleanup', ['action' => 'run', 'uuid' => 'x']],
|
|
'manage_destination create' => ['manage_destination', ['action' => 'create', 'server_uuid' => 'x']],
|
|
'manage_registry create' => ['manage_registry', ['action' => 'create', 'uuid' => 'x']],
|
|
'manage_private_key create' => ['manage_private_key', ['action' => 'create']],
|
|
'manage_s3_storage create' => ['manage_s3_storage', ['action' => 'create']],
|
|
'manage_git_source create' => ['manage_git_source', ['action' => 'create', 'resource' => 'github']],
|
|
'cloud_provider provision_server' => ['cloud_provider', ['action' => 'provision_server', 'resource' => 'hetzner']],
|
|
'manage_notifications update' => ['manage_notifications', ['action' => 'update', 'resource' => 'discord']],
|
|
]);
|
|
|
|
test('server group write actions reject member tokens', function (string $tool, array $arguments) {
|
|
$member = User::factory()->create();
|
|
$this->team->members()->attach($member->id, ['role' => 'member']);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($member, ['read', 'write']), $tool, $arguments + ['confirm' => true]);
|
|
|
|
expect($response->json('result'))->toBeNull()
|
|
->and($response->json('error.message'))->toBe('This API token has more access than you have in this team. Revoke it and create a new token.');
|
|
})->with([
|
|
'manage_server update' => ['manage_server', ['action' => 'update', 'data' => ['name' => 'x']]],
|
|
'manage_private_key create' => ['manage_private_key', ['action' => 'create']],
|
|
'manage_registry list' => ['manage_registry', ['action' => 'list']],
|
|
'cloud_provider create_token' => ['cloud_provider', ['action' => 'create_token']],
|
|
'manage_notifications update' => ['manage_notifications', ['action' => 'update', 'resource' => 'email']],
|
|
]);
|
|
|
|
test('server group destructive actions need confirm=true', function (string $tool, array $arguments) {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy', 'delete']), $tool, $arguments);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(mcpSrvText($response))->toContain('requires confirm=true');
|
|
Queue::assertNothingPushed();
|
|
})->with([
|
|
'manage_server delete' => ['manage_server', ['action' => 'delete', 'uuid' => 'x']],
|
|
'manage_proxy update' => ['manage_proxy', ['action' => 'update', 'uuid' => 'x']],
|
|
'manage_proxy set_configuration' => ['manage_proxy', ['action' => 'set_configuration', 'uuid' => 'x']],
|
|
'manage_proxy restart' => ['manage_proxy', ['action' => 'restart', 'uuid' => 'x']],
|
|
'docker_cleanup run' => ['docker_cleanup', ['action' => 'run', 'uuid' => 'x']],
|
|
'manage_destination delete' => ['manage_destination', ['action' => 'delete', 'uuid' => 'x']],
|
|
'manage_registry delete' => ['manage_registry', ['action' => 'delete', 'uuid' => 'x', 'registry' => 'ghcr.io']],
|
|
'manage_private_key delete' => ['manage_private_key', ['action' => 'delete', 'uuid' => 'x']],
|
|
'manage_s3_storage delete' => ['manage_s3_storage', ['action' => 'delete', 'uuid' => 'x']],
|
|
'manage_git_source delete' => ['manage_git_source', ['action' => 'delete', 'resource' => 'github', 'source_id' => '1']],
|
|
'cloud_provider delete_token' => ['cloud_provider', ['action' => 'delete_token', 'uuid' => 'x']],
|
|
'cloud_provider provision_server' => ['cloud_provider', ['action' => 'provision_server', 'resource' => 'hetzner']],
|
|
'manage_integration_tokens delete' => ['manage_integration_tokens', ['action' => 'delete', 'uuid' => 'x']],
|
|
'manage_dns replace' => ['manage_dns', ['action' => 'replace', 'resource' => 'application', 'uuid' => 'x']],
|
|
'manage_dns release' => ['manage_dns', ['action' => 'release', 'resource' => 'application', 'uuid' => 'x', 'hostname' => 'app.example.com']],
|
|
'manage_secret_manager remove' => ['manage_secret_manager', ['action' => 'remove', 'resource' => 'application', 'uuid' => 'x']],
|
|
'manage_cloudflare_tunnel update' => ['manage_cloudflare_tunnel', ['action' => 'update', 'uuid' => 'x', 'data' => ['is_cloudflare_tunnel' => false]]],
|
|
'manage_cloudflare_tunnel enable' => ['manage_cloudflare_tunnel', ['action' => 'enable', 'uuid' => 'x']],
|
|
'manage_cloudflare_tunnel disable' => ['manage_cloudflare_tunnel', ['action' => 'disable', 'uuid' => 'x']],
|
|
]);
|
|
|
|
// ---------------------------------------------------------------- manage_server
|
|
|
|
test('manage_server creates a server in the token team with a team private key', function () {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => 'create', 'data' => [
|
|
'name' => 'mcp-server', 'ip' => '203.0.113.50', 'private_key_uuid' => $this->key->uuid,
|
|
]]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response));
|
|
$server = Server::where('name', 'mcp-server')->sole();
|
|
expect($server->team_id)->toBe($this->team->id)
|
|
->and($server->private_key_id)->toBe($this->key->id);
|
|
});
|
|
|
|
test('manage_server rejects a private key of another team', function () {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => 'create', 'data' => [
|
|
'name' => 'stolen-key', 'ip' => '203.0.113.51', 'private_key_uuid' => $this->otherKey->uuid,
|
|
]]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(Server::where('name', 'stolen-key')->exists())->toBeFalse();
|
|
|
|
$update = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => 'update', 'uuid' => $this->server->uuid, 'data' => ['private_key_uuid' => $this->otherKey->uuid]]);
|
|
expect(mcpSrvIsError($update))->toBeTrue()
|
|
->and($this->server->fresh()->private_key_id)->toBe($this->key->id);
|
|
});
|
|
|
|
test('manage_server cannot change, validate, or delete a server of another team', function (string $action, array $data) {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true, 'data' => $data]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue();
|
|
$fresh = Server::find($this->otherServer->id);
|
|
expect($fresh)->not->toBeNull()->and($fresh->name)->toBe('Theirs');
|
|
Queue::assertNothingPushed();
|
|
})->with([
|
|
'update' => ['update', ['name' => 'Hijacked']],
|
|
'validate' => ['validate', []],
|
|
'delete' => ['delete', []],
|
|
]);
|
|
|
|
test('manage_server updates, validates, and deletes its own server', function () {
|
|
$token = mcpSrvToken($this->user);
|
|
|
|
$update = mcpSrvCall($token, 'manage_server', ['action' => 'update', 'uuid' => $this->server->uuid, 'data' => ['name' => 'renamed']]);
|
|
expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update))
|
|
->and($this->server->fresh()->name)->toBe('renamed');
|
|
|
|
$validate = mcpSrvCall($token, 'manage_server', ['action' => 'validate', 'uuid' => $this->server->uuid]);
|
|
expect(mcpSrvIsError($validate))->toBeFalse(mcpSrvText($validate));
|
|
|
|
$delete = mcpSrvCall($token, 'manage_server', ['action' => 'delete', 'uuid' => $this->server->uuid, 'confirm' => true]);
|
|
expect(mcpSrvIsError($delete))->toBeFalse(mcpSrvText($delete))
|
|
->and(Server::find($this->server->id))->toBeNull();
|
|
});
|
|
|
|
// ---------------------------------------------------------------- manage_proxy
|
|
|
|
test('manage_proxy reads and updates the proxy of its own server and queues a restart', function () {
|
|
$token = mcpSrvToken($this->user, ['read', 'write', 'deploy']);
|
|
|
|
$get = mcpSrvCall($token, 'manage_proxy', ['action' => 'get', 'uuid' => $this->server->uuid]);
|
|
expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get))
|
|
->and(mcpSrvData($get))->toHaveKey('proxy_type');
|
|
|
|
$update = mcpSrvCall($token, 'manage_proxy', ['action' => 'update', 'uuid' => $this->server->uuid, 'confirm' => true, 'data' => ['generate_exact_labels' => true]]);
|
|
expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update))
|
|
->and((bool) $this->server->settings()->first()->generate_exact_labels)->toBeTrue();
|
|
|
|
$restart = mcpSrvCall($token, 'manage_proxy', ['action' => 'restart', 'uuid' => $this->server->uuid, 'confirm' => true]);
|
|
expect(mcpSrvIsError($restart))->toBeFalse(mcpSrvText($restart));
|
|
Queue::assertPushed(RestartProxyJob::class);
|
|
});
|
|
|
|
test('manage_proxy cannot read or change the proxy of another team', function (string $action, array $data) {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), 'manage_proxy', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true, 'data' => $data]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and((bool) $this->otherServer->settings()->first()->generate_exact_labels)->toBeFalse();
|
|
Queue::assertNothingPushed();
|
|
})->with([
|
|
'get' => ['get', []],
|
|
'update' => ['update', ['generate_exact_labels' => true]],
|
|
'set_configuration' => ['set_configuration', ['configuration' => "services:\n traefik:\n image: evil\n"]],
|
|
'restart' => ['restart', []],
|
|
]);
|
|
|
|
// ---------------------------------------------------------------- docker_cleanup
|
|
|
|
test('docker_cleanup reads, updates, lists, and runs cleanup on its own server', function () {
|
|
$token = mcpSrvToken($this->user, ['read', 'write', 'deploy']);
|
|
|
|
$get = mcpSrvCall($token, 'docker_cleanup', ['action' => 'get', 'uuid' => $this->server->uuid]);
|
|
expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get))
|
|
->and(mcpSrvData($get))->toHaveKey('docker_cleanup_threshold');
|
|
|
|
$update = mcpSrvCall($token, 'docker_cleanup', ['action' => 'update', 'uuid' => $this->server->uuid, 'data' => ['docker_cleanup_threshold' => 55]]);
|
|
expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update))
|
|
->and((int) $this->server->settings()->first()->docker_cleanup_threshold)->toBe(55);
|
|
|
|
$executions = mcpSrvCall($token, 'docker_cleanup', ['action' => 'executions', 'uuid' => $this->server->uuid]);
|
|
expect(mcpSrvIsError($executions))->toBeFalse(mcpSrvText($executions));
|
|
|
|
$run = mcpSrvCall($token, 'docker_cleanup', ['action' => 'run', 'uuid' => $this->server->uuid, 'confirm' => true]);
|
|
expect(mcpSrvIsError($run))->toBeFalse(mcpSrvText($run));
|
|
Queue::assertPushed(DockerCleanupJob::class);
|
|
});
|
|
|
|
test('docker_cleanup cannot touch a server of another team', function (string $action, array $data) {
|
|
$before = (int) $this->otherServer->settings()->first()->docker_cleanup_threshold;
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), 'docker_cleanup', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true, 'data' => $data]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and((int) $this->otherServer->settings()->first()->docker_cleanup_threshold)->toBe($before);
|
|
Queue::assertNothingPushed();
|
|
})->with([
|
|
'get' => ['get', []],
|
|
'update' => ['update', ['docker_cleanup_threshold' => 12]],
|
|
'executions' => ['executions', []],
|
|
'run' => ['run', []],
|
|
]);
|
|
|
|
// ---------------------------------------------------------------- manage_destination
|
|
|
|
test('manage_destination creates, renames, and deletes a destination on its own server', function () {
|
|
$cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class);
|
|
$cleanup->shouldReceive('handle')->once();
|
|
$this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup);
|
|
$token = mcpSrvToken($this->user);
|
|
|
|
$create = mcpSrvCall($token, 'manage_destination', ['action' => 'create', 'server_uuid' => $this->server->uuid, 'data' => ['network' => 'mcp-net', 'name' => 'mcp-dest']]);
|
|
expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create));
|
|
$destination = StandaloneDocker::where('network', 'mcp-net')->sole();
|
|
expect($destination->server_id)->toBe($this->server->id);
|
|
|
|
$update = mcpSrvCall($token, 'manage_destination', ['action' => 'update', 'uuid' => $destination->uuid, 'data' => ['name' => 'renamed-dest']]);
|
|
expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update))
|
|
->and($destination->fresh()->name)->toBe('renamed-dest');
|
|
|
|
$delete = mcpSrvCall($token, 'manage_destination', ['action' => 'delete', 'uuid' => $destination->uuid, 'confirm' => true]);
|
|
expect(mcpSrvIsError($delete))->toBeFalse(mcpSrvText($delete))
|
|
->and($destination->fresh())->toBeNull();
|
|
});
|
|
|
|
test('manage_destination cannot create a destination on a server of another team', function () {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_destination', ['action' => 'create', 'server_uuid' => $this->otherServer->uuid, 'data' => ['network' => 'evil-net']]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(StandaloneDocker::where('network', 'evil-net')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('manage_destination cannot rename or delete a destination of another team', function (string $action, array $data) {
|
|
$cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class);
|
|
$cleanup->shouldNotReceive('handle');
|
|
$this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup);
|
|
$foreign = StandaloneDocker::where('server_id', $this->otherServer->id)->firstOrFail();
|
|
$name = $foreign->name;
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_destination', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and($foreign->fresh())->not->toBeNull()
|
|
->and($foreign->fresh()->name)->toBe($name);
|
|
})->with([
|
|
'update' => ['update', ['name' => 'Hijacked']],
|
|
'delete' => ['delete', []],
|
|
]);
|
|
|
|
// ---------------------------------------------------------------- manage_registry
|
|
|
|
test('manage_registry lists registry logins without passwords', function () {
|
|
mcpSrvMakeFunctional($this->server);
|
|
Process::fake(fn () => Process::result(output: json_encode([
|
|
'auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:REGISTRY_PASSWORD_VALUE')]],
|
|
])));
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_registry', ['action' => 'list', 'uuid' => $this->server->uuid]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response))
|
|
->and(mcpSrvText($response))->toContain('ghcr.io')
|
|
->and(mcpSrvText($response))->not->toContain('REGISTRY_PASSWORD_VALUE')
|
|
->and(mcpSrvText($response))->not->toContain(base64_encode('octocat:REGISTRY_PASSWORD_VALUE'));
|
|
});
|
|
|
|
test('manage_registry list needs read:sensitive', function () {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), 'manage_registry', ['action' => 'list', 'uuid' => $this->server->uuid]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(mcpSrvText($response))->toContain('read:sensitive');
|
|
});
|
|
|
|
test('manage_registry logs in on its own server without echoing the password', function () {
|
|
mcpSrvMakeFunctional($this->server);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), 'manage_registry', ['action' => 'create', 'uuid' => $this->server->uuid, 'data' => [
|
|
'registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'REGISTRY_PASSWORD_VALUE',
|
|
]]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response))
|
|
->and(mcpSrvText($response))->not->toContain('REGISTRY_PASSWORD_VALUE');
|
|
Process::assertRan(fn ($process) => str_contains(is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process), 'docker'));
|
|
});
|
|
|
|
test('manage_registry cannot touch registries on a server of another team', function (string $action, string $ability, array $extra) {
|
|
mcpSrvMakeFunctional($this->otherServer);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', $ability]), 'manage_registry', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true] + $extra);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue();
|
|
Process::assertNothingRan();
|
|
})->with([
|
|
'list' => ['list', 'read:sensitive', []],
|
|
'create' => ['create', 'write', ['data' => ['registry' => 'ghcr.io', 'username' => 'evil', 'password' => 'x']]],
|
|
'check' => ['check', 'write', ['registry' => 'ghcr.io']],
|
|
'delete' => ['delete', 'write', ['registry' => 'ghcr.io']],
|
|
]);
|
|
|
|
// ---------------------------------------------------------------- manage_private_key
|
|
|
|
test('manage_private_key never returns key material, even to root and read:sensitive tokens', function (array $abilities) {
|
|
$token = mcpSrvToken($this->user, $abilities);
|
|
$keyBody = 'QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi';
|
|
|
|
$list = mcpSrvCall($token, 'manage_private_key', ['action' => 'list']);
|
|
expect(mcpSrvIsError($list))->toBeFalse(mcpSrvText($list))
|
|
->and(mcpSrvText($list))->toContain($this->key->uuid)
|
|
->and(mcpSrvText($list))->not->toContain('PRIVATE KEY')
|
|
->and(mcpSrvText($list))->not->toContain($keyBody)
|
|
->and(mcpSrvText($list))->not->toContain($this->otherKey->uuid);
|
|
|
|
$get = mcpSrvCall($token, 'manage_private_key', ['action' => 'get', 'uuid' => $this->key->uuid]);
|
|
expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get))
|
|
->and(mcpSrvText($get))->not->toContain('PRIVATE KEY')
|
|
->and(mcpSrvText($get))->not->toContain($keyBody);
|
|
})->with([
|
|
'root' => [['root']],
|
|
'read:sensitive' => [['read', 'read:sensitive']],
|
|
]);
|
|
|
|
test('manage_private_key create does not return or audit the key material', function () {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_private_key', ['action' => 'create', 'data' => [
|
|
'name' => 'mcp-key', 'private_key' => MCP_SRV_NEW_PRIVATE_KEY,
|
|
]]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response))
|
|
->and(mcpSrvText($response))->not->toContain('PRIVATE KEY')
|
|
->and(mcpSrvText($response))->not->toContain('AAAECKtKd242pj0qdKWaOpXAQFI2ZGLQOg9vCP2');
|
|
expect(PrivateKey::where('name', 'mcp-key')->sole()->team_id)->toBe($this->team->id);
|
|
|
|
$audit = AuditEvent::query()->get()->toJson();
|
|
expect($audit)->not->toContain('PRIVATE KEY')
|
|
->and($audit)->not->toContain('AAAECKtKd242pj0qdKWaOpXAQFI2ZGLQOg9vCP2');
|
|
});
|
|
|
|
test('manage_private_key cannot read, change, or delete a key of another team', function (string $action, array $data) {
|
|
$original = $this->otherKey->fresh()->name;
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_private_key', ['action' => $action, 'uuid' => $this->otherKey->uuid, 'confirm' => true, 'data' => $data]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(PrivateKey::find($this->otherKey->id))->not->toBeNull()
|
|
->and(PrivateKey::find($this->otherKey->id)->name)->toBe($original);
|
|
})->with([
|
|
'get' => ['get', []],
|
|
'update' => ['update', ['name' => 'Hijacked', 'private_key' => MCP_SRV_NEW_PRIVATE_KEY]],
|
|
'delete' => ['delete', []],
|
|
]);
|
|
|
|
test('manage_private_key deletes an unused key of its own team', function () {
|
|
$unused = PrivateKey::create(['team_id' => $this->team->id, 'name' => 'unused', 'private_key' => MCP_SRV_NEW_PRIVATE_KEY]);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_private_key', ['action' => 'delete', 'uuid' => $unused->uuid, 'confirm' => true]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response))
|
|
->and(PrivateKey::find($unused->id))->toBeNull();
|
|
});
|
|
|
|
// ---------------------------------------------------------------- manage_s3_storage
|
|
|
|
test('manage_s3_storage creates a storage and never returns the secret', function () {
|
|
$token = mcpSrvToken($this->user, ['root']);
|
|
|
|
$create = mcpSrvCall($token, 'manage_s3_storage', ['action' => 'create', 'data' => [
|
|
'name' => 'mcp-s3', 'endpoint' => 'https://s3.amazonaws.com', 'bucket' => 'mcp-bucket', 'region' => 'us-east-1',
|
|
'key' => 'S3_ACCESS_KEY_VALUE', 'secret' => 'S3_SECRET_KEY_VALUE',
|
|
]]);
|
|
expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create))
|
|
->and(mcpSrvText($create))->not->toContain('S3_SECRET_KEY_VALUE');
|
|
$storage = S3Storage::where('name', 'mcp-s3')->sole();
|
|
expect($storage->team_id)->toBe($this->team->id);
|
|
|
|
foreach ([['action' => 'list'], ['action' => 'get', 'uuid' => $storage->uuid]] as $arguments) {
|
|
$read = mcpSrvCall($token, 'manage_s3_storage', $arguments);
|
|
expect(mcpSrvIsError($read))->toBeFalse(mcpSrvText($read))
|
|
->and(mcpSrvText($read))->toContain($storage->uuid)
|
|
->and(mcpSrvText($read))->not->toContain('S3_SECRET_KEY_VALUE');
|
|
}
|
|
});
|
|
|
|
test('manage_s3_storage cannot read, change, test, or delete a storage of another team', function (string $action, array $data) {
|
|
$foreign = mcpSrvS3($this->otherTeam, ['name' => 'Theirs']);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_s3_storage', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(mcpSrvText($response))->not->toContain('S3_SECRET_KEY_VALUE')
|
|
->and($foreign->fresh())->not->toBeNull()
|
|
->and($foreign->fresh()->name)->toBe('Theirs');
|
|
})->with([
|
|
'get' => ['get', []],
|
|
'update' => ['update', ['name' => 'Hijacked']],
|
|
'validate' => ['validate', []],
|
|
'delete' => ['delete', []],
|
|
]);
|
|
|
|
test('manage_s3_storage list does not include storages of another team', function () {
|
|
$foreign = mcpSrvS3($this->otherTeam);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_s3_storage', ['action' => 'list']);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse()
|
|
->and(mcpSrvText($response))->not->toContain($foreign->uuid);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- manage_git_source
|
|
|
|
test('manage_git_source lists apps without client or webhook secrets', function () {
|
|
$own = mcpSrvGithubApp($this->team, $this->key);
|
|
$shared = mcpSrvGithubApp($this->otherTeam, $this->otherKey, ['name' => 'shared', 'is_system_wide' => true]);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_git_source', ['action' => 'list', 'resource' => 'github']);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response))
|
|
->and(mcpSrvText($response))->toContain($own->uuid)
|
|
->and(mcpSrvText($response))->not->toContain('GH_CLIENT_SECRET_VALUE')
|
|
->and(mcpSrvText($response))->not->toContain('GH_WEBHOOK_SECRET_VALUE')
|
|
->and(mcpSrvText($response))->not->toContain('PRIVATE KEY');
|
|
});
|
|
|
|
test('manage_git_source rejects a GitHub app with a private key of another team', function () {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => 'create', 'resource' => 'github', 'data' => [
|
|
'name' => 'stolen', 'html_url' => 'https://github.com', 'app_id' => 1, 'installation_id' => 2,
|
|
'client_id' => 'id', 'client_secret' => 'secret', 'webhook_secret' => 'hook', 'private_key_uuid' => $this->otherKey->uuid,
|
|
]]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(GithubApp::where('name', 'stolen')->exists())->toBeFalse();
|
|
|
|
$own = mcpSrvGithubApp($this->team, $this->key);
|
|
$update = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => 'update', 'resource' => 'github', 'source_id' => (string) $own->id, 'data' => ['private_key_uuid' => $this->otherKey->uuid]]);
|
|
expect(mcpSrvIsError($update))->toBeTrue()
|
|
->and($own->fresh()->private_key_id)->toBe($this->key->id);
|
|
});
|
|
|
|
test('manage_git_source cannot change or delete GitHub apps of another team, including system-wide ones', function (bool $systemWide, string $action) {
|
|
$foreign = mcpSrvGithubApp($this->otherTeam, $this->otherKey, ['name' => 'Theirs', 'is_system_wide' => $systemWide]);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => $action, 'resource' => 'github', 'source_id' => (string) $foreign->id, 'confirm' => true, 'data' => ['name' => 'Hijacked']]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and($foreign->fresh())->not->toBeNull()
|
|
->and($foreign->fresh()->name)->toBe('Theirs');
|
|
})->with([
|
|
'team app' => false,
|
|
'system-wide app' => true,
|
|
])->with(['update', 'delete']);
|
|
|
|
test('manage_git_source creates, updates, and deletes a GitLab app without returning secrets', function () {
|
|
$token = mcpSrvToken($this->user, ['read', 'write', 'delete']);
|
|
|
|
$create = mcpSrvCall($token, 'manage_git_source', ['action' => 'create', 'resource' => 'gitlab', 'data' => [
|
|
'name' => 'mcp-gitlab', 'html_url' => 'https://gitlab.com', 'client_secret' => 'GL_CLIENT_SECRET_VALUE', 'webhook_token' => 'GL_WEBHOOK_TOKEN_VALUE',
|
|
]]);
|
|
expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create))
|
|
->and(mcpSrvText($create))->not->toContain('GL_CLIENT_SECRET_VALUE')
|
|
->and(mcpSrvText($create))->not->toContain('GL_WEBHOOK_TOKEN_VALUE');
|
|
$app = GitlabApp::where('name', 'mcp-gitlab')->sole();
|
|
expect($app->team_id)->toBe($this->team->id);
|
|
|
|
$list = mcpSrvCall($token, 'manage_git_source', ['action' => 'list', 'resource' => 'gitlab']);
|
|
expect(mcpSrvIsError($list))->toBeFalse()
|
|
->and(mcpSrvText($list))->not->toContain('GL_CLIENT_SECRET_VALUE')
|
|
->and(mcpSrvText($list))->not->toContain('GL_WEBHOOK_TOKEN_VALUE');
|
|
$sourceId = collect(mcpSrvData($list))->firstWhere('name', 'mcp-gitlab')['id'];
|
|
expect($sourceId)->not->toBeNull();
|
|
|
|
$update = mcpSrvCall($token, 'manage_git_source', ['action' => 'update', 'resource' => 'gitlab', 'source_id' => (string) $sourceId, 'data' => ['name' => 'renamed-gitlab']]);
|
|
expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update))
|
|
->and(mcpSrvText($update))->not->toContain('GL_CLIENT_SECRET_VALUE')
|
|
->and(mcpSrvText($update))->not->toContain('GL_WEBHOOK_TOKEN_VALUE')
|
|
->and($app->fresh()->name)->toBe('renamed-gitlab');
|
|
|
|
$delete = mcpSrvCall($token, 'manage_git_source', ['action' => 'delete', 'resource' => 'gitlab', 'source_id' => (string) $sourceId, 'confirm' => true]);
|
|
expect(mcpSrvIsError($delete))->toBeFalse(mcpSrvText($delete))
|
|
->and(GitlabApp::find($app->id))->toBeNull();
|
|
});
|
|
|
|
test('manage_git_source cannot change or delete a GitLab app of another team', function (string $action) {
|
|
$foreign = GitlabApp::create(['name' => 'Theirs', 'html_url' => 'https://gitlab.com', 'api_url' => 'https://gitlab.com/api/v4', 'team_id' => $this->otherTeam->id]);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => $action, 'resource' => 'gitlab', 'source_id' => (string) $foreign->id, 'confirm' => true, 'data' => ['name' => 'Hijacked']]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and($foreign->fresh())->not->toBeNull()
|
|
->and($foreign->fresh()->name)->toBe('Theirs');
|
|
})->with(['update', 'delete']);
|
|
|
|
test('manage_git_source requires a known resource', function () {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => 'list', 'resource' => 'bitbucket']);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(mcpSrvText($response))->toContain('resource must be one of: github, gitlab');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- cloud_provider
|
|
|
|
test('cloud_provider adds and lists tokens without returning the token value', function () {
|
|
Http::fake(['https://api.hetzner.cloud/v1/servers' => Http::response([], 200)]);
|
|
$token = mcpSrvToken($this->user, ['root']);
|
|
|
|
$create = mcpSrvCall($token, 'cloud_provider', ['action' => 'create_token', 'data' => ['provider' => 'hetzner', 'token' => 'HETZNER_TOKEN_VALUE', 'name' => 'mcp-hetzner']]);
|
|
expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create))
|
|
->and(mcpSrvText($create))->not->toContain('HETZNER_TOKEN_VALUE');
|
|
$stored = CloudProviderToken::where('name', 'mcp-hetzner')->sole();
|
|
expect($stored->team_id)->toBe($this->team->id);
|
|
|
|
foreach ([['action' => 'list_tokens'], ['action' => 'get_token', 'uuid' => $stored->uuid]] as $arguments) {
|
|
$read = mcpSrvCall($token, 'cloud_provider', $arguments);
|
|
expect(mcpSrvIsError($read))->toBeFalse(mcpSrvText($read))
|
|
->and(mcpSrvText($read))->toContain($stored->uuid)
|
|
->and(mcpSrvText($read))->not->toContain('HETZNER_TOKEN_VALUE');
|
|
}
|
|
});
|
|
|
|
test('cloud_provider cannot read, change, validate, or delete a token of another team', function (string $action, array $data) {
|
|
Http::fake();
|
|
$foreign = CloudProviderToken::create(['team_id' => $this->otherTeam->id, 'provider' => 'hetzner', 'token' => 'FOREIGN_TOKEN_VALUE', 'name' => 'Theirs']);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'cloud_provider', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(mcpSrvText($response))->not->toContain('FOREIGN_TOKEN_VALUE')
|
|
->and($foreign->fresh())->not->toBeNull()
|
|
->and($foreign->fresh()->name)->toBe('Theirs');
|
|
Http::assertNothingSent();
|
|
})->with([
|
|
'get_token' => ['get_token', []],
|
|
'update_token' => ['update_token', ['name' => 'Hijacked']],
|
|
'validate_token' => ['validate_token', []],
|
|
'delete_token' => ['delete_token', []],
|
|
]);
|
|
|
|
test('cloud_provider reads a catalog with a token of its own team', function () {
|
|
Http::fake(['https://api.hetzner.cloud/v1/locations*' => Http::response([
|
|
'locations' => [['id' => 1, 'name' => 'nbg1', 'description' => 'Nuremberg', 'country' => 'DE', 'city' => 'Nuremberg']],
|
|
'meta' => ['pagination' => ['next_page' => null]],
|
|
], 200)]);
|
|
$cloudToken = CloudProviderToken::create(['team_id' => $this->team->id, 'provider' => 'hetzner', 'token' => 'OWN_TOKEN_VALUE', 'name' => 'Mine']);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => 'catalog', 'resource' => 'hetzner_locations', 'data' => ['cloud_provider_token_uuid' => $cloudToken->uuid]]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response))
|
|
->and(mcpSrvText($response))->toContain('nbg1')
|
|
->and(mcpSrvText($response))->not->toContain('OWN_TOKEN_VALUE');
|
|
});
|
|
|
|
test('cloud_provider cannot use a token of another team for catalogs or provisioning', function (string $action, string $resource, array $data) {
|
|
Http::fake();
|
|
$foreign = CloudProviderToken::create(['team_id' => $this->otherTeam->id, 'provider' => 'hetzner', 'token' => 'FOREIGN_TOKEN_VALUE', 'name' => 'Theirs']);
|
|
$serverCount = Server::count();
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => $action, 'resource' => $resource, 'confirm' => true, 'data' => ['cloud_provider_token_uuid' => $foreign->uuid] + $data]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(Server::count())->toBe($serverCount);
|
|
Http::assertNothingSent();
|
|
})->with([
|
|
'catalog' => ['catalog', 'hetzner_server_types', []],
|
|
'provision' => ['provision_server', 'hetzner', ['location' => 'nbg1', 'server_type' => 'cx22', 'image' => 1, 'name' => 'evil']],
|
|
]);
|
|
|
|
test('cloud_provider provisioning rejects a private key of another team', function () {
|
|
Http::fake();
|
|
$cloudToken = CloudProviderToken::create(['team_id' => $this->team->id, 'provider' => 'hetzner', 'token' => 'OWN_TOKEN_VALUE', 'name' => 'Mine']);
|
|
$serverCount = Server::count();
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => 'provision_server', 'resource' => 'hetzner', 'confirm' => true, 'data' => [
|
|
'cloud_provider_token_uuid' => $cloudToken->uuid, 'private_key_uuid' => $this->otherKey->uuid,
|
|
'location' => 'nbg1', 'server_type' => 'cx22', 'image' => 1, 'name' => 'evil',
|
|
]]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(Server::count())->toBe($serverCount);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('cloud_provider provisioning a Hostinger VPS with a post-install script or extra SSH keys needs deploy', function (array $extra) {
|
|
Http::fake();
|
|
$cloudToken = CloudProviderToken::create(['team_id' => $this->team->id, 'provider' => 'hostinger', 'token' => 'OWN_TOKEN_VALUE', 'name' => 'Mine']);
|
|
$serverCount = Server::count();
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => 'provision_server', 'resource' => 'hostinger', 'confirm' => true, 'data' => [
|
|
'cloud_provider_token_uuid' => $cloudToken->uuid, 'private_key_uuid' => $this->key->uuid,
|
|
'item_id' => 'hostingercom-vps-kvm1-usd-1m', 'data_center_id' => 19, 'template_id' => 1130,
|
|
...$extra,
|
|
]]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(mcpSrvText($response))->toContain('Missing required permissions: deploy')
|
|
->and(Server::count())->toBe($serverCount);
|
|
Http::assertNothingSent();
|
|
})->with([
|
|
'post-install script' => [['post_install_script_id' => 73]],
|
|
'extra SSH keys' => [['public_key_ids' => [42]]],
|
|
]);
|
|
|
|
// ---------------------------------------------------------------- manage_notifications
|
|
|
|
test('manage_notifications updates a channel of the token team and never returns webhook URLs or tokens', function () {
|
|
$token = mcpSrvToken($this->user, ['root']);
|
|
$otherSettings = DiscordNotificationSettings::query()->firstOrCreate(['team_id' => $this->otherTeam->id]);
|
|
|
|
$update = mcpSrvCall($token, 'manage_notifications', ['action' => 'update', 'resource' => 'discord', 'data' => [
|
|
'discord_enabled' => true, 'discord_webhook_url' => 'https://discord.com/api/webhooks/123/DISCORD_WEBHOOK_SECRET',
|
|
]]);
|
|
expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update))
|
|
->and(mcpSrvText($update))->not->toContain('DISCORD_WEBHOOK_SECRET');
|
|
|
|
expect(DiscordNotificationSettings::where('team_id', $this->team->id)->sole()->discord_webhook_url)->toContain('DISCORD_WEBHOOK_SECRET')
|
|
->and($otherSettings->fresh()->discord_webhook_url)->toBeNull();
|
|
|
|
$get = mcpSrvCall($token, 'manage_notifications', ['action' => 'get', 'resource' => 'discord']);
|
|
expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get))
|
|
->and(mcpSrvText($get))->toContain('discord_enabled')
|
|
->and(mcpSrvText($get))->not->toContain('DISCORD_WEBHOOK_SECRET');
|
|
});
|
|
|
|
test('manage_notifications never returns channel secrets', function (string $channel, array $data, string $secret) {
|
|
$token = mcpSrvToken($this->user, ['root']);
|
|
|
|
$update = mcpSrvCall($token, 'manage_notifications', ['action' => 'update', 'resource' => $channel, 'data' => $data]);
|
|
expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update))
|
|
->and(mcpSrvText($update))->not->toContain($secret);
|
|
|
|
$get = mcpSrvCall($token, 'manage_notifications', ['action' => 'get', 'resource' => $channel]);
|
|
expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get))
|
|
->and(mcpSrvText($get))->not->toContain($secret);
|
|
})->with([
|
|
'email smtp password' => ['email', ['smtp_password' => 'SMTP_PASSWORD_VALUE', 'resend_api_key' => 'RESEND_KEY_VALUE'], 'SMTP_PASSWORD_VALUE'],
|
|
'email resend key' => ['email', ['resend_api_key' => 'RESEND_KEY_VALUE'], 'RESEND_KEY_VALUE'],
|
|
'slack webhook' => ['slack', ['slack_webhook_url' => 'https://hooks.slack.com/services/SLACK_SECRET_PATH'], 'SLACK_SECRET_PATH'],
|
|
'telegram token' => ['telegram', ['telegram_token' => 'TELEGRAM_TOKEN_VALUE', 'telegram_chat_id' => '123'], 'TELEGRAM_TOKEN_VALUE'],
|
|
'pushover keys' => ['pushover', ['pushover_user_key' => 'PUSHOVER_USER_VALUE', 'pushover_api_token' => 'PUSHOVER_TOKEN_VALUE'], 'PUSHOVER_'],
|
|
'webhook url' => ['webhook', ['webhook_url' => 'https://example.com/hook/WEBHOOK_SECRET_PATH'], 'WEBHOOK_SECRET_PATH'],
|
|
]);
|
|
|
|
test('manage_notifications only changes the token team', function () {
|
|
TelegramNotificationSettings::query()->firstOrCreate(['team_id' => $this->otherTeam->id]);
|
|
$sessionSwitch = Team::factory()->create();
|
|
$sessionSwitch->members()->attach($this->user->id, ['role' => 'owner']);
|
|
$token = mcpSrvToken($this->user);
|
|
session(['currentTeam' => $sessionSwitch]);
|
|
|
|
$response = mcpSrvCall($token, 'manage_notifications', ['action' => 'update', 'resource' => 'telegram', 'data' => ['telegram_chat_id' => '999']]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response))
|
|
->and(TelegramNotificationSettings::where('team_id', $this->team->id)->sole()->telegram_chat_id)->toBe('999')
|
|
->and(TelegramNotificationSettings::where('team_id', $this->otherTeam->id)->sole()->telegram_chat_id)->toBeNull()
|
|
->and(TelegramNotificationSettings::where('team_id', $sessionSwitch->id)->first()?->telegram_chat_id)->toBeNull();
|
|
});
|
|
|
|
// ---------------------------------------------------------------- list_audit_events
|
|
|
|
test('list_audit_events returns only events of the token team', function () {
|
|
AuditEvent::factory()->create(['team_id' => $this->team->id, 'description' => 'own-team-event']);
|
|
AuditEvent::factory()->create(['team_id' => $this->otherTeam->id, 'description' => 'other-team-event']);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'list_audit_events', ['action' => 'list', 'data' => ['per_page' => 50]]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response))
|
|
->and(mcpSrvText($response))->toContain('own-team-event')
|
|
->and(mcpSrvText($response))->not->toContain('other-team-event');
|
|
});
|
|
|
|
test('list_audit_events is denied for team members', function () {
|
|
$member = User::factory()->create();
|
|
$this->team->members()->attach($member->id, ['role' => 'member']);
|
|
AuditEvent::factory()->create(['team_id' => $this->team->id, 'description' => 'own-team-event']);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($member, ['read']), 'list_audit_events', ['action' => 'list']);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(mcpSrvText($response))->not->toContain('own-team-event');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- get_server_metrics
|
|
|
|
test('get_server_metrics returns recent metrics for a server of the token team', function () {
|
|
McpSrvStubMetricsClient::$calls = 0;
|
|
$this->app->bind(SentinelMetricsClient::class, fn ($app, array $parameters) => new McpSrvStubMetricsClient($parameters['server']));
|
|
$this->server->settings->update(['is_metrics_enabled' => true]);
|
|
Server::flushIdentityMap();
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'get_server_metrics', ['server_uuid' => $this->server->uuid, 'minutes' => 60]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response));
|
|
$data = mcpSrvData($response);
|
|
expect($data['online'])->toBeTrue()
|
|
->and($data['current']['cpu_percent'])->toBe(42.5)
|
|
->and($data['series']['cpu_percent'])->toHaveCount(1)
|
|
->and($data['series']['disk_used_percent'])->toBe([])
|
|
->and($data['series']['network_rx_bytes_per_sec'])->toHaveCount(1)
|
|
->and(mcpSrvText($response))->not->toContain('sentinel_token');
|
|
});
|
|
|
|
test('get_server_metrics caps minutes and rejects invalid values', function () {
|
|
$this->app->bind(SentinelMetricsClient::class, fn ($app, array $parameters) => new McpSrvStubMetricsClient($parameters['server']));
|
|
$this->server->settings->update(['is_metrics_enabled' => true]);
|
|
Server::flushIdentityMap();
|
|
$token = mcpSrvToken($this->user, ['read']);
|
|
|
|
$capped = mcpSrvCall($token, 'get_server_metrics', ['server_uuid' => $this->server->uuid, 'minutes' => 999999]);
|
|
expect(mcpSrvData($capped)['minutes'])->toBe(43200);
|
|
|
|
$invalid = mcpSrvCall($token, 'get_server_metrics', ['server_uuid' => $this->server->uuid, 'minutes' => 0]);
|
|
expect(mcpSrvIsError($invalid))->toBeTrue();
|
|
});
|
|
|
|
test('get_server_metrics does not read metrics of a server of another team', function () {
|
|
McpSrvStubMetricsClient::$calls = 0;
|
|
$this->app->bind(SentinelMetricsClient::class, fn ($app, array $parameters) => new McpSrvStubMetricsClient($parameters['server']));
|
|
$this->otherServer->settings->update(['is_metrics_enabled' => true]);
|
|
Server::flushIdentityMap();
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'get_server_metrics', ['server_uuid' => $this->otherServer->uuid]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(mcpSrvText($response))->toContain('not found')
|
|
->and(McpSrvStubMetricsClient::$calls)->toBe(0);
|
|
});
|
|
|
|
test('get_server_metrics explains when metrics are disabled', function () {
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'get_server_metrics', ['server_uuid' => $this->server->uuid]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeTrue()
|
|
->and(mcpSrvText($response))->toContain('Metrics are not enabled');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- manage_integration_tokens and manage_dns
|
|
|
|
test('manage_integration_tokens lists team tokens without the secret and cannot read another team token', function () {
|
|
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'CF_SECRET_VALUE', 'capabilities' => ['dns']]);
|
|
$foreign = IntegrationToken::factory()->for($this->otherTeam)->create(['provider' => 'cloudflare']);
|
|
$bearer = mcpSrvToken($this->user, ['read']);
|
|
|
|
$list = mcpSrvCall($bearer, 'manage_integration_tokens', ['action' => 'list']);
|
|
$other = mcpSrvCall($bearer, 'manage_integration_tokens', ['action' => 'get', 'uuid' => $foreign->uuid]);
|
|
|
|
expect(mcpSrvIsError($list))->toBeFalse()
|
|
->and(collect(mcpSrvData($list))->pluck('uuid')->all())->toBe([$token->uuid])
|
|
->and($list->getContent())->not->toContain('CF_SECRET_VALUE')
|
|
->and(mcpSrvIsError($other))->toBeTrue()
|
|
->and(mcpSrvText($other))->toContain('Integration token not found.');
|
|
});
|
|
|
|
test('manage_dns creates the dns record of an application domain', function () {
|
|
$this->server->update(['ip' => '203.0.113.10']);
|
|
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
|
$application = Application::factory()->create([
|
|
'environment_id' => Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id])->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
'fqdn' => 'https://app.example.com',
|
|
]);
|
|
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'capabilities' => ['dns']]);
|
|
DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
|
|
Http::fake([
|
|
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]),
|
|
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]),
|
|
]);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write']), 'manage_dns', [
|
|
'action' => 'create', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['hostname' => 'app.example.com'],
|
|
]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse()
|
|
->and(mcpSrvData($response)['record'])->toMatchArray(['hostname' => 'app.example.com', 'address' => '203.0.113.10'])
|
|
->and(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue();
|
|
});
|
|
|
|
test('manage_dns returns the conflict of a create so the agent can replace the record', function () {
|
|
$this->server->update(['ip' => '203.0.113.10']);
|
|
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
|
$application = Application::factory()->create([
|
|
'environment_id' => Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id])->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
'fqdn' => 'https://app.example.com',
|
|
]);
|
|
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'capabilities' => ['dns']]);
|
|
DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
|
|
Http::fake([
|
|
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => [
|
|
['id' => 'record-old', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.7'],
|
|
]]),
|
|
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/*' => Http::response(['success' => true, 'result' => ['id' => 'record-old']]),
|
|
]);
|
|
$bearer = mcpSrvToken($this->user, ['read', 'write']);
|
|
|
|
$create = mcpSrvCall($bearer, 'manage_dns', [
|
|
'action' => 'create', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['hostname' => 'app.example.com'],
|
|
]);
|
|
$text = mcpSrvText($create);
|
|
$conflict = json_decode(substr($text, strpos($text, '{"conflict"')), true)['conflict'];
|
|
|
|
expect(mcpSrvIsError($create))->toBeTrue()
|
|
->and($conflict)->toBe(['record_id' => 'record-old', 'current' => '198.51.100.7', 'proposed' => '203.0.113.10']);
|
|
|
|
$replace = mcpSrvCall($bearer, 'manage_dns', [
|
|
'action' => 'replace', 'resource' => 'application', 'uuid' => $application->uuid, 'confirm' => true,
|
|
'data' => ['hostname' => 'app.example.com', 'record_id' => $conflict['record_id'], 'current' => $conflict['current']],
|
|
]);
|
|
|
|
expect(mcpSrvIsError($replace))->toBeFalse(mcpSrvText($replace))
|
|
->and(mcpSrvData($replace)['record'])->toMatchArray(['address' => '203.0.113.10', 'owned' => false]);
|
|
});
|
|
|
|
test('manage_integration_tokens renames a token', function () {
|
|
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'doppler', 'token' => 'dp.st.secret', 'capabilities' => ['secrets']]);
|
|
|
|
$response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write']), 'manage_integration_tokens', [
|
|
'action' => 'update', 'uuid' => $token->uuid, 'data' => ['name' => 'Renamed'],
|
|
]);
|
|
|
|
expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response))
|
|
->and($token->fresh()->name)->toBe('Renamed');
|
|
});
|
|
|
|
test('manage_secret_manager links a token of the team to an application', function () {
|
|
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
|
$application = Application::factory()->create([
|
|
'environment_id' => Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id])->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'doppler', 'token' => 'dp.st.secret', 'capabilities' => ['secrets']]);
|
|
$bearer = mcpSrvToken($this->user, ['read', 'write']);
|
|
|
|
$set = mcpSrvCall($bearer, 'manage_secret_manager', ['action' => 'set', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['integration_token_uuid' => $token->uuid]]);
|
|
$get = mcpSrvCall($bearer, 'manage_secret_manager', ['action' => 'get', 'resource' => 'application', 'uuid' => $application->uuid]);
|
|
|
|
expect(mcpSrvIsError($set))->toBeFalse(mcpSrvText($set))
|
|
->and(mcpSrvData($get))->toMatchArray(['linked' => true, 'integration_token_uuid' => $token->uuid, 'provider' => 'doppler']);
|
|
});
|
|
|
|
test('manage_cloudflare_tunnel reads the tunnel of its own server only', function () {
|
|
$bearer = mcpSrvToken($this->user, ['read']);
|
|
|
|
$own = mcpSrvCall($bearer, 'manage_cloudflare_tunnel', ['action' => 'get', 'uuid' => $this->server->uuid]);
|
|
$foreign = mcpSrvCall($bearer, 'manage_cloudflare_tunnel', ['action' => 'get', 'uuid' => $this->otherServer->uuid]);
|
|
|
|
expect(mcpSrvIsError($own))->toBeFalse(mcpSrvText($own))
|
|
->and(mcpSrvData($own))->toHaveKey('is_cloudflare_tunnel')
|
|
->and(mcpSrvIsError($foreign))->toBeTrue();
|
|
});
|