feat(patch): fleet patch view + security-update awareness (feature 6/8)

Fleet-wide package-update visibility: pending + SECURITY update counts per server, one-click patch.
Extends MaintenanceService (single-server pending/apply already existed) with the security subset
and a fleet aggregation.

- MaintenanceService::updateCounts(Server) → { pending, security } in ONE round-trip via a new
  PackageManager::countsScript() (apt/dnf/zypper). Security is best-effort per family (apt path solid;
  dnf/zypper degrade to 0 when the metadata isn't there) and clamped ≤ pending. Either value is null
  when the query can't run (shows "unknown", never a misleading 0).
- Patch\Index page (route /patch, `operate`-gated: route + mount + per-method). Lazy per-server scan,
  each guarded (an unreachable host → error row). Fleet KPIs (total pending / total security). Applying
  upgrades is a stateful action → signed ConfirmToken + R5 modal (modal audits patch.apply from the
  sealed token; handler does not re-audit) resolving a client uuid → the sealed server id; a forged
  token is a no-op. Reuses the existing applyUpgrades; the result output is mb_scrub'd before it lands
  in a Livewire prop.
- countsScript strings are STATIC per manager (no operator input) — no injection surface.
- lang/{de,en}/patch.php + audit.php patch.apply + shell.nav_patch (de/en parity). No emoji.

11 new tests: MaintenanceService (parse pending+security, clamp security≤pending, missing-sentinel /
unsupported-manager → unknown), component (route gating, scan shows counts + survives an unreachable
server, patch opens confirm, applyPatch runs upgrades over the SEALED server, forged token no-op).
720 tests green, Pint, lang parity, self-reviewed (RBAC, ConfirmToken seal, static scripts, scrub).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 20:51:17 +02:00
parent 31852fc04c
commit 79862ab014
15 changed files with 519 additions and 0 deletions

View File

@ -0,0 +1,130 @@
<?php
namespace App\Livewire\Patch;
use App\Models\Server;
use App\Services\MaintenanceService;
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;
/**
* Fleet patch view: pending + security update counts per server, one-click patch. `operate`-gated
* (route + mount + per-method). Counts scan is lazy + per-server guarded. Applying upgrades is a
* stateful action a signed ConfirmToken + R5 modal (the modal audits patch.apply from the sealed
* token; the handler does not re-audit). Targets resolve a client uuid the sealed server id.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** @var array<string, array{pending:?int, security:?int}|array{error:string}> uuid → counts */
public array $counts = [];
/** @var array<string, array{ok:bool, output:string}> uuid → last patch result */
public array $results = [];
public bool $ready = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('patch.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function scan(MaintenanceService $maint): void
{
$this->gate();
$this->counts = [];
foreach (Server::withActiveCredential()->orderBy('name')->get() as $server) {
try {
$this->counts[$server->uuid] = $maint->updateCounts($server);
} catch (Throwable $e) {
$this->counts[$server->uuid] = ['error' => $e->getMessage()];
}
}
$this->ready = true;
}
public function patch(string $uuid): void
{
$this->gate();
$server = Server::withActiveCredential()->where('uuid', $uuid)->firstOrFail();
$c = $this->counts[$uuid] ?? [];
$pending = $c['pending'] ?? '?';
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('patch.confirm_heading'),
'body' => __('patch.confirm_body', ['server' => $server->name, 'count' => $pending]),
'confirmLabel' => __('patch.apply'),
'danger' => true,
'icon' => 'rotate',
'notify' => '', // the handler reports the outcome
'token' => ConfirmToken::issue('patchApply', ['uuid' => $server->uuid], 'patch.apply', $server->name, $server->id),
],
);
}
#[On('patchApply')]
public function applyPatch(string $confirmToken, MaintenanceService $maint): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'patchApply');
} catch (InvalidConfirmToken) {
return; // forged / replayed — the modal already audited patch.apply
}
$server = Server::where('uuid', $payload['params']['uuid'])->first();
if (! $server) {
return;
}
$res = $maint->applyUpgrades($server);
$res['output'] = mb_scrub($res['output'], 'UTF-8'); // remote output into a Livewire prop → scrub invalid bytes
$this->results[$server->uuid] = $res;
// Refresh this server's counts so the badge reflects the post-patch state.
try {
$this->counts[$server->uuid] = $maint->updateCounts($server);
} catch (Throwable) {
// keep the old counts if the re-scan fails
}
$this->dispatch('notify', message: $res['ok']
? __('patch.patched', ['server' => $server->name])
: __('patch.patch_failed', ['error' => $res['output']]));
}
public function render(): View
{
// Fleet roll-up (only over successfully-scanned servers).
$scored = array_filter($this->counts, fn ($c) => isset($c['pending']) && $c['pending'] !== null);
$totalPending = array_sum(array_map(fn ($c) => $c['pending'], $scored));
$totalSecurity = array_sum(array_map(fn ($c) => $c['security'] ?? 0, $scored));
return view('livewire.patch.index', [
'servers' => Server::withActiveCredential()->orderBy('name')->get(['uuid', 'name']),
'totalPending' => (int) $totalPending,
'totalSecurity' => (int) $totalSecurity,
])->title($this->title());
}
}

