diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index b2340c505e..f75e23bfe6 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -294,7 +294,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->preview->generate_preview_fqdn(); } } - if ($this->application->is_github_based()) { + if ($this->application->isGithubAppSource()) { ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::IN_PROGRESS); } if ($this->application->build_pack === 'dockerfile') { @@ -384,7 +384,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->detectBuildKitCapabilities(); $this->decide_what_to_do(); } catch (Exception $e) { - if ($this->pull_request_id !== 0 && $this->application->is_github_based()) { + if ($this->pull_request_id !== 0 && $this->application->isGithubAppSource()) { ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::ERROR); } $this->fail($e); @@ -605,7 +605,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } if ($this->pull_request_id !== 0) { - if ($this->application->is_github_based()) { + if ($this->application->isGithubAppSource()) { try { ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED); } catch (Exception $e) { diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 14da6ac62c..86dbf4e800 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -94,6 +94,9 @@ class Previews extends Component { try { $this->authorize('update', $this->application); + if (! $this->application->isGithubAppSource()) { + throw new \Exception('Loading pull requests is only supported for GitHub App sources.'); + } ['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/pulls"); $this->rate_limit_remaining = $rate_limit_remaining; $this->pull_requests = $data->sortBy('number')->values(); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 8a369edc1d..0bd347ea78 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -2,20 +2,13 @@ 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 @@ -35,13 +28,6 @@ class Proxy extends Component public bool $generateExactLabels = false; - public array $traefikCertificates = []; - - 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. @@ -237,114 +223,6 @@ class Proxy extends Component return $this->server->outdatedCaddyProxyImage(); } - public function loadTraefikCertificates(): void - { - $this->traefikCertificates = []; - - try { - $this->authorize('view', $this->server); - $this->traefikCertificates = GetTraefikCertificates::run($this->server); - $this->traefikCertificatesLoaded = true; - } catch (\Throwable $e) { - $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 - { - try { - $this->authorize('update', $this->server); - $certificate = DeleteTraefikCertificate::run($this->server, $certificateId); - auditLog('ui.proxy.certificate_deleted', [ - 'team_id' => $this->server->team_id, - 'server_uuid' => $this->server->uuid, - 'server_name' => $this->server->name, - 'domain' => $certificate['main_domain'], - '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) { - handleError($e, $this); - } - } - /** * Get the latest Traefik version for this server's current branch. * diff --git a/app/Livewire/Server/Proxy/Certificates.php b/app/Livewire/Server/Proxy/Certificates.php new file mode 100644 index 0000000000..93c16ae083 --- /dev/null +++ b/app/Livewire/Server/Proxy/Certificates.php @@ -0,0 +1,142 @@ + */ + public array $traefikAcmeBackups = []; + + public function loadTraefikCertificates(): void + { + $this->traefikCertificates = []; + + try { + $this->authorize('view', $this->server); + $this->traefikCertificates = GetTraefikCertificates::run($this->server); + $this->traefikCertificatesLoaded = true; + } catch (\Throwable $e) { + $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 + { + try { + $this->authorize('update', $this->server); + $certificate = DeleteTraefikCertificate::run($this->server, $certificateId); + auditLog('ui.proxy.certificate_deleted', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'domain' => $certificate['main_domain'], + '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) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.server.proxy.certificates'); + } +} diff --git a/app/Livewire/Server/Proxy/CertificatesShow.php b/app/Livewire/Server/Proxy/CertificatesShow.php new file mode 100644 index 0000000000..53c56d2b05 --- /dev/null +++ b/app/Livewire/Server/Proxy/CertificatesShow.php @@ -0,0 +1,26 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('view', $this->server); + } + + public function render(): View + { + return view('livewire.server.proxy.certificates-show'); + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index e2694d01c1..277b7ec246 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -652,6 +652,11 @@ class Application extends BaseModel return false; } + public function isGithubAppSource(): bool + { + return $this->source instanceof GithubApp; + } + public function isForceHttpsEnabled() { return data_get($this, 'settings.is_force_https_enabled', false); diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 5418012621..f444210e00 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -70,6 +70,7 @@ 'children' => [ ['label' => 'Configuration', 'route' => 'server.proxy', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'], ['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => $activeSubMenu === 'dynamic-confs', 'icon' => 'sliders', 'visible' => $server->proxySet()], + ['label' => 'TLS Certificates', 'route' => 'server.proxy.certificates', 'active' => $activeSubMenu === 'certificates', 'icon' => 'shield-star', 'visible' => $server->proxyType() === \App\Enums\ProxyTypes::TRAEFIK->value], ['label' => 'Logs', 'route' => 'server.proxy.logs', 'active' => $activeSubMenu === 'logs', 'icon' => 'file-content', 'visible' => $server->proxySet(), 'navigate' => false], ], ], diff --git a/resources/views/livewire/analytics.blade.php b/resources/views/livewire/analytics.blade.php index 1e984ac775..cfcb6e1594 100644 --- a/resources/views/livewire/analytics.blade.php +++ b/resources/views/livewire/analytics.blade.php @@ -255,6 +255,13 @@ $appListboxOptions = array_merge( @else @php $maxAppRequests = max(1, (int) collect($topApps)->max('requests')); @endphp
+
+ Application / service + + Requests + + +
@foreach ($topApps as $row) @php $appHref = $row['link'] ?? null; @@ -279,7 +286,7 @@ $appListboxOptions = array_merge( - {{ compactNumber($row['requests']) }} @if ($appHref) @@ -287,6 +294,8 @@ $appListboxOptions = array_merge( fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> + @else + @endif @endforeach diff --git a/resources/views/livewire/project/application/previews.blade.php b/resources/views/livewire/project/application/previews.blade.php index 36280e873b..23edbf3966 100644 --- a/resources/views/livewire/project/application/previews.blade.php +++ b/resources/views/livewire/project/application/previews.blade.php @@ -7,7 +7,7 @@ helper="Automatic pull request deployments and who can trigger them."> @can('update', $application) - @if ($application->is_github_based()) + @if ($application->isGithubAppSource()) diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 1fe88d5248..037466932a 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -83,164 +83,6 @@
- @if ($server->proxyType() === ProxyTypes::TRAEFIK->value) - - - - Refresh - - - -
- -
- -
- @if ($traefikCertificatesLoaded && count($traefikCertificates) === 0) - - @elseif (count($traefikCertificates) > 0) -
-
- - - - - - - - - - - - @foreach ($traefikCertificates as $certificate) - - - - - - - - @endforeach - -
DomainResolverAlternative namesExpiresActions
- {{ $certificate['main_domain'] }} - - {{ $certificate['resolver'] }} - @if ($certificate['store']) - - ({{ $certificate['store'] }}) - - @endif - - @if (count($certificate['sans']) > 0) -
- @foreach ($certificate['sans'] as $domain) - - {{ $domain }} - - @endforeach -
- @else - None - @endif -
- {{ $certificate['expires_at'] ?? 'Unknown' }} - - @can('update', $server) - - @endcan -
-
-
- @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 - @php $proxyTitle = $server->proxyType() === ProxyTypes::TRAEFIK->value diff --git a/resources/views/livewire/server/proxy/certificates-show.blade.php b/resources/views/livewire/server/proxy/certificates-show.blade.php new file mode 100644 index 0000000000..a58ce1d6fd --- /dev/null +++ b/resources/views/livewire/server/proxy/certificates-show.blade.php @@ -0,0 +1,24 @@ +
+ + TLS Certificates | Coolify + + +
+ + @if ($server->isFunctional() && $server->proxyType() === \App\Enums\ProxyTypes::TRAEFIK->value) +
+ +
+ @else +
+ + + +
+ @endif +
+
diff --git a/resources/views/livewire/server/proxy/certificates.blade.php b/resources/views/livewire/server/proxy/certificates.blade.php new file mode 100644 index 0000000000..8afcaa228c --- /dev/null +++ b/resources/views/livewire/server/proxy/certificates.blade.php @@ -0,0 +1,167 @@ +@php use App\Enums\ProxyTypes; @endphp + +
+ @if ($server->hasPendingProxyConfiguration()) + + Restart the proxy to apply TLS certificate changes. + + @endif + @if ($server->proxyType() === ProxyTypes::TRAEFIK->value) + + + + Refresh + + + +
+ +
+ +
+ @if ($traefikCertificatesLoaded && count($traefikCertificates) === 0) + + @elseif (count($traefikCertificates) > 0) +
+
+ + + + + + + + + + + + @foreach ($traefikCertificates as $certificate) + + + + + + + + @endforeach + +
DomainResolverAlternative namesExpiresActions
+ {{ $certificate['main_domain'] }} + + {{ $certificate['resolver'] }} + @if ($certificate['store']) + + ({{ $certificate['store'] }}) + + @endif + + @if (count($certificate['sans']) > 0) +
+ @foreach ($certificate['sans'] as $domain) + + {{ $domain }} + + @endforeach +
+ @else + None + @endif +
+ {{ $certificate['expires_at'] ?? 'Unknown' }} + + @can('update', $server) + + @endcan +
+
+
+ @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/resources/views/livewire/traffic/_breakdown-section.blade.php b/resources/views/livewire/traffic/_breakdown-section.blade.php index d854f4e924..e6b1e9be4f 100644 --- a/resources/views/livewire/traffic/_breakdown-section.blade.php +++ b/resources/views/livewire/traffic/_breakdown-section.blade.php @@ -31,6 +31,7 @@ : match ($dimension) { 'device' => deviceLabel($value), 'referer' => $host ?? 'Direct / none', + 'status' => $value === '0' ? 'No response status (0)' : ($value !== '' ? $value : 'Unknown'), default => $value !== '' ? $value : 'Unknown', }; $requests = (int) ($row['requests'] ?? 0); diff --git a/resources/views/livewire/traffic/_hosts-list.blade.php b/resources/views/livewire/traffic/_hosts-list.blade.php index 0a1eda3a34..710e8fa637 100644 --- a/resources/views/livewire/traffic/_hosts-list.blade.php +++ b/resources/views/livewire/traffic/_hosts-list.blade.php @@ -16,6 +16,12 @@ icon-name="unordered-list" /> @else
+
+ Host + + Requests + +
@foreach ($hosts as $row) @php $host = (string) ($row['host'] ?? ''); @@ -35,7 +41,7 @@ - {{ compactNumber($requests) }}
diff --git a/resources/views/livewire/traffic/_paths-list.blade.php b/resources/views/livewire/traffic/_paths-list.blade.php index 3ca1a97bdd..ad5e28f46f 100644 --- a/resources/views/livewire/traffic/_paths-list.blade.php +++ b/resources/views/livewire/traffic/_paths-list.blade.php @@ -18,6 +18,16 @@ icon-name="unordered-list" /> @else
+
+ Path + + Requests + + 5xx errors + + + +
@foreach ($paths as $path) @php $domain = $path['domain'] ?? null; @@ -41,7 +51,7 @@ - {{ compactNumber($requests) }} diff --git a/routes/web.php b/routes/web.php index 4300ff674f..65a9cb4d48 100644 --- a/routes/web.php +++ b/routes/web.php @@ -70,6 +70,7 @@ use App\Livewire\Server\GithubRunners; use App\Livewire\Server\Index as ServerIndex; use App\Livewire\Server\LogDrains; use App\Livewire\Server\PrivateKey\Show as PrivateKeyShow; +use App\Livewire\Server\Proxy\CertificatesShow as ProxyCertificatesShow; use App\Livewire\Server\Proxy\DynamicConfigurations as ProxyDynamicConfigurations; use App\Livewire\Server\Proxy\Logs as ProxyLogs; use App\Livewire\Server\Proxy\Show as ProxyShow; @@ -397,6 +398,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/danger', DeleteServer::class)->name('server.delete'); Route::get('/transfer', ServerTransfer::class)->name('server.transfer'); Route::get('/proxy', ProxyShow::class)->name('server.proxy'); + Route::get('/proxy/certificates', ProxyCertificatesShow::class)->name('server.proxy.certificates'); Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs'); Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal'); diff --git a/tests/Feature/ApplicationPreviewSettingsTest.php b/tests/Feature/ApplicationPreviewSettingsTest.php index 9df838d763..02be2b8c6c 100644 --- a/tests/Feature/ApplicationPreviewSettingsTest.php +++ b/tests/Feature/ApplicationPreviewSettingsTest.php @@ -3,6 +3,7 @@ use App\Livewire\Project\Application\Previews; use App\Models\Application; use App\Models\Environment; +use App\Models\GitlabApp; use App\Models\InstanceSettings; use App\Models\PrivateKey; use App\Models\Project; @@ -117,6 +118,23 @@ it('renders preview deployment enablement as a section action', function () { expect($this->application->fresh()->settings->is_preview_deployments_enabled)->toBeTrue(); }); +it('does not load GitHub pull requests for GitLab sources', function () { + $gitlabApp = GitlabApp::create([ + 'name' => 'Self-hosted GitLab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'team_id' => $this->team->id, + ]); + $this->application->update(['source_id' => $gitlabApp->id, 'source_type' => GitlabApp::class]); + + Livewire::test(Previews::class, ['application' => $this->application->fresh()]) + ->assertSee('PR deployment access') + ->assertDontSee('Load pull requests') + ->call('load_prs') + ->assertSet('rate_limit_remaining', 0) + ->assertDispatched('error'); +}); + it('does not show git preview settings for non-git applications', function (string $buildPack, ?string $dockerfile) { $this->application->update(['build_pack' => $buildPack, 'dockerfile' => $dockerfile]); diff --git a/tests/Feature/ProxyAcmeCertificateUiTest.php b/tests/Feature/ProxyAcmeCertificateUiTest.php index d562511ceb..f3fab3d44b 100644 --- a/tests/Feature/ProxyAcmeCertificateUiTest.php +++ b/tests/Feature/ProxyAcmeCertificateUiTest.php @@ -1,8 +1,8 @@ toContain('submitAction="deleteTraefikCertificate') diff --git a/tests/Feature/TraefikAcmeBackupTest.php b/tests/Feature/TraefikAcmeBackupTest.php index 25d17a4976..841cd351fe 100644 --- a/tests/Feature/TraefikAcmeBackupTest.php +++ b/tests/Feature/TraefikAcmeBackupTest.php @@ -8,7 +8,7 @@ use App\Actions\Proxy\RestoreTraefikAcmeBackup; use App\Actions\Proxy\SaveTraefikAcmeFile; use App\Enums\ProxyTypes; use App\Jobs\RestartProxyJob; -use App\Livewire\Server\Proxy; +use App\Livewire\Server\Proxy\Certificates; use App\Models\AuditEvent; use App\Models\InstanceSettings; use App\Models\PrivateKey; @@ -219,7 +219,7 @@ it('lets an admin see and restore acme.json backups from the proxy page', functi actingAsAcmeBackupUser($this->server->team, 'admin'); createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); - Livewire::test(Proxy::class, ['server' => $this->server]) + Livewire::test(Certificates::class, ['server' => $this->server]) ->call('loadTraefikCertificates') ->assertSee('acme.json.backup-20260101T000000Z-00000001') ->assertSee('2026-01-01 00:00:00 UTC') @@ -245,7 +245,7 @@ it('restarts the proxy after a restore when the restart option is selected', fun actingAsAcmeBackupUser($this->server->team, 'admin'); createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); - Livewire::test(Proxy::class, ['server' => $this->server]) + Livewire::test(Certificates::class, ['server' => $this->server]) ->assertSet('restartProxyAfterAcmeRestore', true) ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001', '', ['restartProxyAfterAcmeRestore']) ->assertDispatched('success'); @@ -260,7 +260,7 @@ it('does not restart the proxy after a restore when the restart option is cleare actingAsAcmeBackupUser($this->server->team, 'admin'); createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); - Livewire::test(Proxy::class, ['server' => $this->server]) + Livewire::test(Certificates::class, ['server' => $this->server]) ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001', '') ->assertDispatched('success'); @@ -271,7 +271,7 @@ 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]) + Livewire::test(Certificates::class, ['server' => $this->server]) ->call('deleteTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') ->assertDispatched('success') ->assertSet('traefikAcmeBackups', []); @@ -283,7 +283,7 @@ 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]) + Livewire::test(Certificates::class, ['server' => $this->server]) ->call('loadTraefikCertificates') ->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 2) ->assertSet('traefikAcmeBackups', []) @@ -305,7 +305,7 @@ it('does not list, restore, or delete acme.json backups of another team', functi actingAsAcmeBackupUser(Team::factory()->create(), 'owner'); createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); - Livewire::test(Proxy::class, ['server' => $this->server]) + Livewire::test(Certificates::class, ['server' => $this->server]) ->call('loadTraefikCertificates') ->assertSet('traefikAcmeBackups', []) ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') diff --git a/tests/Feature/TraefikCertificateRemoteFileTest.php b/tests/Feature/TraefikCertificateRemoteFileTest.php index f5f80efbbb..972d0f4986 100644 --- a/tests/Feature/TraefikCertificateRemoteFileTest.php +++ b/tests/Feature/TraefikCertificateRemoteFileTest.php @@ -4,7 +4,7 @@ use App\Actions\Development\SeedDevelopmentTraefikCertificates; use App\Actions\Proxy\DeleteTraefikCertificate; use App\Actions\Proxy\GetTraefikCertificates; use App\Enums\ProxyTypes; -use App\Livewire\Server\Proxy; +use App\Livewire\Server\Proxy\Certificates; use App\Models\AuditEvent; use App\Models\InstanceSettings; use App\Models\PrivateKey; @@ -99,7 +99,7 @@ it('shows the restart warning after deleting a certificate from the proxy page', session(['currentTeam' => $this->server->team]); $certificateId = GetTraefikCertificates::run($this->server)[0]['id']; - Livewire::test(Proxy::class, ['server' => $this->server]) + Livewire::test(Certificates::class, ['server' => $this->server]) ->assertDontSee('Restart the proxy to apply TLS certificate changes.') ->call('deleteTraefikCertificate', $certificateId) ->assertDispatched('refreshServerShow') @@ -122,7 +122,7 @@ it('lets an admin delete a certificate and records an audit event', function () actingAsTraefikCertificateUser($this->server->team, 'admin'); $certificate = GetTraefikCertificates::run($this->server)[0]; - Livewire::test(Proxy::class, ['server' => $this->server]) + Livewire::test(Certificates::class, ['server' => $this->server]) ->call('deleteTraefikCertificate', $certificate['id']) ->assertDispatched('success'); @@ -140,7 +140,7 @@ 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(Proxy::class, ['server' => $this->server]) + Livewire::test(Certificates::class, ['server' => $this->server]) ->call('loadTraefikCertificates') ->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 40) ->assertDontSeeHtml('deleteTraefikCertificate(') @@ -157,7 +157,7 @@ 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(Proxy::class, ['server' => $this->server]) + Livewire::test(Certificates::class, ['server' => $this->server]) ->call('loadTraefikCertificates') ->assertSet('traefikCertificates', []) ->assertDontSee('app1.example.com') @@ -186,3 +186,38 @@ it('refuses to add example certificates outside development', function () { 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'); +}); diff --git a/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php b/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php index 2401622624..54fd736d6f 100644 --- a/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php +++ b/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php @@ -55,3 +55,21 @@ it('renders ampersands in breakdown empty-state descriptions', function () { ->toContain('No ai agents & bots data for the selected range.') ->not->toContain('No ai agents & bots data for the selected range.'); }); + +it('distinguishes a missing response status from numeric values in other dimensions', function (string $dimension, mixed $value, string $display) { + $html = view('livewire.traffic._breakdown-section', [ + 'dimension' => $dimension, + 'label' => 'Breakdown', + 'rows' => [['value' => $value, 'requests' => 2, 'bytesOut' => 0]], + ])->render(); + + expect($html)->toContain('title="'.$display.'"') + ->toContain('title="2 requests"'); +})->with([ + 'integer zero status' => ['status', 0, 'No response status (0)'], + 'string zero status' => ['status', '0', 'No response status (0)'], + 'valid status' => ['status', '200', '200'], + 'missing status' => ['status', '', 'Unknown'], + 'overflow status' => ['status', '__other__', 'Other'], + 'zero in another dimension' => ['path', '0', '0'], +]);