mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 01:54:37 +08:00
224 lines
9.9 KiB
PHP
224 lines
9.9 KiB
PHP
<?php
|
|
|
|
use App\Actions\Development\SeedDevelopmentTraefikCertificates;
|
|
use App\Actions\Proxy\DeleteTraefikCertificate;
|
|
use App\Actions\Proxy\GetTraefikCertificates;
|
|
use App\Enums\ProxyTypes;
|
|
use App\Livewire\Server\Proxy\Certificates;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Server;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\TraefikAcmeService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config(['constants.ssh.mux_enabled' => false]);
|
|
|
|
$team = Team::factory()->create();
|
|
$key = PrivateKey::factory()->create(['team_id' => $team->id]);
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $team->id,
|
|
'private_key_id' => $key->id,
|
|
'user' => 'deploy',
|
|
'proxy' => ['type' => ProxyTypes::TRAEFIK->value, 'status' => 'running'],
|
|
]);
|
|
|
|
// Large enough that the old inline write exceeded the 128 KiB single-argument limit.
|
|
$certificates = collect(range(1, 40))->map(fn (int $number): array => [
|
|
'domain' => ['main' => "app{$number}.example.com"],
|
|
'certificate' => base64_encode(str_repeat("certificate-{$number}", 400)),
|
|
'key' => base64_encode(str_repeat("key-{$number}", 150)),
|
|
])->all();
|
|
$this->acmeContents = json_encode(['letsencrypt' => ['Certificates' => $certificates]]);
|
|
|
|
$this->uploadedContents = null;
|
|
Process::fake(function ($process) {
|
|
$command = remoteProcessScript($process);
|
|
if (str_starts_with($command, 'timeout') && str_contains($command, ' scp ')
|
|
&& preg_match("~'(".preg_quote(sys_get_temp_dir(), '~')."/coolify-acme-[^']+)'~", $command, $matches)) {
|
|
$this->uploadedContents = file_get_contents($matches[1]);
|
|
|
|
return Process::result();
|
|
}
|
|
|
|
return Process::result(output: str_contains($command, 'head -c') ? base64_encode($this->acmeContents) : '');
|
|
});
|
|
});
|
|
|
|
it('reads the ACME file with sudo on non-root servers', function () {
|
|
$certificates = GetTraefikCertificates::run($this->server);
|
|
|
|
expect($certificates)->toHaveCount(40);
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "\nsudo sh -c 'if [ ! -f")
|
|
&& str_contains(remoteProcessScript($process), 'head -c'));
|
|
});
|
|
|
|
it('uploads a large ACME file instead of passing it as a shell argument', function () {
|
|
$certificate = GetTraefikCertificates::run($this->server)[0];
|
|
|
|
DeleteTraefikCertificate::run($this->server, $certificate['id']);
|
|
|
|
$remaining = json_decode($this->uploadedContents, true)['letsencrypt']['Certificates'];
|
|
expect(strlen($this->acmeContents))->toBeGreaterThan(128 * 1024)
|
|
->and($remaining)->toHaveCount(39)
|
|
->and(collect($remaining)->pluck('domain.main'))->not->toContain('app1.example.com');
|
|
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "\nsudo sh -c 'set -e;")
|
|
&& str_contains(remoteProcessScript($process), 'umask 077')
|
|
&& str_contains(remoteProcessScript($process), 'chmod 600')
|
|
&& str_contains(remoteProcessScript($process), 'mv --'));
|
|
Process::assertDidntRun(fn ($process) => strlen($process->command) > 128 * 1024);
|
|
expect(glob(sys_get_temp_dir().'/coolify-acme-*'))->toBeEmpty();
|
|
});
|
|
|
|
it('asks for a proxy restart after a certificate is deleted until the proxy is restarted', function () {
|
|
expect($this->server->hasPendingProxyConfiguration())->toBeFalse();
|
|
|
|
DeleteTraefikCertificate::run($this->server, GetTraefikCertificates::run($this->server)[0]['id']);
|
|
|
|
expect($this->server->fresh()->hasPendingProxyConfiguration())->toBeTrue();
|
|
|
|
$this->server->markProxyConfigurationApplied('services: {}');
|
|
|
|
expect($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse()
|
|
->and($this->server->fresh()->proxy->get('last_applied_settings'))->toBe(md5(base64_encode('services: {}')));
|
|
});
|
|
|
|
it('shows the restart warning after deleting a certificate from the proxy page', function () {
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
$user = User::factory()->create();
|
|
$user->teams()->attach($this->server->team_id, ['role' => 'admin']);
|
|
$this->actingAs($user);
|
|
session(['currentTeam' => $this->server->team]);
|
|
$certificateId = GetTraefikCertificates::run($this->server)[0]['id'];
|
|
|
|
Livewire::test(Certificates::class, ['server' => $this->server])
|
|
->assertDontSee('Restart the proxy to apply TLS certificate changes.')
|
|
->call('deleteTraefikCertificate', $certificateId)
|
|
->assertDispatched('refreshServerShow')
|
|
->assertSee('Restart the proxy to apply TLS certificate changes.');
|
|
});
|
|
|
|
function actingAsTraefikCertificateUser(Team $team, string $role): User
|
|
{
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
$user = User::factory()->create();
|
|
$user->teams()->attach($team->id, ['role' => $role]);
|
|
test()->actingAs($user);
|
|
session(['currentTeam' => $team]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
it('lets an admin delete a certificate and records an audit event', function () {
|
|
$this->withoutDefer();
|
|
actingAsTraefikCertificateUser($this->server->team, 'admin');
|
|
$certificate = GetTraefikCertificates::run($this->server)[0];
|
|
|
|
Livewire::test(Certificates::class, ['server' => $this->server])
|
|
->call('deleteTraefikCertificate', $certificate['id'])
|
|
->assertDispatched('success');
|
|
|
|
$event = AuditEvent::query()->where('event', 'ui.proxy.certificate_deleted')->sole();
|
|
expect($this->uploadedContents)->not->toBeNull()
|
|
->and($event->team_id)->toBe($this->server->team_id)
|
|
->and($event->metadata)->toMatchArray([
|
|
'server_uuid' => $this->server->uuid,
|
|
'domain' => 'app1.example.com',
|
|
'resolver' => 'letsencrypt',
|
|
]);
|
|
});
|
|
|
|
it('lets a member list certificates but not delete them', function () {
|
|
actingAsTraefikCertificateUser($this->server->team, 'member');
|
|
$certificateId = GetTraefikCertificates::run($this->server)[0]['id'];
|
|
|
|
Livewire::test(Certificates::class, ['server' => $this->server])
|
|
->call('loadTraefikCertificates')
|
|
->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 40)
|
|
->assertDontSeeHtml('deleteTraefikCertificate(')
|
|
->call('deleteTraefikCertificate', $certificateId)
|
|
->assertDispatched('error')
|
|
->assertNotDispatched('success');
|
|
|
|
expect($this->uploadedContents)->toBeNull()
|
|
->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse()
|
|
->and(AuditEvent::query()->where('event', 'ui.proxy.certificate_deleted')->exists())->toBeFalse();
|
|
});
|
|
|
|
it('does not show or delete certificates of another team', function () {
|
|
actingAsTraefikCertificateUser(Team::factory()->create(), 'owner');
|
|
$certificateId = app(TraefikAcmeService::class)->certificates($this->acmeContents)[0]['id'];
|
|
|
|
Livewire::test(Certificates::class, ['server' => $this->server])
|
|
->call('loadTraefikCertificates')
|
|
->assertSet('traefikCertificates', [])
|
|
->assertDontSee('app1.example.com')
|
|
->call('deleteTraefikCertificate', $certificateId)
|
|
->assertDispatched('error');
|
|
|
|
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'acme.json'));
|
|
expect($this->uploadedContents)->toBeNull()
|
|
->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse();
|
|
});
|
|
|
|
it('adds example certificates in development without touching other resolvers', function () {
|
|
config(['app.env' => 'local']);
|
|
|
|
$this->artisan('dev:traefik-certificates', ['server' => $this->server->id])->assertSuccessful();
|
|
|
|
$certificates = collect(app(TraefikAcmeService::class)->certificates($this->uploadedContents));
|
|
$examples = $certificates->where('resolver', SeedDevelopmentTraefikCertificates::RESOLVER);
|
|
expect($certificates->where('resolver', 'letsencrypt'))->toHaveCount(40)
|
|
->and($examples->pluck('main_domain')->all())->toContain('app.coolify.test', '*.preview.coolify.test')
|
|
->and($examples->pluck('expires_at')->filter())->toHaveCount($examples->count());
|
|
});
|
|
|
|
it('refuses to add example certificates outside development', function () {
|
|
$this->artisan('dev:traefik-certificates', ['server' => $this->server->id])->assertFailed();
|
|
|
|
expect($this->uploadedContents)->toBeNull();
|
|
});
|
|
|
|
it('serves the certificate page for team users', function (string $role) {
|
|
$this->server->team->update(['show_boarding' => false]);
|
|
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
|
actingAsTraefikCertificateUser($this->server->team, $role);
|
|
|
|
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
|
|
->assertOk()
|
|
->assertSeeLivewire(Certificates::class);
|
|
})->with(['admin', 'member']);
|
|
|
|
it('does not serve the certificate page of another team', function () {
|
|
actingAsTraefikCertificateUser(Team::factory()->create(['show_boarding' => false]), 'owner');
|
|
|
|
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
|
|
->assertNotFound();
|
|
});
|
|
|
|
it('requires login to open the certificate page', function () {
|
|
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
|
|
->assertRedirect(route('login'));
|
|
});
|
|
|
|
it('does not load certificate controls for a Caddy server', function () {
|
|
actingAsTraefikCertificateUser($this->server->team, 'admin');
|
|
$this->server->team->update(['show_boarding' => false]);
|
|
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
|
$this->server->proxy = ['type' => ProxyTypes::CADDY->value, 'status' => 'running'];
|
|
$this->server->save();
|
|
|
|
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
|
|
->assertOk()
|
|
->assertDontSeeLivewire(Certificates::class)
|
|
->assertSee('Traefik required');
|
|
});
|