mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 01:54:37 +08:00
768 lines
35 KiB
PHP
768 lines
35 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Server\DockerRegistries;
|
|
use App\Livewire\Server\DockerRegistries\Login;
|
|
use App\Livewire\Server\DockerRegistries\ServerRegistries;
|
|
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\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\ServiceDatabase;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\DockerRegistryLogins;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Queue::fake();
|
|
$this->withoutVite();
|
|
$this->withoutDefer();
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
config(['constants.ssh.mux_enabled' => false]);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
|
]);
|
|
|
|
$destination = $this->server->standaloneDockers()->firstOrFail();
|
|
$environment = Environment::factory()->create([
|
|
'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id,
|
|
]);
|
|
$this->environment = $environment;
|
|
$this->destination = $destination;
|
|
$this->privateApp = Application::factory()->create([
|
|
'name' => 'private-app',
|
|
'docker_registry_image_name' => 'registry.example.com/team/app',
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
});
|
|
|
|
function actingAsDockerRegistriesRole(Team $team, string $role): User
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->teams()->attach($team, ['role' => $role]);
|
|
test()->actingAs($user);
|
|
session(['currentTeam' => $team]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
function makeDockerRegistriesServerReachable(Server $server): void
|
|
{
|
|
$server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
|
$server->refresh();
|
|
}
|
|
|
|
it('shows which registries a server is logged in to and which ones its applications need', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
|
|
Process::fake(function ($process) {
|
|
$command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process);
|
|
|
|
return Process::result(output: str_contains($command, '.docker/config.json')
|
|
? json_encode(['auths' => ['ghcr.io' => ['auth' => base64_encode('user:SECRET_TOKEN')]]])
|
|
: '');
|
|
});
|
|
|
|
$component = Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
|
|
->assertSet('error', null)
|
|
->assertSet('registries', [
|
|
['registry' => 'ghcr.io', 'logged_in' => true, 'source' => 'auths', 'username' => 'user', 'used_by' => []],
|
|
[
|
|
'registry' => 'registry.example.com',
|
|
'logged_in' => false,
|
|
'source' => null,
|
|
'username' => null,
|
|
'used_by' => [['type' => 'Application', 'name' => 'private-app', 'link' => $this->privateApp->link()]],
|
|
],
|
|
])
|
|
->assertSee('Logged in')
|
|
->assertSee('Not logged in')
|
|
->assertSee('private-app');
|
|
|
|
expect($component->html())->not->toContain('SECRET_TOKEN')
|
|
->and(json_encode($component->instance()->registries))->not->toContain('SECRET_TOKEN');
|
|
});
|
|
|
|
it('shows an error and keeps the application registries when the server is not reachable', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
Process::fake();
|
|
|
|
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
|
|
->assertSet('error', 'The server is not reachable. Validate the server to read its registry logins.')
|
|
->assertSee('registry.example.com')
|
|
->assertSee('Unknown');
|
|
|
|
Process::assertNothingRan();
|
|
});
|
|
|
|
it('lists the team servers for admins on the registries page', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
|
|
Livewire::test(DockerRegistries::class)
|
|
->assertViewHas('servers', fn ($servers) => $servers->pluck('id')->all() === [$this->server->id]);
|
|
|
|
$this->get(route('registries.index'))
|
|
->assertOk()
|
|
->assertSee('href="'.route('registries.index').'"', false);
|
|
|
|
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
|
|
|
$this->get(route('server.registries', ['server_uuid' => $this->server->uuid]))
|
|
->assertOk()
|
|
->assertSee('href="'.route('server.registries', ['server_uuid' => $this->server->uuid]).'"', false);
|
|
});
|
|
|
|
it('does not show registry logins to members', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'member');
|
|
|
|
Livewire::test(DockerRegistries::class)->assertForbidden();
|
|
|
|
$this->get(route('server.show', ['server_uuid' => $this->server->uuid]))
|
|
->assertOk()
|
|
->assertDontSee('href="'.route('registries.index').'"', false)
|
|
->assertDontSee('href="'.route('server.registries', ['server_uuid' => $this->server->uuid]).'"', false);
|
|
|
|
$this->get(route('server.registries', ['server_uuid' => $this->server->uuid]))->assertForbidden();
|
|
|
|
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])->assertForbidden();
|
|
});
|
|
|
|
it('does not show registry logins of another team server', function () {
|
|
actingAsDockerRegistriesRole(Team::factory()->create(), 'owner');
|
|
|
|
Livewire::test(DockerRegistries::class)
|
|
->assertViewHas('servers', fn ($servers) => $servers->isEmpty());
|
|
|
|
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])->assertForbidden();
|
|
});
|
|
|
|
it('reads the Docker config of a non-root server user through sudo', function () {
|
|
$server = Server::factory()->make(['user' => 'deploy']);
|
|
|
|
$commands = parseCommandsByLineForSudo(collect(['cat "$HOME/.docker/config.json" 2>/dev/null || true']), $server);
|
|
|
|
expect($commands)->toBe(['sudo cat "$HOME/.docker/config.json" 2>/dev/null || sudo true']);
|
|
});
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
function fakeDockerRegistryServer(string $loginError = ''): array
|
|
{
|
|
$commands = [];
|
|
Process::fake(function ($process) use (&$commands, $loginError) {
|
|
$command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process);
|
|
$commands[] = $command;
|
|
|
|
if ($loginError !== '' && str_contains($command, ' login ')) {
|
|
return Process::result(errorOutput: $loginError, exitCode: 1);
|
|
}
|
|
|
|
return Process::result(output: str_contains($command, '.docker/config.json') && str_contains($command, 'cat ')
|
|
? json_encode(['auths' => ['ghcr.io' => ['auth' => 'x']]])
|
|
: '');
|
|
});
|
|
|
|
return $commands;
|
|
}
|
|
|
|
it('logs in to a registry with the token on stdin and never as plain text in the command', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
$commands = [];
|
|
Process::fake(function ($process) use (&$commands) {
|
|
$commands[] = remoteProcessScript($process);
|
|
|
|
return Process::result(output: '');
|
|
});
|
|
$token = "ghp_SECRET'token\"with\$special";
|
|
|
|
Livewire::test(Login::class, ['server' => $this->server])
|
|
->set('registry', 'https://GHCR.io/')
|
|
->set('username', 'octocat')
|
|
->set('password', $token)
|
|
->call('login')
|
|
->assertHasNoErrors()
|
|
->assertSet('password', '')
|
|
->assertSet('registry', '')
|
|
->assertDispatched('success')
|
|
->assertDispatched('close-modal')
|
|
->assertDispatched('registryLoginsChanged', serverId: $this->server->id);
|
|
|
|
$login = collect($commands)->first(fn (string $command) => str_contains($command, ' login '));
|
|
expect($login)
|
|
->toContain("echo '".base64_encode($token)."' | base64 -d | docker --config \"\$HOME/.docker\" login --username 'octocat' --password-stdin 'ghcr.io'")
|
|
->not->toContain('ghp_SECRET');
|
|
|
|
$event = AuditEvent::query()->where('event', 'ui.server.registry_login')->sole();
|
|
expect($event->metadata['registry'])->toBe('ghcr.io')
|
|
->and($event->metadata['outcome'])->toBe('success')
|
|
->and(json_encode($event->metadata))->not->toContain('ghp_SECRET');
|
|
});
|
|
|
|
it('lets docker choose the Docker Hub key and runs the login through sudo for non-root users', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
$this->server->update(['user' => 'deploy']);
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
$commands = [];
|
|
Process::fake(function ($process) use (&$commands) {
|
|
$commands[] = remoteProcessScript($process);
|
|
|
|
return Process::result(output: '');
|
|
});
|
|
|
|
Livewire::test(Login::class, ['server' => $this->server])
|
|
->set('registry', 'docker.io')
|
|
->set('username', 'someone')
|
|
->set('password', 'dckr_pat_SECRET')
|
|
->call('login')
|
|
->assertHasNoErrors();
|
|
|
|
$login = collect($commands)->first(fn (string $command) => str_contains($command, ' login '));
|
|
expect($login)->toContain("echo '".base64_encode('dckr_pat_SECRET')."' | sudo base64 -d | sudo docker --config \"\$HOME/.docker\" login --username 'someone' --password-stdin\n");
|
|
});
|
|
|
|
it('shows the docker error without the token when the login fails', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer("ControlSocket /tmp/mux already exists, disabling multiplexing\nWARNING! Using --password via the CLI is insecure.\nError response from daemon: unauthorized: bad token SECRET_TOKEN");
|
|
|
|
Livewire::test(Login::class, ['server' => $this->server])
|
|
->set('registry', 'ghcr.io')
|
|
->set('username', 'octocat')
|
|
->set('password', 'SECRET_TOKEN')
|
|
->call('login')
|
|
->assertSet('password', '')
|
|
->assertSet('registry', 'ghcr.io')
|
|
->assertDispatched('error', 'Login failed.', 'Error response from daemon: unauthorized: bad token ***');
|
|
|
|
expect(AuditEvent::query()->where('event', 'ui.server.registry_login')->sole()->metadata['outcome'])->toBe('failed');
|
|
});
|
|
|
|
it('validates the registry host and username before running a command', function (string $registry, string $username, string $errorField) {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
$commands = fakeDockerRegistryServer();
|
|
|
|
Livewire::test(Login::class, ['server' => $this->server])
|
|
->set('registry', $registry)
|
|
->set('username', $username)
|
|
->set('password', 'token')
|
|
->call('login')
|
|
->assertHasErrors($errorField);
|
|
|
|
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), ' login '));
|
|
})->with([
|
|
'shell characters in registry' => ["ghcr.io';reboot;'", 'user', 'registry'],
|
|
'space in username' => ['ghcr.io', 'my user', 'username'],
|
|
'empty registry' => ['', 'user', 'registry'],
|
|
]);
|
|
|
|
it('logs out from a registry', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
|
|
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
|
|
->assertSee('Delete the login for ghcr.io?')
|
|
->assertSeeHtml('logout(ghcr.io)')
|
|
->call('logout', 'ghcr.io', '')
|
|
->assertDispatched('success');
|
|
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker --config \"\$HOME/.docker\" logout 'ghcr.io'"));
|
|
expect(AuditEvent::query()->where('event', 'ui.server.registry_logout')->sole()->metadata['registry'])->toBe('ghcr.io');
|
|
});
|
|
|
|
it('does not let a user who lost the admin role log in or log out', function (string $action) {
|
|
$user = actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
$component = $action === 'login'
|
|
? Livewire::test(Login::class, ['server' => $this->server])->set('registry', 'ghcr.io')->set('username', 'u')->set('password', 'p')
|
|
: Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server]);
|
|
|
|
$user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
|
|
$user->refresh();
|
|
Cache::flush();
|
|
|
|
$action === 'login'
|
|
? $component->call('login')->assertForbidden()
|
|
: $component->call('logout', 'ghcr.io')->assertForbidden();
|
|
|
|
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), ' login ') || str_contains(remoteProcessScript($process), ' logout '));
|
|
})->with(['login', 'logout']);
|
|
|
|
it('does not render the registries page again for a user who lost the admin role', function () {
|
|
$user = actingAsDockerRegistriesRole($this->team, 'admin');
|
|
$component = Livewire::test(DockerRegistries::class)->assertOk();
|
|
|
|
$user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
|
|
$user->refresh();
|
|
Cache::flush();
|
|
|
|
$component->call('$refresh')->assertForbidden();
|
|
});
|
|
|
|
it('does not open the login form for members', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'member');
|
|
|
|
Livewire::test(Login::class, ['server' => $this->server])->assertForbidden();
|
|
});
|
|
|
|
it('reloads only the card of the server that got a new login', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
$component = Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
|
|
->set('registries', []);
|
|
|
|
$component->dispatch('registryLoginsChanged', serverId: $this->server->id + 1000)
|
|
->assertSet('registries', []);
|
|
|
|
$component->dispatch('registryLoginsChanged', serverId: $this->server->id)
|
|
->assertSet('registries', fn (array $registries) => collect($registries)->pluck('registry')->all() === ['ghcr.io', 'registry.example.com']);
|
|
});
|
|
|
|
it('fills the registry and username from the chosen provider', function (string $provider, string $registry, string $username) {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
|
|
Livewire::test(Login::class, ['server' => $this->server])
|
|
->set('password', 'typed-before')
|
|
->set('provider', $provider)
|
|
->assertSet('registry', $registry)
|
|
->assertSet('username', $username)
|
|
->assertSet('password', '');
|
|
})->with([
|
|
'docker hub' => ['dockerhub', 'docker.io', ''],
|
|
'google' => ['google', '', '_json_key'],
|
|
'aws' => ['aws', '', 'AWS'],
|
|
'unknown value' => ['nope', '', ''],
|
|
]);
|
|
|
|
it('edits an existing login with the registry kept fixed', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
$commands = [];
|
|
Process::fake(function ($process) use (&$commands) {
|
|
$commands[] = remoteProcessScript($process);
|
|
|
|
return Process::result(output: '');
|
|
});
|
|
|
|
Livewire::test(Login::class, ['server' => $this->server, 'editRegistry' => 'ghcr.io', 'currentUsername' => 'octocat'])
|
|
->assertSet('provider', 'ghcr')
|
|
->assertSet('registry', 'ghcr.io')
|
|
->assertSet('username', 'octocat')
|
|
->assertSee('Update login')
|
|
->set('registry', 'evil.example.com')
|
|
->set('provider', 'dockerhub')
|
|
->assertSet('registry', 'ghcr.io')
|
|
->set('username', 'new-user')
|
|
->set('password', 'NEW_SECRET')
|
|
->call('login')
|
|
->assertHasNoErrors()
|
|
->assertSet('registry', 'ghcr.io')
|
|
->assertSet('username', 'new-user')
|
|
->assertSet('password', '')
|
|
->assertDispatched('success', 'Updated the login for ghcr.io.');
|
|
|
|
$login = collect($commands)->first(fn (string $command) => str_contains($command, ' login '));
|
|
expect($login)->toContain("login --username 'new-user' --password-stdin 'ghcr.io'")
|
|
->not->toContain('evil.example.com');
|
|
expect(AuditEvent::query()->where('event', 'ui.server.registry_login_updated')->sole()->metadata['registry'])->toBe('ghcr.io');
|
|
});
|
|
|
|
it('does not allow changing the edited registry from the browser', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
|
|
expect(fn () => Livewire::test(Login::class, ['server' => $this->server, 'editRegistry' => 'ghcr.io'])
|
|
->set('editRegistry', 'evil.example.com'))->toThrow(Exception::class);
|
|
});
|
|
|
|
it('shows an edit button only for logins stored in the Docker config', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
Process::fake(fn () => Process::result(output: json_encode([
|
|
'auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:SECRET_TOKEN')]],
|
|
'credHelpers' => ['123456789012.dkr.ecr.eu-west-1.amazonaws.com' => 'ecr-login'],
|
|
])));
|
|
|
|
$html = Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])->html();
|
|
|
|
expect(substr_count($html, 'Edit login for'))->toBe(1)
|
|
->and($html)->toContain('Edit login for ghcr.io')
|
|
->and($html)->not->toContain('SECRET_TOKEN');
|
|
});
|
|
|
|
it('guesses the provider of an existing registry', function (string $registry, string $provider) {
|
|
expect(Login::providerFor($registry))->toBe($provider);
|
|
})->with([
|
|
['docker.io', 'dockerhub'],
|
|
['europe-west1-docker.pkg.dev', 'google'],
|
|
['ghcr.io', 'ghcr'],
|
|
['registry.gitlab.com', 'gitlab'],
|
|
['myregistry.azurecr.io', 'azure'],
|
|
['123456789012.dkr.ecr.eu-west-1.amazonaws.com', 'aws'],
|
|
['registry.example.com', 'custom'],
|
|
]);
|
|
|
|
it('always uses the fixed host of a provider, even if the browser sends another one', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
$commands = fakeDockerRegistryServer();
|
|
|
|
Livewire::test(Login::class, ['server' => $this->server])
|
|
->set('provider', 'ghcr')
|
|
->set('registry', 'evil.example.com')
|
|
->set('username', 'octocat')
|
|
->set('password', 'token')
|
|
->call('login')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('success', 'Logged in to ghcr.io.');
|
|
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "--password-stdin 'ghcr.io'"));
|
|
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'evil.example.com'));
|
|
});
|
|
|
|
it('checks that the host belongs to the chosen cloud provider', function (string $provider, string $registry, bool $valid) {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
|
|
$component = Livewire::test(Login::class, ['server' => $this->server])
|
|
->set('provider', $provider)
|
|
->set('registry', $registry)
|
|
->set('username', 'user')
|
|
->set('password', 'token')
|
|
->call('login');
|
|
|
|
$valid ? $component->assertHasNoErrors() : $component->assertHasErrors('registry');
|
|
})->with([
|
|
'google artifact registry' => ['google', 'europe-west1-docker.pkg.dev', true],
|
|
'google container registry' => ['google', 'eu.gcr.io', true],
|
|
'google with other host' => ['google', 'ghcr.io', false],
|
|
'azure' => ['azure', 'myregistry.azurecr.io', true],
|
|
'azure with other host' => ['azure', 'azurecr.io.example.com', false],
|
|
'aws' => ['aws', '123456789012.dkr.ecr.eu-west-1.amazonaws.com', true],
|
|
'aws china' => ['aws', '123456789012.dkr.ecr.cn-north-1.amazonaws.com.cn', true],
|
|
'aws with other host' => ['aws', 'dkr.ecr.eu-west-1.amazonaws.com', false],
|
|
'other accepts any host' => ['custom', 'registry.example.com:5000', true],
|
|
]);
|
|
|
|
it('returns 403 on the registries pages for members', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'member');
|
|
|
|
$this->get(route('registries.index'))->assertForbidden();
|
|
$this->get(route('server.registries', ['server_uuid' => $this->server->uuid]))->assertForbidden();
|
|
});
|
|
|
|
it('does not open the registries page of another team server', function () {
|
|
actingAsDockerRegistriesRole(Team::factory()->create(), 'owner');
|
|
|
|
$response = $this->get(route('server.registries', ['server_uuid' => $this->server->uuid]));
|
|
|
|
expect($response->status())->toBeIn([403, 404])
|
|
->and($response->getContent())->not->toContain('registry.example.com');
|
|
Livewire::test(Login::class, ['server' => $this->server])->assertForbidden();
|
|
});
|
|
|
|
it('does not let the browser swap the server of a card or of the login form', function (string $component) {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
$otherServer = Server::factory()->create([
|
|
'team_id' => Team::factory()->create()->id,
|
|
'private_key_id' => PrivateKey::factory()->create()->id,
|
|
]);
|
|
|
|
$test = $component === 'card'
|
|
? Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
|
|
: Livewire::test(Login::class, ['server' => $this->server]);
|
|
|
|
expect(fn () => $test->set('server', $otherServer->id))->toThrow(CannotUpdateLockedPropertyException::class);
|
|
})->with(['card', 'login form']);
|
|
|
|
it('sends only registry names and usernames to the browser, never secrets from the Docker config', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
$this->server->update(['ip' => '203.0.113.77']);
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
Process::fake(fn () => Process::result(output: json_encode([
|
|
'auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:SECRET_TOKEN'), 'identitytoken' => 'ID_TOKEN_SECRET']],
|
|
'credsStore' => 'pass',
|
|
'proxies' => ['default' => ['httpProxy' => 'http://proxyuser:PROXY_SECRET@proxy']],
|
|
])));
|
|
|
|
$card = Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server]);
|
|
$form = Livewire::test(Login::class, ['server' => $this->server, 'editRegistry' => 'ghcr.io', 'currentUsername' => 'octocat']);
|
|
|
|
expect(array_keys($card->snapshot['data']))->toBe(['server', 'registries', 'error', 'loginChecks'])
|
|
->and(array_keys($form->snapshot['data']))->toBe(['server', 'selectedServers', 'provider', 'editRegistry', 'registry', 'username', 'password'])
|
|
->and($form->snapshot['data']['password'])->toBe('');
|
|
|
|
foreach ([$card, $form] as $component) {
|
|
$payload = json_encode($component->snapshot).$component->html();
|
|
expect($payload)
|
|
->not->toContain('SECRET_TOKEN')
|
|
->not->toContain(base64_encode('octocat:SECRET_TOKEN'))
|
|
->not->toContain('ID_TOKEN_SECRET')
|
|
->not->toContain('PROXY_SECRET')
|
|
->not->toContain('203.0.113.77');
|
|
}
|
|
});
|
|
|
|
it('lists databases and service containers that use a registry, and each service only once', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
$placement = [
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
];
|
|
|
|
$database = StandalonePostgresql::create([
|
|
'name' => 'private-db',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => encrypt('password'),
|
|
'postgres_db' => 'app',
|
|
'image' => 'ghcr.io/acme/postgres:16',
|
|
] + $placement);
|
|
StandalonePostgresql::create([
|
|
'name' => 'coolify-db',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => encrypt('password'),
|
|
'postgres_db' => 'coolify',
|
|
'image' => 'ghcr.io/acme/internal:1',
|
|
] + $placement);
|
|
$service = Service::factory()->create(['name' => 'private-stack', 'server_id' => $this->server->id] + $placement);
|
|
foreach (['web' => 'ghcr.io/acme/web:1', 'worker' => 'ghcr.io/acme/worker:1', 'cache' => 'redis:7'] as $name => $image) {
|
|
ServiceApplication::create(['uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => $name, 'image' => $image]);
|
|
}
|
|
ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'db', 'image' => 'ghcr.io/acme/db:1']);
|
|
|
|
$registries = collect(Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
|
|
->assertSee('private-db')
|
|
->assertSee('private-stack')
|
|
->get('registries'))->keyBy('registry');
|
|
|
|
expect($registries['ghcr.io']['used_by'])->toBe([
|
|
['type' => 'Database', 'name' => 'private-db', 'link' => $database->link()],
|
|
['type' => 'Service', 'name' => 'private-stack', 'link' => $service->link()],
|
|
])
|
|
->and($registries['docker.io']['used_by'])->toBe([
|
|
['type' => 'Service', 'name' => 'private-stack', 'link' => $service->link()],
|
|
])
|
|
->and($registries['docker.io']['logged_in'])->toBeFalse()
|
|
->and($registries['registry.example.com']['used_by'][0]['type'])->toBe('Application');
|
|
});
|
|
|
|
it('shows one user by name and many users as a summary with a grouped list', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
$placement = [
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
];
|
|
foreach (['second-app', 'third-app'] as $name) {
|
|
Application::factory()->create(['name' => $name, 'docker_registry_image_name' => 'registry.example.com/team/'.$name] + $placement);
|
|
}
|
|
StandalonePostgresql::create([
|
|
'name' => 'hub-db',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => encrypt('password'),
|
|
'postgres_db' => 'app',
|
|
'image' => 'postgres:16',
|
|
] + $placement);
|
|
|
|
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
|
|
->assertSee('1 resource on this server uses')
|
|
->assertSee('Docker Hub images. Public images need no login.')
|
|
->assertSee('3 applications')
|
|
->assertSee('Applications (3)')
|
|
->assertSee('second-app')
|
|
->assertSee('third-app');
|
|
});
|
|
|
|
it('shows applications that build on a build server on that build server', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
$buildServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->server->private_key_id]);
|
|
$buildServer->settings->update(['server_role' => 'build', 'is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
|
$buildServer->refresh();
|
|
$this->privateApp->settings->update(['is_build_server_enabled' => true]);
|
|
$placement = [
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
];
|
|
Application::factory()->create(['name' => 'local-build', 'docker_registry_image_name' => 'registry.example.com/team/local'] + $placement);
|
|
$otherTeamServer = Server::factory()->create(['team_id' => Team::factory()->create()->id, 'private_key_id' => PrivateKey::factory()->create()->id]);
|
|
$otherTeamServer->settings->update(['server_role' => 'build']);
|
|
|
|
$users = collect(Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $buildServer])->get('registries'))
|
|
->keyBy('registry');
|
|
|
|
expect($users['registry.example.com']['used_by'])->toBe([
|
|
['type' => 'Build', 'name' => 'private-app', 'link' => $this->privateApp->link()],
|
|
]);
|
|
expect(DockerRegistryLogins::usersOf($otherTeamServer->refresh()))->toBeEmpty();
|
|
});
|
|
|
|
it('offers a login with the registry filled in on a row that is not logged in', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
|
|
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
|
|
->assertSee('Log in to registry.example.com');
|
|
|
|
Livewire::test(Login::class, ['server' => $this->server, 'presetRegistry' => '123456789012.dkr.ecr.eu-west-1.amazonaws.com'])
|
|
->assertSet('provider', 'aws')
|
|
->assertSet('registry', '123456789012.dkr.ecr.eu-west-1.amazonaws.com')
|
|
->assertSet('username', 'AWS');
|
|
});
|
|
|
|
it('tests a saved login and shows the result on the row', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
$checkFails = false;
|
|
Process::fake(function ($process) use (&$checkFails) {
|
|
if (str_contains(remoteProcessScript($process), '</dev/null')) {
|
|
return $checkFails
|
|
? Process::result(errorOutput: "Authenticating with existing credentials... [Username: octocat]\nLogin did not succeed, error: Error response from daemon: unauthorized\nerror: cannot perform an interactive login from a non-TTY device", exitCode: 1)
|
|
: Process::result(output: 'Login Succeeded');
|
|
}
|
|
|
|
return Process::result(output: json_encode(['auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:x')]]]));
|
|
});
|
|
|
|
$component = Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
|
|
->call('testLogin', 'ghcr.io')
|
|
->assertSet('loginChecks', ['ghcr.io' => ['ok' => true, 'message' => 'The login works.']])
|
|
->assertDispatched('success', 'The login for ghcr.io works.');
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker --config \"\$HOME/.docker\" login 'ghcr.io' </dev/null"));
|
|
|
|
$checkFails = true;
|
|
$component->call('testLogin', 'ghcr.io')
|
|
->assertSet('loginChecks', ['ghcr.io' => ['ok' => false, 'message' => 'Error response from daemon: unauthorized']])
|
|
->assertSee('Login failed')
|
|
->call('loadRegistries')
|
|
->assertSet('loginChecks', []);
|
|
});
|
|
|
|
it('does not test registries that are not logged in', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
|
|
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
|
|
->call('testLogin', 'registry.example.com')
|
|
->assertSet('loginChecks', []);
|
|
|
|
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), '</dev/null'));
|
|
});
|
|
|
|
it('logs in to several servers at once from the overview page', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
$secondServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->server->private_key_id]);
|
|
makeDockerRegistriesServerReachable($secondServer);
|
|
fakeDockerRegistryServer();
|
|
|
|
Livewire::test(Login::class)
|
|
->assertSee($this->server->name)
|
|
->assertSee($secondServer->name)
|
|
->set('registry', 'registry.example.com')
|
|
->set('username', 'robot')
|
|
->set('password', 'token')
|
|
->call('login')
|
|
->assertHasErrors('selectedServers')
|
|
->assertSee('Choose at least one server.')
|
|
->set('password', 'token')
|
|
->set('selectedServers', [$this->server->uuid, $secondServer->uuid])
|
|
->call('login')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('registryLoginsChanged', serverId: $this->server->id)
|
|
->assertDispatched('registryLoginsChanged', serverId: $secondServer->id)
|
|
->assertDispatched('success', 'Logged in to registry.example.com.')
|
|
->assertSet('selectedServers', []);
|
|
|
|
expect(AuditEvent::query()->where('event', 'ui.server.registry_login')->count())->toBe(2);
|
|
});
|
|
|
|
it('reports the servers where a multi-server login failed', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer('Error response from daemon: unauthorized');
|
|
|
|
Livewire::test(Login::class)
|
|
->set('selectedServers', [$this->server->uuid])
|
|
->set('registry', 'registry.example.com')
|
|
->set('username', 'robot')
|
|
->set('password', 'token')
|
|
->call('login')
|
|
->assertDispatched('error', 'Login failed.', 'Error response from daemon: unauthorized')
|
|
->assertNotDispatched('registryLoginsChanged');
|
|
});
|
|
|
|
it('refuses a multi-server login to a server of another team', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'admin');
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
fakeDockerRegistryServer();
|
|
$foreignServer = Server::factory()->create(['team_id' => Team::factory()->create()->id, 'private_key_id' => PrivateKey::factory()->create()->id]);
|
|
|
|
Livewire::test(Login::class)
|
|
->assertDontSee($foreignServer->name)
|
|
->set('selectedServers', [$this->server->uuid, $foreignServer->uuid])
|
|
->set('registry', 'registry.example.com')
|
|
->set('username', 'robot')
|
|
->set('password', 'token')
|
|
->call('login')
|
|
->assertForbidden();
|
|
|
|
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), ' login '));
|
|
});
|
|
|
|
it('does not open the overview login form for members', function () {
|
|
actingAsDockerRegistriesRole($this->team, 'member');
|
|
|
|
Livewire::test(Login::class)->assertForbidden();
|
|
});
|
|
|
|
it('explains a missing login and hides SSH connection noise in docker errors', function () {
|
|
makeDockerRegistriesServerReachable($this->server);
|
|
$sshNoise = "Control socket connect(/var/www/html/storage/app/ssh/mux/mux_x): Permission denied\nControlSocket /var/www/html/storage/app/ssh/mux/mux_x already exists, disabling multiplexing\nWarning: Permanently added '203.0.113.7' (ED25519) to the list of known hosts.";
|
|
Process::fake(fn () => Process::result(errorOutput: $sshNoise."\nerror: cannot perform an interactive login from a non-TTY device", exitCode: 1));
|
|
|
|
expect(fn () => DockerRegistryLogins::checkLogin($this->server, 'ghcr.io'))
|
|
->toThrow(RuntimeException::class, 'There is no saved login for ghcr.io on this server.');
|
|
|
|
Process::fake(fn () => Process::result(errorOutput: $sshNoise."\nError response from daemon: unauthorized", exitCode: 1));
|
|
|
|
expect(fn () => DockerRegistryLogins::login($this->server, 'ghcr.io', 'octocat', 'token'))
|
|
->toThrow(fn (RuntimeException $exception) => expect($exception->getMessage())->toBe('Error response from daemon: unauthorized'));
|
|
});
|