feat(security): TLS certificate expiry monitoring (feature 7/8)

Monitor the certificate expiry of configured TLS endpoints (the panel's own domain, fleet
services, anything reachable) and flag the ones due soon — before they lapse.

- CertService::check(host, port) reads the peer certificate NATIVELY via a PHP SSL stream +
  openssl_x509_parse — no SSH, no shell, so `host` is never interpolated into a command. Trust is
  intentionally NOT verified (verify_peer off): the goal is to report expiry even for a self-signed
  or already-expired cert. certInfo() (subject/issuer/expiry/daysLeft) is split out for unit testing
  against a generated cert; status() maps daysLeft → ok / warning (<30) / critical (<7) / expired.
- Certs\Index page (route /certs, `operate`-gated: route + mount + per-method). Add/remove endpoints
  (host validated as a hostname/IP — rejects scheme/path/shell chars; port 1-65535). Lazy per-endpoint
  scan, each guarded. Delete via signed ConfirmToken + R5 modal. Add/delete audited. Internal
  endpoints are allowed on purpose (monitoring internal service certs is legitimate; the check only
  reads a cert, it POSTs nothing).
- lang/{de,en}/certs.php + audit.php cert.endpoint_* + shell.nav_certs (de/en parity). No emoji.

10 new tests: service (certInfo extracts subject/expiry from a generated cert, status thresholds,
check errors on an unreachable endpoint), component (route gating, add + audit, reject scheme/
metachar host + out-of-range port, scan checks each endpoint, delete via confirmed token). 730 tests
green, Pint, lang parity, self-reviewed. LE issuance is a documented future (needs ACME on the host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 20:57:53 +02:00
parent 79862ab014
commit 3cab72bf46
16 changed files with 609 additions and 0 deletions

View File

@ -0,0 +1,139 @@
<?php
namespace App\Livewire\Certs;
use App\Models\AuditEvent;
use App\Models\CertEndpoint;
use App\Services\CertService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* TLS certificate-expiry monitoring for configured endpoints. `operate`-gated (route + mount +
* per-method). The expiry check is native (a PHP SSL stream in CertService no SSH/shell, so the
* host is never interpolated into a command); the lazy scan is per-endpoint guarded. Deleting an
* endpoint goes through a signed ConfirmToken + R5 modal.
*/
#[Layout('layouts.app')]
class Index extends Component
{
// Add-endpoint form
public string $label = '';
public string $host = '';
public int $port = 443;
/** @var array<string, array<string, mixed>> endpoint uuid → check result (+ 'status') */
public array $checks = [];
public bool $ready = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('certs.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function scan(CertService $certs): void
{
$this->gate();
$this->checks = [];
foreach (CertEndpoint::orderBy('host')->get() as $ep) {
try {
$res = $certs->check($ep->host, $ep->port);
$res['status'] = $res['ok'] ? $certs->status($res['daysLeft'] ?? null) : 'error';
$this->checks[$ep->uuid] = $res;
} catch (Throwable $e) {
$this->checks[$ep->uuid] = ['ok' => false, 'status' => 'error', 'error' => $e->getMessage()];
}
}
$this->ready = true;
}
public function addEndpoint(): void
{
$this->gate();
$data = $this->validate([
'label' => ['nullable', 'string', 'max:60'],
// A hostname or an IP — never a scheme/path/shell char (it's used in ssl://host:port).
'host' => ['required', 'string', 'max:253', 'regex:/^[A-Za-z0-9]([A-Za-z0-9.\-]*[A-Za-z0-9])?$/'],
'port' => ['required', 'integer', 'min:1', 'max:65535'],
]);
CertEndpoint::create($data);
$this->audit('cert.endpoint_add', $data['host'].':'.$data['port']);
$this->reset('label', 'host');
$this->port = 443;
$this->dispatch('notify', message: __('certs.added', ['host' => $data['host']]));
}
public function confirmDeleteEndpoint(string $uuid): void
{
$this->gate();
$ep = CertEndpoint::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('certs.delete_heading'),
'body' => __('certs.delete_body', ['host' => $ep->host]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('certs.deleted', ['host' => $ep->host]),
'token' => ConfirmToken::issue('certEndpointDeleted', ['uuid' => $ep->uuid], 'cert.endpoint_delete', $ep->host.':'.$ep->port, null),
],
);
}
#[On('certEndpointDeleted')]
public function deleteEndpoint(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'certEndpointDeleted');
} catch (InvalidConfirmToken) {
return;
}
CertEndpoint::where('uuid', $payload['params']['uuid'])->first()?->delete();
}
public function render(): View
{
return view('livewire.certs.index', [
'endpoints' => CertEndpoint::orderBy('host')->get(),
])->title($this->title());
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/** A TLS endpoint (host:port) whose certificate expiry Clusev monitors. */
class CertEndpoint extends Model
{
protected $guarded = [];
protected $casts = ['port' => 'integer'];
protected static function booted(): void
{
static::creating(fn (self $e) => $e->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Services;
/**
* Reads a TLS endpoint's certificate expiry natively (a PHP SSL stream + openssl_x509_parse) no
* SSH, no shell, so `host` is never interpolated into a command. Trust is intentionally NOT verified
* (verify_peer off): the point is to report expiry even for a self-signed or already-expired cert.
* Monitoring internal endpoints is a legitimate use, so private/internal hosts are allowed.
*/
class CertService
{
/**
* @return array{ok:bool, subject?:string, issuer?:string, expiresAt?:int, daysLeft?:?int, error?:string}
*/
public function check(string $host, int $port, int $timeout = 5): array
{
$ctx = stream_context_create(['ssl' => [
'capture_peer_cert' => true,
'verify_peer' => false,
'verify_peer_name' => false,
'SNI_enabled' => true,
'peer_name' => $host,
]]);
$errno = 0;
$errstr = '';
// @ — a refused/timed-out/unresolved endpoint is a normal result here, not a PHP warning.
$client = @stream_socket_client(
'ssl://'.$host.':'.$port,
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$ctx,
);
if (! $client) {
return ['ok' => false, 'error' => $errstr !== '' ? $errstr : 'connection failed'];
}
$params = stream_context_get_params($client);
fclose($client);
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
if ($cert === null) {
return ['ok' => false, 'error' => 'no certificate presented'];
}
return ['ok' => true] + $this->certInfo($cert);
}
/**
* Extract subject/issuer/expiry from an openssl cert (resource or PEM). Split out so it is unit-
* testable against a generated cert without a live network connection.
*
* @param \OpenSSLCertificate|string $cert
* @return array{subject:string, issuer:string, expiresAt:?int, daysLeft:?int}
*/
public function certInfo($cert): array
{
$parsed = openssl_x509_parse($cert) ?: [];
$expiresAt = isset($parsed['validTo_time_t']) ? (int) $parsed['validTo_time_t'] : null;
return [
'subject' => (string) ($parsed['subject']['CN'] ?? ($parsed['subject']['O'] ?? '—')),
'issuer' => (string) ($parsed['issuer']['CN'] ?? ($parsed['issuer']['O'] ?? '—')),
'expiresAt' => $expiresAt,
'daysLeft' => $expiresAt !== null ? (int) floor(($expiresAt - time()) / 86400) : null,
];
}
/** expired · critical (<7d) · warning (<30d) · ok — mapped to the status triad in the UI. */
public function status(?int $daysLeft): string
{
if ($daysLeft === null) {
return 'unknown';
}
return match (true) {
$daysLeft < 0 => 'expired',
$daysLeft < 7 => 'critical',
$daysLeft < 30 => 'warning',
default => 'ok',
};
}
}

View File

@ -71,6 +71,7 @@ class ConfirmToken
'commandRun', 'commandRun',
'runbookDeleted', 'runbookDeleted',
'patchApply', 'patchApply',
'certEndpointDeleted',
]; ];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */ /** How long an issued confirm stays valid (seconds) — generous for a human click. */

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cert_endpoints', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('label')->nullable();
$table->string('host');
$table->unsignedSmallInteger('port')->default(443);
$table->timestamps();
$table->unique(['host', 'port']);
});
}
public function down(): void
{
Schema::dropIfExists('cert_endpoints');
}
};

