diff --git a/app/Actions/Proxy/DeleteTraefikAcmeBackup.php b/app/Actions/Proxy/DeleteTraefikAcmeBackup.php new file mode 100644 index 0000000000..df28e571a9 --- /dev/null +++ b/app/Actions/Proxy/DeleteTraefikAcmeBackup.php @@ -0,0 +1,19 @@ +-. + */ +class ListTraefikAcmeBackups +{ + use AsAction; + + public const KEEP = 10; + + public const NAME_PATTERN = '/^acme\.json\.backup-(\d{8}T\d{6}Z)-[0-9a-f]{8}$/'; + + private const SHELL_NAME_PATTERN = '^acme\.json\.backup-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$'; + + /** @return array */ + public function handle(Server $server): array + { + if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) { + return []; + } + + $pattern = escapeshellarg(self::directory($server)).'/acme.json.backup-*'; + $script = "for file in {$pattern}; do if [ -f \"\$file\" ]; then printf '%s %s\\n' \"\${file##*/}\" \"\$(wc -c < \"\$file\")\"; fi; done"; + $output = instant_remote_process(['sh -c '.escapeshellarg($script)], $server); + + return collect(preg_split('/\R/', (string) $output)) + ->map(function (string $line): ?array { + if (! preg_match('/^(\S+) +(\d+)$/', trim($line), $matches) + || ! preg_match(self::NAME_PATTERN, $matches[1], $name)) { + return null; + } + + return [ + 'name' => $matches[1], + 'created_at' => CarbonImmutable::createFromFormat('Ymd\THis\Z', $name[1], 'UTC')->format('Y-m-d H:i:s').' UTC', + 'size' => (int) $matches[2], + ]; + }) + ->filter() + ->sortByDesc('name') + ->values() + ->all(); + } + + /** + * Resolve a backup name from the client against the backups that exist on the server. + */ + public static function resolve(Server $server, string $name): string + { + if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) { + throw new RuntimeException('TLS certificates can only be managed for Traefik proxies.'); + } + + if (preg_match(self::NAME_PATTERN, $name) !== 1 + || ! collect(self::run($server))->contains('name', $name)) { + throw new RuntimeException('The selected acme.json backup could not be found.'); + } + + return $name; + } + + public static function directory(Server $server): string + { + return rtrim($server->proxyPath(), '/'); + } + + /** + * Shell commands that copy the current acme.json, if any, to a new backup with mode 600. + * + * @return array{script: string, temporary_path: string} + */ + public static function backupCommands(Server $server): array + { + $directory = self::directory($server); + $suffix = bin2hex(random_bytes(4)); + $acmePath = escapeshellarg("{$directory}/acme.json"); + $temporaryPath = "{$directory}/.acme.json.backup-{$suffix}.tmp"; + $backupPath = escapeshellarg("{$directory}/acme.json.backup-".now()->utc()->format('Ymd\THis\Z')."-{$suffix}"); + $temporary = escapeshellarg($temporaryPath); + + return [ + 'script' => "if [ -f {$acmePath} ]; then cp -- {$acmePath} {$temporary}; chmod 600 {$temporary}; mv -- {$temporary} {$backupPath}; fi", + 'temporary_path' => $temporaryPath, + ]; + } + + /** + * Shell commands that delete all but the newest backups. + */ + public static function pruneCommands(Server $server): string + { + $directory = escapeshellarg(self::directory($server)); + + return sprintf( + 'ls -1 -- %s | grep -E %s | sort -r | tail -n +%d | while IFS= read -r name; do rm -f -- %s/"$name"; done', + $directory, + escapeshellarg(self::SHELL_NAME_PATTERN), + self::KEEP + 1, + $directory, + ); + } +} diff --git a/app/Actions/Proxy/RestoreTraefikAcmeBackup.php b/app/Actions/Proxy/RestoreTraefikAcmeBackup.php new file mode 100644 index 0000000000..458b2e76cb --- /dev/null +++ b/app/Actions/Proxy/RestoreTraefikAcmeBackup.php @@ -0,0 +1,44 @@ +proxy->certificates_restart_required = true; + $server->save(); + } +} diff --git a/app/Actions/Proxy/SaveTraefikAcmeFile.php b/app/Actions/Proxy/SaveTraefikAcmeFile.php index de469e7551..586e094bc4 100644 --- a/app/Actions/Proxy/SaveTraefikAcmeFile.php +++ b/app/Actions/Proxy/SaveTraefikAcmeFile.php @@ -26,15 +26,18 @@ class SaveTraefikAcmeFile @unlink($localPath); } - $script = sprintf( - 'set -e; trap "rm -f -- %s" EXIT; umask 077; cat -- %s > %s; chmod 600 %s; mv -- %s %s', - escapeshellarg($uploadPath), - escapeshellarg($uploadPath), - escapeshellarg($temporaryPath), - escapeshellarg($temporaryPath), - escapeshellarg($temporaryPath), - escapeshellarg($path), - ); + // Keep a copy of the current file, so a change can be rolled back from the proxy page. + $backup = ListTraefikAcmeBackups::backupCommands($server); + $script = implode('; ', [ + 'set -e', + 'trap '.escapeshellarg('rm -f -- '.escapeshellarg($uploadPath).' '.escapeshellarg($temporaryPath).' '.escapeshellarg($backup['temporary_path'])).' EXIT', + 'umask 077', + 'cat -- '.escapeshellarg($uploadPath).' > '.escapeshellarg($temporaryPath), + 'chmod 600 '.escapeshellarg($temporaryPath), + $backup['script'], + 'mv -- '.escapeshellarg($temporaryPath).' '.escapeshellarg($path), + ListTraefikAcmeBackups::pruneCommands($server), + ]); instant_remote_process(['sh -c '.escapeshellarg($script)], $server); } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index f5da7146af..8a369edc1d 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -2,15 +2,20 @@ namespace App\Livewire\Server; +use App\Actions\Proxy\DeleteTraefikAcmeBackup; use App\Actions\Proxy\DeleteTraefikCertificate; use App\Actions\Proxy\GetProxyConfiguration; use App\Actions\Proxy\GetTraefikCertificates; +use App\Actions\Proxy\ListTraefikAcmeBackups; +use App\Actions\Proxy\RestoreTraefikAcmeBackup; use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; +use App\Jobs\RestartProxyJob; use App\Models\Server; use App\Rules\SafeExternalUrl; use App\Traits\ListensToTeamChannel; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Gate; use Livewire\Component; class Proxy extends Component @@ -34,6 +39,9 @@ class Proxy extends Component public bool $traefikCertificatesLoaded = false; + /** @var array */ + public array $traefikAcmeBackups = []; + /** * Cache the versions.json file data in memory for this component instance. * This avoids multiple file reads during a single request/render cycle. @@ -241,6 +249,79 @@ class Proxy extends Component $this->traefikCertificatesLoaded = true; handleError($e, $this); } + + $this->loadTraefikAcmeBackups(); + } + + private function loadTraefikAcmeBackups(): void + { + $this->traefikAcmeBackups = []; + + try { + if (Gate::allows('manageProxy', $this->server)) { + $this->traefikAcmeBackups = ListTraefikAcmeBackups::run($this->server); + } + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** Default of the "Restart the proxy now" option in the acme.json restore dialog. */ + public bool $restartProxyAfterAcmeRestore = true; + + /** + * @param array $selectedActions checkbox ids selected in the restore dialog + */ + public function restoreTraefikAcmeBackup(string $backupName, string $password = '', array $selectedActions = []): void + { + try { + $this->authorize('manageProxy', $this->server); + RestoreTraefikAcmeBackup::run($this->server, $backupName); + auditLog('ui.proxy.acme_backup_restored', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'backup' => $backupName, + ]); + $this->loadTraefikCertificates(); + + // A running Traefik keeps its certificates in memory and can write them back to acme.json. + if (in_array('restartProxyAfterAcmeRestore', $selectedActions, true)) { + RestartProxyJob::dispatch($this->server); + auditLog('ui.proxy.restarted', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); + $this->dispatch('refreshServerShow'); + $this->dispatch('success', 'acme.json restored. The proxy is restarting to load the restored certificates.'); + + return; + } + + $this->dispatch('refreshServerShow'); + $this->dispatch('success', 'acme.json restored. Restart the proxy to load the restored certificates.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function deleteTraefikAcmeBackup(string $backupName, string $password = ''): void + { + try { + $this->authorize('manageProxy', $this->server); + DeleteTraefikAcmeBackup::run($this->server, $backupName); + auditLog('ui.proxy.acme_backup_deleted', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'backup' => $backupName, + ]); + $this->loadTraefikAcmeBackups(); + $this->dispatch('success', 'acme.json backup deleted.'); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function deleteTraefikCertificate(string $certificateId, string $password = ''): void @@ -256,6 +337,7 @@ class Proxy extends Component 'resolver' => $certificate['resolver'], ]); $this->traefikCertificates = GetTraefikCertificates::run($this->server); + $this->loadTraefikAcmeBackups(); $this->dispatch('refreshServerShow'); $this->dispatch('success', 'TLS certificate deleted. Restart Traefik to remove it from the running proxy.'); } catch (\Throwable $e) { diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 7d7bd93db6..3b4a465ff3 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -39,7 +39,7 @@ @elseif ($server->hasPendingProxyConfiguration()) - Restart the proxy to stop serving deleted TLS certificates. + Restart the proxy to apply TLS certificate changes. @else
@@ -103,7 +103,7 @@ @if ($traefikCertificatesLoaded && count($traefikCertificates) === 0) + icon-name="shield-star" /> @elseif (count($traefikCertificates) > 0)
@@ -154,6 +154,7 @@ buttonTitle="Delete" submitAction="deleteTraefikCertificate({{ $certificate['id'] }})" :actions="[ + 'Save the current acme.json as a backup that you can restore below.', 'Delete the certificate for '.$certificate['main_domain'].' from acme.json.', 'Restart the proxy before it stops using the certificate. If a route still uses the domain, Traefik requests a new certificate after the restart.', ]" @@ -172,6 +173,70 @@
@endif + + @can('manageProxy', $server) + @if (count($traefikAcmeBackups) > 0) +
+
+