View File

@ -47,6 +47,36 @@ class MaintenanceService
return preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null; return preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null;
} }
/**
* Pending update count AND its security subset in one round-trip. Either value is NULL when the
* query couldn't run (unsupported manager / errored) so the caller shows "unbekannt" rather than
* a misleading 0. Security is best-effort per family; it is always pending.
*
* @return array{pending: ?int, security: ?int}
*/
public function updateCounts(Server $server): array
{
$os = $this->detector->detect($server);
if (! $os->managesPackages()) {
return ['pending' => null, 'security' => null];
}
$res = $this->fleet->runPlain($server, PackageManager::for($os)->countsScript());
if (! str_contains($res['output'], 'CLUSEV_OK')) {
return ['pending' => null, 'security' => null];
}
$pending = preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null;
$security = preg_match('/CLUSEV_SECURITY=(\d+)/', $res['output'], $s) ? (int) $s[1] : null;
// Security can never exceed total pending (guards a noisy grep).
if ($pending !== null && $security !== null) {
$security = min($security, $pending);
}
return ['pending' => $pending, 'security' => $security];
}
/** /**
* Refresh the index and apply all upgrades as root, using the host's package * Refresh the index and apply all upgrades as root, using the host's package
* manager. Long-running, so a 900s timeout. Output trimmed to the last ~400 * manager. Long-running, so a 900s timeout. Output trimmed to the last ~400

View File

@ -70,6 +70,7 @@ class ConfirmToken
'alertRuleDeleted', 'alertRuleDeleted',
'commandRun', 'commandRun',
'runbookDeleted', 'runbookDeleted',
'patchApply',
]; ];
/** 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

@ -51,6 +51,29 @@ final class PackageManager
}; };
} }
/**
* Like pendingScript, but ALSO emits `CLUSEV_SECURITY=<m>` the subset of pending upgrades that
* come from a security source. One round-trip for both counts. Best-effort per family (the apt
* path is solid; dnf/zypper security queries degrade to 0 when the metadata isn't present).
*/
public function countsScript(): string
{
return match ($this->manager) {
'apt' => 'out=$(apt-get -s upgrade 2>/dev/null) && echo CLUSEV_OK; '
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -c '^Inst ')\"; "
."echo \"CLUSEV_SECURITY=\$(printf '%s\\n' \"\$out\" | grep '^Inst ' | grep -ic 'securit')\"",
'dnf' => 'out=$('.$this->dnf().' -q check-update 2>/dev/null); rc=$?; '
.'if [ "$rc" = 0 ] || [ "$rc" = 100 ]; then echo CLUSEV_OK; fi; '
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -cE '^[[:graph:]]+[[:space:]]+[[:graph:]]+[[:space:]]+[[:graph:]]+')\"; "
.'echo "CLUSEV_SECURITY=$('.$this->dnf().' -q --security check-update 2>/dev/null | grep -cE \'^[[:graph:]]+[[:space:]]+[[:graph:]]+[[:space:]]+[[:graph:]]+\')"',
'zypper' => 'out=$(zypper --non-interactive list-updates 2>/dev/null); rc=$?; '
.'[ "$rc" = 0 ] && echo CLUSEV_OK; '
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -cE '^v \\|')\"; "
.'echo "CLUSEV_SECURITY=$(zypper --non-interactive list-patches --category security 2>/dev/null | grep -cE \'^[[:alnum:]]\')"',
default => 'echo CLUSEV_PENDING=0; echo CLUSEV_SECURITY=0',
};
}
/** Refresh index + apply all upgrades (privileged, long-running). */ /** Refresh index + apply all upgrades (privileged, long-running). */
public function applyScript(): string public function applyScript(): string
{ {

View File

@ -70,6 +70,7 @@ return [
'group.members' => 'Gruppen-Zuordnung geändert', 'group.members' => 'Gruppen-Zuordnung geändert',
'mail.configure' => 'E-Mail (SMTP) konfiguriert', 'mail.configure' => 'E-Mail (SMTP) konfiguriert',
'password.change' => 'Passwort geändert', 'password.change' => 'Passwort geändert',
'patch.apply' => 'Updates eingespielt',
'password.reset' => 'Passwort zurückgesetzt', 'password.reset' => 'Passwort zurückgesetzt',
'profile.update' => 'Profil aktualisiert', 'profile.update' => 'Profil aktualisiert',
// Neutral, WAF/IDS-style labels — deliberately do NOT reveal the deception layer to anyone // Neutral, WAF/IDS-style labels — deliberately do NOT reveal the deception layer to anyone

27
lang/de/patch.php Normal file
View File

@ -0,0 +1,27 @@
<?php
return [
'eyebrow' => 'Wartung',
'title' => 'Updates',
'subtitle' => 'Ausstehende Paket-Updates je Server + Sicherheits-Updates',
'scan_hint' => 'Prüfe Updates …',
'total_pending' => 'Ausstehend',
'total_security' => 'Sicherheit',
'no_servers_title' => 'Keine Server',
'no_servers' => 'Keine Server mit aktivem Credential.',
'up_to_date' => 'Aktuell',
'pending_label' => ':count Update(s)',
'security_label' => ':count Sicherheit',
'unknown' => 'unbekannt',
'scan_error' => 'Prüfung fehlgeschlagen',
'apply' => 'Patchen',
'patched' => ':server gepatcht.',
'patch_failed' => 'Patch fehlgeschlagen: :error',
'result_title' => 'Ergebnis',
'confirm_heading' => 'Updates einspielen',
'confirm_body' => 'Alle Updates auf :server einspielen (:count ausstehend)?',
];

View File

@ -26,6 +26,7 @@ return [
'nav_commands' => 'Befehle', 'nav_commands' => 'Befehle',
'nav_alerts' => 'Alarme', 'nav_alerts' => 'Alarme',
'nav_posture' => 'Sicherheitslage', 'nav_posture' => 'Sicherheitslage',
'nav_patch' => 'Updates',
'nav_threats' => 'Bedrohungen', 'nav_threats' => 'Bedrohungen',
'nav_versions' => 'Version', 'nav_versions' => 'Version',
'nav_help' => 'Hilfe', 'nav_help' => 'Hilfe',

View File

@ -70,6 +70,7 @@ return [
'group.members' => 'Group membership changed', 'group.members' => 'Group membership changed',
'mail.configure' => 'E-mail (SMTP) configured', 'mail.configure' => 'E-mail (SMTP) configured',
'password.change' => 'Password changed', 'password.change' => 'Password changed',
'patch.apply' => 'Updates applied',
'password.reset' => 'Password reset', 'password.reset' => 'Password reset',
'profile.update' => 'Profile updated', 'profile.update' => 'Profile updated',
// Neutral, WAF/IDS-style labels — deliberately do NOT reveal the deception layer to anyone // Neutral, WAF/IDS-style labels — deliberately do NOT reveal the deception layer to anyone

27
lang/en/patch.php Normal file
View File

@ -0,0 +1,27 @@
<?php
return [
'eyebrow' => 'Maintenance',
'title' => 'Updates',
'subtitle' => 'Pending package updates per server + security updates',
'scan_hint' => 'Scanning updates …',
'total_pending' => 'Pending',
'total_security' => 'Security',
'no_servers_title' => 'No servers',
'no_servers' => 'No servers with an active credential.',
'up_to_date' => 'Up to date',
'pending_label' => ':count update(s)',
'security_label' => ':count security',
'unknown' => 'unknown',
'scan_error' => 'Scan failed',
'apply' => 'Patch',
'patched' => ':server patched.',
'patch_failed' => 'Patch failed: :error',
'result_title' => 'Result',
'confirm_heading' => 'Apply updates',
'confirm_body' => 'Apply all updates on :server (:count pending)?',
];

View File

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

View File

@ -62,6 +62,7 @@
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item> <x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
@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>
@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,70 @@
<div class="space-y-4" @if (! $ready) wire:init="scan" @endif>
{{-- Header --}}
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('patch.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('patch.title') }}</h2>
<p class="mt-1 text-sm text-ink-3">{{ __('patch.subtitle') }}</p>
</div>
{{-- Fleet KPIs --}}
@if ($ready)
<div class="grid grid-cols-2 gap-3">
<x-kpi :label="__('patch.total_pending')" :value="$totalPending" icon="rotate" :tone="$totalPending > 0 ? 'warning' : 'online'" />
<x-kpi :label="__('patch.total_security')" :value="$totalSecurity" icon="shield-alert" :tone="$totalSecurity > 0 ? 'offline' : 'online'" />
</div>
@endif
@if ($servers->isEmpty())
<x-panel>
<div class="py-8 text-center">
<p class="font-display text-sm font-semibold text-ink">{{ __('patch.no_servers_title') }}</p>
<p class="mt-1 text-xs text-ink-3">{{ __('patch.no_servers') }}</p>
</div>
</x-panel>
@elseif (! $ready)
<div class="space-y-2">
@foreach ($servers as $s)
<div wire:key="pl-{{ $s->uuid }}" class="h-16 animate-pulse rounded-lg bg-raised/50"></div>
@endforeach
<p class="text-center font-mono text-[11px] text-ink-4">{{ __('patch.scan_hint') }}</p>
</div>
@else
<x-panel :padded="false">
<div class="divide-y divide-line">
@foreach ($servers as $s)
@php($c = $counts[$s->uuid] ?? null)
@php($result = $results[$s->uuid] ?? null)
<div wire:key="pr-{{ $s->uuid }}" class="px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center gap-3">
<div class="min-w-0 flex-1">
<a href="{{ route('servers.show', $s->uuid) }}" wire:navigate class="truncate font-mono text-sm text-ink hover:text-accent-text">{{ $s->name }}</a>
</div>
@if (! $c || isset($c['error']))
<span class="font-mono text-[11px] text-offline">{{ __('patch.scan_error') }}</span>
@elseif (is_null($c['pending']))
<span class="font-mono text-[11px] text-ink-4">{{ __('patch.unknown') }}</span>
@elseif ($c['pending'] === 0)
<x-status-pill status="online">{{ __('patch.up_to_date') }}</x-status-pill>
@else
<div class="flex items-center gap-1.5">
<x-status-pill status="warning">{{ __('patch.pending_label', ['count' => $c['pending']]) }}</x-status-pill>
@if (($c['security'] ?? 0) > 0)
<x-status-pill status="offline">{{ __('patch.security_label', ['count' => $c['security']]) }}</x-status-pill>
@endif
@can('operate')
<x-modal-trigger variant="accent" action="patch('{{ $s->uuid }}')" wire:loading.attr="disabled">{{ __('patch.apply') }}</x-modal-trigger>
@endcan
</div>
@endif
</div>
@if ($result)
<pre class="mt-2 max-h-40 overflow-auto rounded-md border border-line bg-void p-3 font-mono text-[11px] leading-relaxed {{ $result['ok'] ? 'text-ink-2' : 'text-offline' }}">{{ $result['output'] }}</pre>
@endif
</div>
@endforeach
</div>
</x-panel>
@endif
</div>

View File

@ -13,6 +13,7 @@ use App\Livewire\Dashboard;
use App\Livewire\Docker; use App\Livewire\Docker;
use App\Livewire\Files; use App\Livewire\Files;
use App\Livewire\Help; use App\Livewire\Help;
use App\Livewire\Patch;
use App\Livewire\Posture; use App\Livewire\Posture;
use App\Livewire\Release; use App\Livewire\Release;
use App\Livewire\Servers; use App\Livewire\Servers;
@ -239,6 +240,7 @@ Route::middleware('auth')->group(function () {
Route::get('/versions', Versions\Index::class)->name('versions'); Route::get('/versions', Versions\Index::class)->name('versions');
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('/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,81 @@
<?php
namespace Tests\Feature;
use App\Models\Server;
use App\Services\FleetService;
use App\Services\MaintenanceService;
use App\Support\Os\OsDetector;
use App\Support\Os\OsProfile;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class MaintenanceUpdateCountsTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function server(): Server
{
return Server::create(['name' => 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
}
private function service(string $runPlainOutput, OsProfile $os): MaintenanceService
{
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('runPlain')->andReturn(['ok' => true, 'output' => $runPlainOutput]);
$detector = Mockery::mock(OsDetector::class);
$detector->shouldReceive('detect')->andReturn($os);
return new MaintenanceService($fleet, $detector);
}
private function apt(): OsProfile
{
return new OsProfile('debian', 'debian', 'Debian 13', 'apt', 'ufw', 'systemd', ['apt-get']);
}
public function test_parses_pending_and_security_counts(): void
{
$counts = $this->service("CLUSEV_OK\nCLUSEV_PENDING=12\nCLUSEV_SECURITY=3", $this->apt())->updateCounts($this->server());
$this->assertSame(12, $counts['pending']);
$this->assertSame(3, $counts['security']);
}
public function test_security_is_clamped_to_pending(): void
{
// A noisy grep could over-count security; it can never exceed the total.
$counts = $this->service("CLUSEV_OK\nCLUSEV_PENDING=2\nCLUSEV_SECURITY=9", $this->apt())->updateCounts($this->server());
$this->assertSame(2, $counts['pending']);
$this->assertSame(2, $counts['security']);
}
public function test_missing_sentinel_yields_unknown(): void
{
$counts = $this->service('some error, no sentinel', $this->apt())->updateCounts($this->server());
$this->assertNull($counts['pending']);
$this->assertNull($counts['security']);
}
public function test_unsupported_manager_yields_unknown(): void
{
$alpine = new OsProfile('alpine', 'alpine', 'Alpine', 'apk', 'none', 'openrc', []);
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('runPlain')->never(); // never even probes an unsupported manager
$detector = Mockery::mock(OsDetector::class);
$detector->shouldReceive('detect')->andReturn($alpine);
$counts = (new MaintenanceService($fleet, $detector))->updateCounts($this->server());
$this->assertNull($counts['pending']);
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace Tests\Feature;
use App\Livewire\Patch\Index;
use App\Models\Server;
use App\Models\User;
use App\Services\MaintenanceService;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class PatchComponentTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function admin(): User
{
return User::factory()->create(['must_change_password' => false]);
}
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]);
}
private function server(string $name = 'box', string $ip = '10.0.0.1'): Server
{
$s = Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']);
$s->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
return $s;
}
public function test_viewer_cannot_open_patch(): void
{
$this->actingAs($this->viewer())->get('/patch')->assertForbidden();
}
public function test_operator_can_open_patch(): void
{
$this->actingAs($this->operator())->get('/patch')->assertOk();
}
public function test_scan_shows_pending_and_security_counts(): void
{
$this->actingAs($this->operator());
$this->server('web1');
$maint = Mockery::mock(MaintenanceService::class);
$maint->shouldReceive('updateCounts')->once()->andReturn(['pending' => 12, 'security' => 3]);
app()->instance(MaintenanceService::class, $maint);
Livewire::test(Index::class)
->call('scan')
->assertOk()
->assertSet('ready', true)
->assertSee('12')
->assertViewHas('totalSecurity', 3);
}
public function test_scan_survives_an_unreachable_server(): void
{
$this->actingAs($this->operator());
$this->server('down');
$maint = Mockery::mock(MaintenanceService::class);
$maint->shouldReceive('updateCounts')->once()->andThrow(new \RuntimeException('ssh fail'));
app()->instance(MaintenanceService::class, $maint);
Livewire::test(Index::class)->call('scan')->assertSet('ready', true)->assertSee(__('patch.scan_error'));
}
public function test_patch_opens_a_confirm(): void
{
$this->actingAs($this->operator());
$server = $this->server();
Livewire::test(Index::class)->call('patch', $server->uuid)->assertDispatched('openModal');
}
public function test_apply_patch_runs_upgrades_over_the_sealed_server(): void
{
$this->actingAs($this->operator());
$server = $this->server();
$maint = Mockery::mock(MaintenanceService::class);
$maint->shouldReceive('applyUpgrades')->once()->with(Mockery::on(fn ($s) => $s->id === $server->id))->andReturn(['ok' => true, 'output' => 'upgraded 12 packages']);
$maint->shouldReceive('updateCounts')->andReturn(['pending' => 0, 'security' => 0]); // post-patch re-scan
app()->instance(MaintenanceService::class, $maint);
$token = ConfirmToken::issue('patchApply', ['uuid' => $server->uuid], 'patch.apply', $server->name, $server->id);
ConfirmToken::confirm($token);
Livewire::test(Index::class)
->call('applyPatch', $token)
->assertOk()
->assertSet("results.{$server->uuid}.output", 'upgraded 12 packages');
}
public function test_a_forged_patch_token_runs_nothing(): void
{
$this->actingAs($this->operator());
$maint = Mockery::mock(MaintenanceService::class);
$maint->shouldReceive('applyUpgrades')->never();
app()->instance(MaintenanceService::class, $maint);
Livewire::test(Index::class)->call('applyPatch', 'not-a-token')->assertSet('results', []);
}
}