Files
coolify/tests/Feature/Api/ServerDockerRegistriesApiTest.php
T
Andras Bacsai 7fe40f4cf6 fix(api): require deploy ability for server side effects
Require deploy permission for API changes that trigger server side effects, including resource deletion, storage access, provider operations, and database imports. Also escape Docker cleanup filters and sanitize log drain server names.
2026-10-19 09:07:42 +02:00

333 lines
14 KiB
PHP

<?php
use App\Models\Application;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
config([
'cache.default' => 'array',
'session.driver' => 'array',
'app.maintenance.driver' => 'file',
'constants.ssh.mux_enabled' => false,
]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
['id' => 0],
['is_api_enabled' => true],
));
$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,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$this->server->refresh();
$this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write', 'deploy']);
$this->sensitiveToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'read:sensitive']);
});
function serverRegistriesApiToken(User $user, Team $team, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'server-registries-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
function serverRegistriesApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
/**
* Record every SSH command and answer the Docker config read with the given config.
*
* @param array<int, string> $commands
*/
function fakeServerRegistriesSsh(array &$commands, string $config = '', string $error = ''): void
{
Process::fake(function ($process) use (&$commands, $config, $error) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
$commands[] = $command;
if (str_contains($command, '.docker/config.json')) {
return Process::result(output: $config);
}
return $error !== '' ? Process::result(errorOutput: $error, exitCode: 1) : Process::result(output: '');
});
}
test('GET /api/v1/servers/{uuid}/registries lists registry logins without secrets', function () {
$environment = Environment::factory()->create([
'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id,
]);
$destination = $this->server->standaloneDockers()->firstOrFail();
$application = Application::factory()->create([
'name' => 'private-app',
'docker_registry_image_name' => 'registry.example.com:5000/team/app',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$commands = [];
fakeServerRegistriesSsh($commands, json_encode([
'auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:SECRET_TOKEN')]],
]));
$response = $this->withHeaders(serverRegistriesApiHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/registries")
->assertOk()
->assertExactJson([
'registries' => [
['registry' => 'ghcr.io', 'logged_in' => true, 'source' => 'auths', 'username' => 'octocat', 'used_by' => []],
[
'registry' => 'registry.example.com:5000',
'logged_in' => false,
'source' => null,
'username' => null,
'used_by' => [['type' => 'Application', 'name' => 'private-app', 'link' => $application->link()]],
],
],
'error' => null,
]);
expect($response->getContent())
->not->toContain('SECRET_TOKEN')
->not->toContain(base64_encode('octocat:SECRET_TOKEN'));
});
test('GET /api/v1/servers/{uuid}/registries returns an error without SSH when the server is not reachable', function () {
$this->server->settings->update(['is_reachable' => false]);
Process::fake();
$this->withHeaders(serverRegistriesApiHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/registries")
->assertOk()
->assertJsonPath('registries', [])
->assertJsonPath('error', 'The server is not reachable. Validate the server to read its registry logins.');
Process::assertNothingRan();
});
test('GET /api/v1/servers/{uuid}/registries requires read:sensitive', function () {
Process::fake();
$readToken = serverRegistriesApiToken($this->user, $this->team, ['read']);
$this->withHeaders(serverRegistriesApiHeaders($readToken))
->getJson("/api/v1/servers/{$this->server->uuid}/registries")
->assertForbidden();
Process::assertNothingRan();
});
test('POST /api/v1/servers/{uuid}/registries logs in through stdin', function () {
$commands = [];
fakeServerRegistriesSsh($commands);
$token = "ghp_SECRET'token\"with\$special";
$response = $this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->postJson("/api/v1/servers/{$this->server->uuid}/registries", [
'registry' => 'https://GHCR.io/',
'username' => 'octocat',
'password' => $token,
])
->assertOk()
->assertExactJson(['message' => 'Logged in to ghcr.io.']);
expect($response->getContent())->not->toContain('ghp_SECRET');
$login = collect($commands)->first(fn (string $command) => str_contains($command, ' login '));
expect($login)
->toContain("login --username 'octocat' --password-stdin 'ghcr.io'")
->not->toContain('ghp_SECRET');
$event = AuditEvent::query()->where('event', 'api.server.registry_login')->sole();
expect($event->metadata['registry'])->toBe('ghcr.io')
->and($event->metadata['outcome'])->toBe('success')
->and($event->metadata['server_uuid'])->toBe($this->server->uuid)
->and(json_encode($event->metadata))->not->toContain('ghp_SECRET');
expect(AuditEvent::query()->where('event', 'like', 'ui.%')->exists())->toBeFalse();
});
test('POST /api/v1/servers/{uuid}/registries validates the input before running a command', function (array $payload, string $errorField) {
Process::fake();
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->postJson("/api/v1/servers/{$this->server->uuid}/registries", $payload)
->assertUnprocessable()
->assertJsonValidationErrors($errorField);
Process::assertNothingRan();
})->with([
'invalid registry' => [['registry' => 'bad host;rm', 'username' => 'octocat', 'password' => 'secret'], 'registry'],
'missing registry' => [['username' => 'octocat', 'password' => 'secret'], 'registry'],
'username with spaces' => [['registry' => 'ghcr.io', 'username' => 'octo cat', 'password' => 'secret'], 'username'],
'missing password' => [['registry' => 'ghcr.io', 'username' => 'octocat'], 'password'],
'too long password' => [['registry' => 'ghcr.io', 'username' => 'octocat', 'password' => str_repeat('a', 20001)], 'password'],
'unknown field' => [['registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'secret', 'extra' => 'x'], 'extra'],
]);
test('POST /api/v1/servers/{uuid}/registries returns the docker error with the token masked', function () {
$commands = [];
fakeServerRegistriesSsh($commands, error: "WARNING! Using --password via the CLI is insecure.\nError response from daemon: unauthorized: bad token SECRET_TOKEN");
$response = $this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->postJson("/api/v1/servers/{$this->server->uuid}/registries", [
'registry' => 'ghcr.io',
'username' => 'octocat',
'password' => 'SECRET_TOKEN',
])
->assertStatus(400)
->assertExactJson(['message' => 'Login failed: Error response from daemon: unauthorized: bad token ***']);
expect($response->getContent())->not->toContain('SECRET_TOKEN');
expect(AuditEvent::query()->where('event', 'api.server.registry_login')->sole()->metadata['outcome'])->toBe('failed');
});
test('POST /api/v1/servers/{uuid}/registries/{registry}/check checks the saved login', function () {
$commands = [];
fakeServerRegistriesSsh($commands);
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->postJson("/api/v1/servers/{$this->server->uuid}/registries/registry.example.com:5000/check")
->assertOk()
->assertExactJson(['message' => 'The login for registry.example.com:5000 works.']);
expect(collect($commands)->first(fn (string $command) => str_contains($command, ' login')))
->toContain("login 'registry.example.com:5000' </dev/null");
});
test('POST /api/v1/servers/{uuid}/registries/{registry}/check returns the docker error', function () {
$commands = [];
fakeServerRegistriesSsh($commands, error: 'Error: Cannot perform an interactive login from a non TTY device');
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->postJson("/api/v1/servers/{$this->server->uuid}/registries/ghcr.io/check")
->assertStatus(400)
->assertJsonPath('message', fn (string $message) => str_starts_with($message, 'Login check failed:'));
});
test('DELETE /api/v1/servers/{uuid}/registries/{registry} logs out', function () {
$commands = [];
fakeServerRegistriesSsh($commands);
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->deleteJson("/api/v1/servers/{$this->server->uuid}/registries/ghcr.io")
->assertOk()
->assertExactJson(['message' => 'Logged out from ghcr.io.']);
expect(collect($commands)->first(fn (string $command) => str_contains($command, ' logout')))
->toContain("logout 'ghcr.io'");
$event = AuditEvent::query()->where('event', 'api.server.registry_logout')->sole();
expect($event->metadata['registry'])->toBe('ghcr.io')
->and($event->metadata['outcome'])->toBe('success');
});
test('registry path parameters are validated before running a command', function (string $method, string $suffix) {
Process::fake();
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->json($method, "/api/v1/servers/{$this->server->uuid}/registries/{$suffix}")
->assertUnprocessable()
->assertJsonValidationErrors('registry');
Process::assertNothingRan();
})->with([
'check' => ['POST', 'bad%20host;rm/check'],
'logout' => ['DELETE', 'bad%20host;rm'],
]);
test('mutating registry endpoints refuse unreachable servers without SSH', function () {
$this->server->settings->update(['is_reachable' => false]);
Process::fake();
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->deleteJson("/api/v1/servers/{$this->server->uuid}/registries/ghcr.io")
->assertStatus(400);
Process::assertNothingRan();
});
test('registry endpoints return 404 for a server of another team', function (string $method, string $suffix, array $payload) {
Process::fake();
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create([
'team_id' => $otherTeam->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $otherTeam->id])->id,
]);
$rootToken = serverRegistriesApiToken($this->user, $this->team, ['root']);
$this->withHeaders(serverRegistriesApiHeaders($rootToken))
->json($method, "/api/v1/servers/{$otherServer->uuid}/registries{$suffix}", $payload)
->assertNotFound();
Process::assertNothingRan();
})->with([
'list' => ['GET', '', []],
'login' => ['POST', '', ['registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'secret']],
'check' => ['POST', '/ghcr.io/check', []],
'logout' => ['DELETE', '/ghcr.io', []],
]);
test('registry endpoints that run commands require the write ability', function (string $method, string $suffix, array $payload) {
Process::fake();
$readToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$this->withHeaders(serverRegistriesApiHeaders($readToken))
->json($method, "/api/v1/servers/{$this->server->uuid}/registries{$suffix}", $payload)
->assertForbidden();
Process::assertNothingRan();
})->with([
'login' => ['POST', '', ['registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'secret']],
'check' => ['POST', '/ghcr.io/check', []],
'logout' => ['DELETE', '/ghcr.io', []],
]);
test('members cannot use registry endpoints', function () {
Process::fake();
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberReadToken = serverRegistriesApiToken($member, $this->team, ['read']);
$memberSensitiveToken = serverRegistriesApiToken($member, $this->team, ['read', 'read:sensitive']);
$this->withHeaders(serverRegistriesApiHeaders($memberReadToken))
->getJson("/api/v1/servers/{$this->server->uuid}/registries")
->assertForbidden();
$this->withHeaders(serverRegistriesApiHeaders($memberSensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/registries")
->assertForbidden();
Process::assertNothingRan();
});