View File

@ -57,6 +57,8 @@ return [
'fail2ban.configure' => 'fail2ban konfiguriert', 'fail2ban.configure' => 'fail2ban konfiguriert',
'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt', 'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt',
'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt', 'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt',
'cert.endpoint_add' => 'Zertifikat-Endpunkt hinzugefügt',
'cert.endpoint_delete' => 'Zertifikat-Endpunkt entfernt',
'command.run' => 'Fleet-Befehl ausgeführt', 'command.run' => 'Fleet-Befehl ausgeführt',
'docker.action' => 'Container-Aktion', 'docker.action' => 'Container-Aktion',
'runbook.create' => 'Runbook angelegt', 'runbook.create' => 'Runbook angelegt',

42
lang/de/certs.php Normal file
View File

@ -0,0 +1,42 @@
<?php
return [
'eyebrow' => 'Sicherheit',
'title' => 'Zertifikate',
'subtitle' => 'TLS-Ablauf überwachter Endpunkte',
'scan_hint' => 'Prüfe Zertifikate …',
// Add
'new_endpoint' => 'Neuer Endpunkt',
'label_label' => 'Bezeichnung',
'host_label' => 'Host',
'port_label' => 'Port',
'add' => 'Hinzufügen',
// List
'no_endpoints_title' => 'Keine Endpunkte',
'no_endpoints' => 'Noch keine überwachten Endpunkte. Füge oben den ersten hinzu.',
'expires_in' => 'läuft in :days Tagen ab',
'expired_since' => 'abgelaufen vor :days Tagen',
'expires_today' => 'läuft heute ab',
'issuer' => 'Aussteller: :issuer',
'check_error' => 'nicht erreichbar',
'delete' => 'Entfernen',
// Status
'status_ok' => 'Gültig',
'status_warning' => 'Bald fällig',
'status_critical' => 'Kritisch',
'status_expired' => 'Abgelaufen',
'status_error' => 'Fehler',
'status_unknown' => 'Unbekannt',
// Toasts
'added' => 'Endpunkt :host hinzugefügt.',
'deleted' => 'Endpunkt :host entfernt.',
// Delete confirm
'delete_heading' => 'Endpunkt entfernen',
'delete_body' => 'Der Endpunkt :host wird nicht mehr überwacht.',
];

View File

@ -27,6 +27,7 @@ return [
'nav_alerts' => 'Alarme', 'nav_alerts' => 'Alarme',
'nav_posture' => 'Sicherheitslage', 'nav_posture' => 'Sicherheitslage',
'nav_patch' => 'Updates', 'nav_patch' => 'Updates',
'nav_certs' => 'Zertifikate',
'nav_threats' => 'Bedrohungen', 'nav_threats' => 'Bedrohungen',
'nav_versions' => 'Version', 'nav_versions' => 'Version',
'nav_help' => 'Hilfe', 'nav_help' => 'Hilfe',

View File

@ -57,6 +57,8 @@ return [
'fail2ban.configure' => 'fail2ban configured', 'fail2ban.configure' => 'fail2ban configured',
'fail2ban.ignoreip_add' => 'fail2ban: exception added', 'fail2ban.ignoreip_add' => 'fail2ban: exception added',
'fail2ban.ignoreip_remove' => 'fail2ban: exception removed', 'fail2ban.ignoreip_remove' => 'fail2ban: exception removed',
'cert.endpoint_add' => 'Certificate endpoint added',
'cert.endpoint_delete' => 'Certificate endpoint removed',
'command.run' => 'Fleet command run', 'command.run' => 'Fleet command run',
'docker.action' => 'Container action', 'docker.action' => 'Container action',
'runbook.create' => 'Runbook created', 'runbook.create' => 'Runbook created',

42
lang/en/certs.php Normal file
View File

@ -0,0 +1,42 @@
<?php
return [
'eyebrow' => 'Security',
'title' => 'Certificates',
'subtitle' => 'TLS expiry of monitored endpoints',
'scan_hint' => 'Checking certificates …',
// Add
'new_endpoint' => 'New endpoint',
'label_label' => 'Label',
'host_label' => 'Host',
'port_label' => 'Port',
'add' => 'Add',
// List
'no_endpoints_title' => 'No endpoints',
'no_endpoints' => 'No monitored endpoints yet. Add the first one above.',
'expires_in' => 'expires in :days days',
'expired_since' => 'expired :days days ago',
'expires_today' => 'expires today',
'issuer' => 'Issuer: :issuer',
'check_error' => 'unreachable',
'delete' => 'Remove',
// Status
'status_ok' => 'Valid',
'status_warning' => 'Due soon',
'status_critical' => 'Critical',
'status_expired' => 'Expired',
'status_error' => 'Error',
'status_unknown' => 'Unknown',
// Toasts
'added' => 'Endpoint :host added.',
'deleted' => 'Endpoint :host removed.',
// Delete confirm
'delete_heading' => 'Remove endpoint',
'delete_body' => 'The endpoint :host will no longer be monitored.',
];

View File

@ -27,6 +27,7 @@ return [
'nav_alerts' => 'Alerts', 'nav_alerts' => 'Alerts',
'nav_posture' => 'Security posture', 'nav_posture' => 'Security posture',
'nav_patch' => 'Updates', 'nav_patch' => 'Updates',
'nav_certs' => 'Certificates',
'nav_threats' => 'Threats', 'nav_threats' => 'Threats',
'nav_versions' => 'Version', 'nav_versions' => 'Version',
'nav_help' => 'Help', 'nav_help' => 'Help',

View File

@ -63,6 +63,7 @@
@can('operate') @can('operate')
<x-nav-item icon="shield-check" href="/posture" :active="request()->is('posture*')">{{ __('shell.nav_posture') }}</x-nav-item> <x-nav-item icon="shield-check" href="/posture" :active="request()->is('posture*')">{{ __('shell.nav_posture') }}</x-nav-item>
<x-nav-item icon="rotate" href="/patch" :active="request()->is('patch*')">{{ __('shell.nav_patch') }}</x-nav-item> <x-nav-item icon="rotate" href="/patch" :active="request()->is('patch*')">{{ __('shell.nav_patch') }}</x-nav-item>
<x-nav-item icon="lock" href="/certs" :active="request()->is('certs*')">{{ __('shell.nav_certs') }}</x-nav-item>
@endcan @endcan
@can('manage-panel') @can('manage-panel')
<x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item> <x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item>

View File

@ -0,0 +1,78 @@
@php
$field = 'h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$statusPill = ['ok' => 'online', 'warning' => 'warning', 'critical' => 'offline', 'expired' => 'offline', 'error' => 'offline', 'unknown' => 'pending'];
@endphp
<div class="space-y-5" @if (! $ready) wire:init="scan" @endif>
{{-- Header --}}
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('certs.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('certs.title') }}</h2>
<p class="mt-1 text-sm text-ink-3">{{ __('certs.subtitle') }}</p>
</div>
{{-- Add endpoint --}}
<x-panel :title="__('certs.new_endpoint')">
<form wire:submit="addEndpoint" class="grid gap-3 sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)_6rem_auto] sm:items-end">
<div>
<label class="{{ $label }}">{{ __('certs.label_label') }}</label>
<input wire:model="label" type="text" maxlength="60" class="{{ $field }}" />
</div>
<div>
<label class="{{ $label }}">{{ __('certs.host_label') }}</label>
<input wire:model="host" type="text" maxlength="253" placeholder="example.com" class="{{ $field }} font-mono" />
@error('host') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('certs.port_label') }}</label>
<input wire:model="port" type="number" min="1" max="65535" class="{{ $field }} font-mono" />
@error('port') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<x-btn variant="primary" size="lg" type="submit">{{ __('certs.add') }}</x-btn>
</form>
</x-panel>
{{-- Endpoints --}}
@if ($endpoints->isEmpty())
<x-panel>
<div class="py-8 text-center">
<p class="font-display text-sm font-semibold text-ink">{{ __('certs.no_endpoints_title') }}</p>
<p class="mt-1 max-w-sm text-xs text-ink-3">{{ __('certs.no_endpoints') }}</p>
</div>
</x-panel>
@else
<x-panel :padded="false">
<div class="divide-y divide-line">
@foreach ($endpoints as $ep)
@php($c = $checks[$ep->uuid] ?? null)
<div wire:key="ce-{{ $ep->uuid }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
<x-status-dot :status="$c ? ($statusPill[$c['status']] ?? 'pending') : 'pending'" class="h-2.5 w-2.5" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-ink">{{ $ep->label ?: $ep->host }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">
{{ $ep->host }}:{{ $ep->port }}
@if ($c && ($c['ok'] ?? false) && ! is_null($c['daysLeft'] ?? null))
·
@if ($c['daysLeft'] < 0)
{{ __('certs.expired_since', ['days' => abs($c['daysLeft'])]) }}
@elseif ($c['daysLeft'] === 0)
{{ __('certs.expires_today') }}
@else
{{ __('certs.expires_in', ['days' => $c['daysLeft']]) }}
@endif
@elseif ($c && ! ($c['ok'] ?? false))
· {{ __('certs.check_error') }}
@endif
</p>
</div>
@if ($c)
<x-status-pill :status="$statusPill[$c['status']] ?? 'pending'">{{ __('certs.status_'.$c['status']) }}</x-status-pill>
@endif
<x-modal-trigger variant="danger-soft" action="confirmDeleteEndpoint('{{ $ep->uuid }}')">{{ __('certs.delete') }}</x-modal-trigger>
</div>
@endforeach
</div>
</x-panel>
@endif
</div>

View File

@ -8,6 +8,7 @@ use App\Http\Middleware\VerifyTerminalSidecarSecret;
use App\Livewire\Alerts; use App\Livewire\Alerts;
use App\Livewire\Audit; use App\Livewire\Audit;
use App\Livewire\Auth; use App\Livewire\Auth;
use App\Livewire\Certs;
use App\Livewire\Commands; use App\Livewire\Commands;
use App\Livewire\Dashboard; use App\Livewire\Dashboard;
use App\Livewire\Docker; use App\Livewire\Docker;
@ -241,6 +242,7 @@ Route::middleware('auth')->group(function () {
Route::get('/system', System\Index::class)->name('system'); Route::get('/system', System\Index::class)->name('system');
Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture'); Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture');
Route::get('/patch', Patch\Index::class)->middleware('can:operate')->name('patch'); Route::get('/patch', Patch\Index::class)->middleware('can:operate')->name('patch');
Route::get('/certs', Certs\Index::class)->middleware('can:operate')->name('certs');
Route::get('/help', Help\Index::class)->name('help'); Route::get('/help', Help\Index::class)->name('help');
Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard'); Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard');
Route::get('/terminal', Terminal\Index::class)->name('terminal'); Route::get('/terminal', Terminal\Index::class)->name('terminal');

View File

@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Services\CertService;
use Tests\TestCase;
class CertServiceTest extends TestCase
{
/** A self-signed cert valid for $days days, so certInfo can be tested without a live network. */
private function makeCert(string $cn, int $days): \OpenSSLCertificate
{
$pk = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$csr = openssl_csr_new(['commonName' => $cn, 'organizationName' => 'Clusev Test'], $pk);
return openssl_csr_sign($csr, null, $pk, $days);
}
public function test_cert_info_extracts_subject_and_expiry(): void
{
$info = (new CertService)->certInfo($this->makeCert('svc.example.com', 30));
$this->assertSame('svc.example.com', $info['subject']);
$this->assertNotNull($info['expiresAt']);
$this->assertGreaterThanOrEqual(28, $info['daysLeft']); // ~30 days out
$this->assertLessThanOrEqual(30, $info['daysLeft']);
}
public function test_status_maps_days_left_to_the_triad(): void
{
$svc = new CertService;
$this->assertSame('expired', $svc->status(-1));
$this->assertSame('critical', $svc->status(3));
$this->assertSame('warning', $svc->status(20));
$this->assertSame('ok', $svc->status(60));
$this->assertSame('unknown', $svc->status(null));
}
public function test_check_returns_an_error_for_an_unreachable_endpoint(): void
{
// 127.0.0.1:1 is refused immediately → a fast, network-free failure.
$res = (new CertService)->check('127.0.0.1', 1, 1);
$this->assertFalse($res['ok']);
$this->assertArrayHasKey('error', $res);
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace Tests\Feature;
use App\Livewire\Certs\Index;
use App\Models\CertEndpoint;
use App\Models\User;
use App\Services\CertService;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class CertsComponentTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function operator(): User
{
return User::factory()->operator()->create(['must_change_password' => false]);
}
private function viewer(): User
{
return User::factory()->viewer()->create(['must_change_password' => false]);
}
public function test_viewer_cannot_open_certs(): void
{
$this->actingAs($this->viewer())->get('/certs')->assertForbidden();
}
public function test_operator_can_open_certs(): void
{
$this->actingAs($this->operator())->get('/certs')->assertOk();
}
public function test_operator_adds_an_endpoint_and_it_is_audited(): void
{
$this->actingAs($this->operator());
Livewire::test(Index::class)
->set('label', 'Panel')
->set('host', 'panel.example.com')
->set('port', 443)
->call('addEndpoint')
->assertHasNoErrors();
$this->assertDatabaseHas('cert_endpoints', ['host' => 'panel.example.com', 'port' => 443]);
$this->assertDatabaseHas('audit_events', ['action' => 'cert.endpoint_add']);
}
public function test_add_rejects_a_host_with_a_scheme_or_metacharacters(): void
{
$this->actingAs($this->operator());
Livewire::test(Index::class)
->set('host', 'https://evil.com/;rm -rf')
->set('port', 443)
->call('addEndpoint')
->assertHasErrors('host');
$this->assertDatabaseCount('cert_endpoints', 0);
}
public function test_add_rejects_an_out_of_range_port(): void
{
$this->actingAs($this->operator());
Livewire::test(Index::class)
->set('host', 'ok.example.com')
->set('port', 99999)
->call('addEndpoint')
->assertHasErrors('port');
}
public function test_scan_checks_each_endpoint(): void
{
$this->actingAs($this->operator());
CertEndpoint::create(['host' => 'svc.example.com', 'port' => 443]);
$certs = Mockery::mock(CertService::class);
$certs->shouldReceive('check')->once()->andReturn(['ok' => true, 'subject' => 'svc.example.com', 'issuer' => 'LE', 'expiresAt' => time() + 5 * 86400, 'daysLeft' => 5]);
$certs->shouldReceive('status')->once()->with(5)->andReturn('critical');
app()->instance(CertService::class, $certs);
Livewire::test(Index::class)
->call('scan')
->assertOk()
->assertSet('ready', true)
->assertSee(__('certs.status_critical'));
}
public function test_operator_deletes_an_endpoint_via_a_confirmed_token(): void
{
$this->actingAs($this->operator());
$ep = CertEndpoint::create(['host' => 'gone.example.com', 'port' => 443]);
$token = ConfirmToken::issue('certEndpointDeleted', ['uuid' => $ep->uuid], 'cert.endpoint_delete', $ep->host, null);
ConfirmToken::confirm($token);
Livewire::test(Index::class)->call('deleteEndpoint', $token)->assertOk();
$this->assertDatabaseMissing('cert_endpoints', ['id' => $ep->id]);
}
}