acme.json backups

+

+ Coolify saves a copy of acme.json before it changes the file and keeps the + last {{ \App\Actions\Proxy\ListTraefikAcmeBackups::KEEP }} copies. +

+
+
+
+ + + + + + + + + + + @foreach ($traefikAcmeBackups as $backup) + + + + + + + @endforeach + +
BackupCreatedSizeActions
+ {{ $backup['name'] }} + {{ $backup['created_at'] }}{{ formatBytes($backup['size']) }} +
+ + +
+
+
+
+
+ @endif + @endcan
@endif diff --git a/tests/Feature/TraefikAcmeBackupTest.php b/tests/Feature/TraefikAcmeBackupTest.php new file mode 100644 index 0000000000..25d17a4976 --- /dev/null +++ b/tests/Feature/TraefikAcmeBackupTest.php @@ -0,0 +1,319 @@ + false]); + + $this->baseConfigPath = sys_get_temp_dir().'/coolify-acme-backup-test-'.bin2hex(random_bytes(6)); + mkdir($this->baseConfigPath.'/proxy', 0700, true); + config(['constants.coolify.base_config_path' => $this->baseConfigPath]); + $this->proxyDirectory = $this->baseConfigPath.'/proxy'; + $this->acmePath = $this->proxyDirectory.'/acme.json'; + + $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'], + ]); + + $this->acmeContents = json_encode(['letsencrypt' => ['Certificates' => [ + ['domain' => ['main' => 'one.example.com'], 'certificate' => base64_encode('one'), 'key' => base64_encode('key-one')], + ['domain' => ['main' => 'two.example.com'], 'certificate' => base64_encode('two'), 'key' => base64_encode('key-two')], + ]]]); + file_put_contents($this->acmePath, $this->acmeContents); + chmod($this->acmePath, 0600); + + $this->remoteLines = []; + Process::fake(function ($process) { + $command = $process->command; + if (str_contains($command, ' scp ') && preg_match("~'([^']+)' \\S+:'([^']+)'$~", $command, $matches)) { + copy($matches[1], $matches[2]); + + return Process::result(); + } + + $lines = explode("\n", $command); + $body = array_slice($lines, 1, -1); + array_push($this->remoteLines, ...$body); + $script = implode("\n", array_map(fn (string $line): string => preg_replace('/^sudo /', '', $line), $body)); + + $handle = proc_open(['sh'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + fwrite($pipes[0], $script); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errorOutput = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return Process::result(output: $output, errorOutput: $errorOutput, exitCode: proc_close($handle)); + }); +}); + +afterEach(function () { + File::deleteDirectory($this->baseConfigPath); +}); + +function acmeBackupFiles(string $directory): array +{ + return collect(scandir($directory)) + ->filter(fn (string $name): bool => preg_match(ListTraefikAcmeBackups::NAME_PATTERN, $name) === 1) + ->sort() + ->values() + ->all(); +} + +function createAcmeBackup(string $directory, string $name, string $contents): void +{ + file_put_contents("{$directory}/{$name}", $contents); + chmod("{$directory}/{$name}", 0600); +} + +function filePermissions(string $path): string +{ + clearstatcache(); + + return substr(sprintf('%o', fileperms($path)), -3); +} + +it('backs up acme.json before a certificate is deleted', function () { + DeleteTraefikCertificate::run($this->server, GetTraefikCertificates::run($this->server)[0]['id']); + + $backups = acmeBackupFiles($this->proxyDirectory); + expect($backups)->toHaveCount(1) + ->and($backups[0])->toMatch('/^acme\.json\.backup-\d{8}T\d{6}Z-[0-9a-f]{8}$/') + ->and(file_get_contents($this->proxyDirectory.'/'.$backups[0]))->toBe($this->acmeContents) + ->and(filePermissions($this->proxyDirectory.'/'.$backups[0]))->toBe('600') + ->and(json_decode(file_get_contents($this->acmePath), true)['letsencrypt']['Certificates'])->toHaveCount(1) + ->and(filePermissions($this->acmePath))->toBe('600'); + + // Every remote line must stay one quoted sudo shell script for non-root SSH users. + expect($this->remoteLines)->not->toBeEmpty(); + foreach ($this->remoteLines as $line) { + expect(isSingleSudoShellScript($line))->toBeTrue(); + } +}); + +it('does not create a backup when acme.json does not exist yet', function () { + unlink($this->acmePath); + + SaveTraefikAcmeFile::run($this->server, '{}'); + + expect(acmeBackupFiles($this->proxyDirectory))->toBeEmpty() + ->and(file_get_contents($this->acmePath))->toBe('{}'); +}); + +it('keeps only the newest acme.json backups', function () { + foreach (range(1, 12) as $day) { + createAcmeBackup($this->proxyDirectory, sprintf('acme.json.backup-202601%02dT000000Z-0000000%s', $day, dechex($day)), "old-{$day}"); + } + file_put_contents($this->proxyDirectory.'/acme.json.backup-unrelated', 'keep'); + + SaveTraefikAcmeFile::run($this->server, '{}'); + + $backups = acmeBackupFiles($this->proxyDirectory); + expect($backups)->toHaveCount(ListTraefikAcmeBackups::KEEP) + ->and($backups)->not->toContain('acme.json.backup-20260101T000000Z-00000001', 'acme.json.backup-20260103T000000Z-00000003') + ->and($backups)->toContain('acme.json.backup-20260104T000000Z-00000004') + ->and(file_get_contents($this->proxyDirectory.'/'.end($backups)))->toBe($this->acmeContents) + ->and(file_exists($this->proxyDirectory.'/acme.json.backup-unrelated'))->toBeTrue(); +}); + +it('lists acme.json backups newest first with time and size', function () { + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T101500Z-0000abcd', 'older'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260202T101500Z-0000abce', 'newer backup'); + file_put_contents($this->proxyDirectory.'/acme.json.backup-$(touch pwned)', 'ignored'); + + $backups = ListTraefikAcmeBackups::run($this->server); + + expect($backups)->toBe([ + ['name' => 'acme.json.backup-20260202T101500Z-0000abce', 'created_at' => '2026-02-02 10:15:00 UTC', 'size' => 12], + ['name' => 'acme.json.backup-20260101T101500Z-0000abcd', 'created_at' => '2026-01-01 10:15:00 UTC', 'size' => 5], + ])->and(file_exists($this->proxyDirectory.'/pwned'))->toBeFalse(); +}); + +it('restores a backup after backing up the current acme.json', function () { + // The restored backup is the oldest one, which the pruning must not remove before it is copied. + foreach (range(1, 10) as $day) { + createAcmeBackup($this->proxyDirectory, sprintf('acme.json.backup-202601%02dT000000Z-000000%02x', $day, $day), "backup-{$day}"); + } + + RestoreTraefikAcmeBackup::run($this->server, 'acme.json.backup-20260101T000000Z-00000001'); + + $backups = acmeBackupFiles($this->proxyDirectory); + expect(file_get_contents($this->acmePath))->toBe('backup-1') + ->and(filePermissions($this->acmePath))->toBe('600') + ->and($backups)->toHaveCount(ListTraefikAcmeBackups::KEEP) + ->and(file_get_contents($this->proxyDirectory.'/'.end($backups)))->toBe($this->acmeContents) + ->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeTrue(); + foreach ($this->remoteLines as $line) { + expect(isSingleSudoShellScript($line))->toBeTrue(); + } +}); + +it('refuses to restore or delete backups that are not listed', function (string $name) { + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', 'backup'); + file_put_contents($this->baseConfigPath.'/acme.json.backup-20260101T000000Z-00000002', 'outside'); + + expect(fn () => RestoreTraefikAcmeBackup::run($this->server, $name))->toThrow(RuntimeException::class) + ->and(fn () => DeleteTraefikAcmeBackup::run($this->server, $name))->toThrow(RuntimeException::class) + ->and(file_get_contents($this->acmePath))->toBe($this->acmeContents) + ->and(acmeBackupFiles($this->proxyDirectory))->toBe(['acme.json.backup-20260101T000000Z-00000001']) + ->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse(); +})->with([ + 'path traversal' => '../acme.json.backup-20260101T000000Z-00000002', + 'shell injection' => "acme.json.backup-20260101T000000Z-00000001'; rm -rf /; '", + 'not a backup' => 'acme.json', + 'missing backup' => 'acme.json.backup-20260101T000000Z-0000000f', +]); + +it('deletes a backup', function () { + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', 'backup'); + + DeleteTraefikAcmeBackup::run($this->server, 'acme.json.backup-20260101T000000Z-00000001'); + + expect(acmeBackupFiles($this->proxyDirectory))->toBeEmpty() + ->and(file_get_contents($this->acmePath))->toBe($this->acmeContents); +}); + +function actingAsAcmeBackupUser(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 see and restore acme.json backups from the proxy page', function () { + $this->withoutDefer(); + actingAsAcmeBackupUser($this->server->team, 'admin'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('loadTraefikCertificates') + ->assertSee('acme.json.backup-20260101T000000Z-00000001') + ->assertSee('2026-01-01 00:00:00 UTC') + ->assertSeeHtml(['restoreTraefikAcmeBackup(', 'deleteTraefikAcmeBackup(']) + ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertDispatched('success') + ->assertDispatched('refreshServerShow') + ->assertSet('traefikCertificates', []) + ->assertSee('Restart the proxy to apply TLS certificate changes.'); + + $event = AuditEvent::query()->where('event', 'ui.proxy.acme_backup_restored')->sole(); + expect(file_get_contents($this->acmePath))->toBe('{}') + ->and($event->team_id)->toBe($this->server->team_id) + ->and($event->metadata)->toMatchArray([ + 'server_uuid' => $this->server->uuid, + 'backup' => 'acme.json.backup-20260101T000000Z-00000001', + ]); +}); + +it('restarts the proxy after a restore when the restart option is selected', function () { + Queue::fake(); + $this->withoutDefer(); + actingAsAcmeBackupUser($this->server->team, 'admin'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->assertSet('restartProxyAfterAcmeRestore', true) + ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001', '', ['restartProxyAfterAcmeRestore']) + ->assertDispatched('success'); + + Queue::assertPushed(RestartProxyJob::class, fn (RestartProxyJob $job): bool => $job->server->is($this->server)); + expect(file_get_contents($this->acmePath))->toBe('{}') + ->and(AuditEvent::query()->where('event', 'ui.proxy.restarted')->exists())->toBeTrue(); +}); + +it('does not restart the proxy after a restore when the restart option is cleared', function () { + Queue::fake(); + actingAsAcmeBackupUser($this->server->team, 'admin'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001', '') + ->assertDispatched('success'); + + Queue::assertNotPushed(RestartProxyJob::class); +}); + +it('lets an admin delete an acme.json backup from the proxy page', function () { + actingAsAcmeBackupUser($this->server->team, 'admin'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('deleteTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertDispatched('success') + ->assertSet('traefikAcmeBackups', []); + + expect(acmeBackupFiles($this->proxyDirectory))->toBeEmpty(); +}); + +it('does not let a member list, restore, or delete acme.json backups', function () { + actingAsAcmeBackupUser($this->server->team, 'member'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('loadTraefikCertificates') + ->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 2) + ->assertSet('traefikAcmeBackups', []) + ->assertDontSee('acme.json.backup-20260101T000000Z-00000001') + ->assertDontSeeHtml(['restoreTraefikAcmeBackup(', 'deleteTraefikAcmeBackup(']) + ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertDispatched('error') + ->assertNotDispatched('success') + ->call('deleteTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertNotDispatched('success'); + + expect(file_get_contents($this->acmePath))->toBe($this->acmeContents) + ->and(acmeBackupFiles($this->proxyDirectory))->toBe(['acme.json.backup-20260101T000000Z-00000001']) + ->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse() + ->and(AuditEvent::query()->where('event', 'ui.proxy.acme_backup_restored')->exists())->toBeFalse(); +}); + +it('does not list, restore, or delete acme.json backups of another team', function () { + actingAsAcmeBackupUser(Team::factory()->create(), 'owner'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('loadTraefikCertificates') + ->assertSet('traefikAcmeBackups', []) + ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertDispatched('error') + ->call('deleteTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertNotDispatched('success'); + + expect($this->remoteLines)->toBeEmpty() + ->and(file_get_contents($this->acmePath))->toBe($this->acmeContents) + ->and(acmeBackupFiles($this->proxyDirectory))->toBe(['acme.json.backup-20260101T000000Z-00000001']); +}); diff --git a/tests/Feature/TraefikCertificateRemoteFileTest.php b/tests/Feature/TraefikCertificateRemoteFileTest.php index ae50e45236..f5f80efbbb 100644 --- a/tests/Feature/TraefikCertificateRemoteFileTest.php +++ b/tests/Feature/TraefikCertificateRemoteFileTest.php @@ -100,10 +100,10 @@ it('shows the restart warning after deleting a certificate from the proxy page', $certificateId = GetTraefikCertificates::run($this->server)[0]['id']; Livewire::test(Proxy::class, ['server' => $this->server]) - ->assertDontSee('Restart the proxy to stop serving deleted TLS certificates.') + ->assertDontSee('Restart the proxy to apply TLS certificate changes.') ->call('deleteTraefikCertificate', $certificateId) ->assertDispatched('refreshServerShow') - ->assertSee('Restart the proxy to stop serving deleted TLS certificates.'); + ->assertSee('Restart the proxy to apply TLS certificate changes.'); }); function actingAsTraefikCertificateUser(Team $team, string $role): User @@ -143,7 +143,7 @@ it('lets a member list certificates but not delete them', function () { Livewire::test(Proxy::class, ['server' => $this->server]) ->call('loadTraefikCertificates') ->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 40) - ->assertDontSeeHtml('submitAction="deleteTraefikCertificate') + ->assertDontSeeHtml('deleteTraefikCertificate(') ->call('deleteTraefikCertificate', $certificateId) ->assertDispatched('error') ->assertNotDispatched('success');