Files
coolify/tests/Feature/ServerDockerRegistriesTest.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'));
});