From 5e4f29216d855017eba1a1deac77748108e1e572 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 5 Jul 2026 20:38:09 +0200 Subject: [PATCH] feat(commands): ad-hoc fleet commands + runbooks (feature 4/8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-server amplifier: run a command (ad-hoc or a saved runbook) across the whole fleet, a group, or a selection, with a per-server result. Because it is arbitrary remote execution, authorization + audit are the whole feature. - The command is intentionally arbitrary; it runs via FleetService::runPlain (the credential's login user, base64 transport, NO extra sudo) — the same privilege the web terminal already grants, batched. No injection to prevent; the concern is who may run + that every run is logged. - CommandRunner.run: per-server runPlain, one server failing never aborts the batch, output byte-capped (256 KiB) + mb_scrub'd before it reaches a Livewire property. - Commands\Index page, fully `operate`-gated: route can:operate + mount() + a per-method gate() on run/runRunbook/execute/createRunbook/confirmDeleteRunbook/deleteRunbook. Every run goes through a signed single-use ConfirmToken + R5 confirm modal ("run on N servers?"); the modal audits command.run from the sealed token so execute() does not re-audit. Targets resolve client UUIDs → ids server-side (withActiveCredential + inGroup + whereIn), and {command, serverIds} is SEALED in the token — a client can't swap the command or targets after confirm. A forged token is a no-op. - Runbooks: saved name+command, operate-gated CRUD, delete via ConfirmToken. - lang/{de,en}/commands.php + audit.php command.run / runbook.* + shell.nav_commands (de/en). No emoji. 15 new tests: CommandRunner (runs each server, one-failing-doesn't-abort, nonzero=not-ok, byte-cap +scrub), component (route gating, ad-hoc run opens confirm, empty-command / no-target errors, execute runs the SEALED targets, forged token runs nothing, group scope targets only members, runbook create +audit + run + delete-via-token). 700 tests green, Pint, lang parity, Codex-reviewed. Co-Authored-By: Claude Fable 5 --- app/Livewire/Commands/Index.php | 214 ++++++++++++++++++ app/Models/Runbook.php | 22 ++ app/Services/CommandRunner.php | 46 ++++ app/Support/Confirm/ConfirmToken.php | 2 + ...026_07_05_100004_create_runbooks_table.php | 24 ++ lang/de/audit.php | 3 + lang/de/commands.php | 50 ++++ lang/de/shell.php | 1 + lang/en/audit.php | 3 + lang/en/commands.php | 50 ++++ lang/en/shell.php | 1 + resources/views/components/sidebar.blade.php | 3 + .../views/livewire/commands/index.blade.php | 126 +++++++++++ routes/web.php | 2 + tests/Feature/CommandRunnerTest.php | 81 +++++++ tests/Feature/CommandsComponentTest.php | 185 +++++++++++++++ 16 files changed, 813 insertions(+) create mode 100644 app/Livewire/Commands/Index.php create mode 100644 app/Models/Runbook.php create mode 100644 app/Services/CommandRunner.php create mode 100644 database/migrations/2026_07_05_100004_create_runbooks_table.php create mode 100644 lang/de/commands.php create mode 100644 lang/en/commands.php create mode 100644 resources/views/livewire/commands/index.blade.php create mode 100644 tests/Feature/CommandRunnerTest.php create mode 100644 tests/Feature/CommandsComponentTest.php diff --git a/app/Livewire/Commands/Index.php b/app/Livewire/Commands/Index.php new file mode 100644 index 0000000..a574c14 --- /dev/null +++ b/app/Livewire/Commands/Index.php @@ -0,0 +1,214 @@ + selected server uuids (scopeType=servers) */ + public array $selectedServers = []; + + // New-runbook form + public string $runbookName = ''; + + public string $runbookCommand = ''; + + /** @var array last run's results */ + public array $results = []; + + public function mount(): void + { + abort_unless(Auth::user()?->can('operate'), 403); + } + + public function title(): string + { + return __('commands.title'); + } + + private function gate(): void + { + abort_unless(Auth::user()?->can('operate'), 403); + } + + /** Resolve the current target selection to active-credential servers (uuids → models). */ + private function targetServers(): Collection + { + $q = Server::withActiveCredential(); + + if ($this->scopeType === 'group' && $this->scopeGroupUuid) { + $group = ServerGroup::where('uuid', $this->scopeGroupUuid)->first(); + + return $group ? $q->inGroup($group->id)->get() : new Collection; + } + + if ($this->scopeType === 'servers') { + return $q->whereIn('uuid', $this->selectedServers)->get(); + } + + return $q->get(); // 'all' + } + + private function issueRun(string $command): void + { + $command = trim($command); + if ($command === '') { + $this->addError('command', __('commands.command_required')); + + return; + } + + $servers = $this->targetServers(); + if ($servers->isEmpty()) { + $this->addError('command', __('commands.no_targets')); + + return; + } + + $ids = $servers->pluck('id')->all(); + $target = Str::limit($command, 60).' · '.__('commands.n_servers', ['count' => count($ids)]); + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('commands.confirm_heading'), + 'body' => __('commands.confirm_body', ['count' => count($ids), 'command' => Str::limit($command, 120)]), + 'confirmLabel' => __('commands.run'), + 'danger' => true, + 'icon' => 'command', + 'notify' => '', // the handler reports the real outcome + 'token' => ConfirmToken::issue('commandRun', ['command' => $command, 'serverIds' => $ids], 'command.run', $target, null), + ], + ); + } + + /** Ad-hoc run from the textarea. */ + public function run(): void + { + $this->gate(); + $this->issueRun($this->command); + } + + /** Run a saved runbook (its command + the current target selection). */ + public function runRunbook(string $uuid): void + { + $this->gate(); + $runbook = Runbook::where('uuid', $uuid)->firstOrFail(); + $this->issueRun($runbook->command); + } + + #[On('commandRun')] + public function execute(string $confirmToken, CommandRunner $runner): void + { + $this->gate(); + + try { + $payload = ConfirmToken::consume($confirmToken, 'commandRun'); + } catch (InvalidConfirmToken) { + return; // forged / replayed / direct-bypass — no-op (the modal already audited command.run) + } + + $servers = Server::whereIn('id', $payload['params']['serverIds'])->get(); + $this->results = $runner->run($payload['params']['command'], $servers); + + $ok = count(array_filter($this->results, fn ($r) => $r['ok'])); + $this->dispatch('notify', message: __('commands.ran', ['ok' => $ok, 'total' => count($this->results)])); + } + + public function createRunbook(): void + { + $this->gate(); + + $data = $this->validate([ + 'runbookName' => ['required', 'string', 'max:80', Rule::unique('runbooks', 'name')], + 'runbookCommand' => ['required', 'string', 'max:4000'], + ]); + + $runbook = Runbook::create(['name' => $data['runbookName'], 'command' => $data['runbookCommand']]); + $this->audit('runbook.create', $runbook->name); + $this->reset('runbookName', 'runbookCommand'); + $this->dispatch('notify', message: __('commands.runbook_created', ['name' => $runbook->name])); + } + + public function confirmDeleteRunbook(string $uuid): void + { + $this->gate(); + $runbook = Runbook::where('uuid', $uuid)->firstOrFail(); + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('commands.delete_heading'), + 'body' => __('commands.delete_body', ['name' => $runbook->name]), + 'confirmLabel' => __('common.delete'), + 'danger' => true, + 'icon' => 'trash', + 'notify' => __('commands.runbook_deleted', ['name' => $runbook->name]), + 'token' => ConfirmToken::issue('runbookDeleted', ['uuid' => $runbook->uuid], 'runbook.delete', $runbook->name, null), + ], + ); + } + + #[On('runbookDeleted')] + public function deleteRunbook(string $confirmToken): void + { + $this->gate(); + + try { + $payload = ConfirmToken::consume($confirmToken, 'runbookDeleted'); + } catch (InvalidConfirmToken) { + return; + } + + Runbook::where('uuid', $payload['params']['uuid'])->first()?->delete(); + } + + 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 render(): View + { + return view('livewire.commands.index', [ + 'runbooks' => Runbook::orderBy('name')->get(), + 'groups' => ServerGroup::orderBy('name')->get(['uuid', 'name']), + 'servers' => Server::orderBy('name')->get(['uuid', 'name']), + ])->title($this->title()); + } +} diff --git a/app/Models/Runbook.php b/app/Models/Runbook.php new file mode 100644 index 0000000..afa7e6d --- /dev/null +++ b/app/Models/Runbook.php @@ -0,0 +1,22 @@ + $r->uuid ??= (string) Str::uuid()); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } +} diff --git a/app/Services/CommandRunner.php b/app/Services/CommandRunner.php new file mode 100644 index 0000000..5392f92 --- /dev/null +++ b/app/Services/CommandRunner.php @@ -0,0 +1,46 @@ + $servers + * @return array + */ + public function run(string $command, Collection $servers, int $timeout = 60): array + { + $results = []; + + foreach ($servers as $server) { + try { + $res = $this->fleet->runPlain($server, $command, $timeout); + $results[] = ['server' => $server->name, 'ok' => (bool) $res['ok'], 'output' => $this->clean($res['output'])]; + } catch (Throwable $e) { + $results[] = ['server' => $server->name, 'ok' => false, 'output' => $this->clean($e->getMessage())]; + } + } + + return $results; + } + + private function clean(string $output): string + { + return mb_scrub(mb_strcut($output, 0, self::MAX_BYTES), 'UTF-8'); + } +} diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index 13e4889..e77dafa 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -68,6 +68,8 @@ class ConfirmToken 'releaseYank', 'groupConfirmed', 'alertRuleDeleted', + 'commandRun', + 'runbookDeleted', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ diff --git a/database/migrations/2026_07_05_100004_create_runbooks_table.php b/database/migrations/2026_07_05_100004_create_runbooks_table.php new file mode 100644 index 0000000..561e24b --- /dev/null +++ b/database/migrations/2026_07_05_100004_create_runbooks_table.php @@ -0,0 +1,24 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name')->unique(); + $table->text('command'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('runbooks'); + } +}; diff --git a/lang/de/audit.php b/lang/de/audit.php index bb92dda..250e85d 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -57,7 +57,10 @@ return [ 'fail2ban.configure' => 'fail2ban konfiguriert', 'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt', 'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt', + 'command.run' => 'Fleet-Befehl ausgeführt', 'docker.action' => 'Container-Aktion', + 'runbook.create' => 'Runbook angelegt', + 'runbook.delete' => 'Runbook gelöscht', 'file.edit' => 'Datei bearbeitet', 'firewall.rule_add' => 'Firewall-Regel hinzugefügt', 'firewall.rule_delete' => 'Firewall-Regel gelöscht', diff --git a/lang/de/commands.php b/lang/de/commands.php new file mode 100644 index 0000000..e6820e1 --- /dev/null +++ b/lang/de/commands.php @@ -0,0 +1,50 @@ + 'Flotte', + 'title' => 'Befehle', + 'subtitle' => 'Befehl oder Runbook über die Flotte ausführen', + + // Ad-hoc + 'adhoc_title' => 'Ad-hoc-Befehl', + 'command_label' => 'Befehl', + 'command_placeholder' => 'z. B. uptime, systemctl status nginx …', + 'scope_label' => 'Ziel', + 'scope_all' => 'Ganze Flotte', + 'scope_group' => 'Gruppe', + 'scope_servers' => 'Auswahl', + 'target_group_label' => 'Gruppe', + 'target_servers_label' => 'Server', + 'run' => 'Ausführen', + + // Results + 'results_title' => 'Ergebnis', + 'no_results' => 'Noch kein Lauf.', + 'exit_ok' => 'OK', + 'exit_fail' => 'Fehler', + + // Runbooks + 'runbooks_title' => 'Runbooks', + 'new_runbook' => 'Neues Runbook', + 'runbook_name_label' => 'Name', + 'runbook_command_label' => 'Befehl', + 'create' => 'Speichern', + 'no_runbooks_title' => 'Keine Runbooks', + 'no_runbooks' => 'Noch keine gespeicherten Befehle.', + + // Confirm run + 'confirm_heading' => 'Befehl ausführen', + 'confirm_body' => 'Auf :count Server(n) ausführen: „:command“?', + 'n_servers' => '{1}:count Server|[2,*]:count Server', + + // Delete runbook + 'delete_heading' => 'Runbook löschen', + 'delete_body' => 'Das Runbook „:name“ wird gelöscht.', + + // Toasts / validation + 'ran' => ':ok/:total Server erfolgreich.', + 'runbook_created' => 'Runbook „:name“ gespeichert.', + 'runbook_deleted' => 'Runbook „:name“ gelöscht.', + 'command_required' => 'Bitte einen Befehl eingeben.', + 'no_targets' => 'Keine Server im Ziel (mit aktivem Credential).', +]; diff --git a/lang/de/shell.php b/lang/de/shell.php index bd9e010..1920085 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -23,6 +23,7 @@ return [ 'nav_settings' => 'Einstellungen', 'nav_system' => 'System', 'nav_docker' => 'Docker', + 'nav_commands' => 'Befehle', 'nav_alerts' => 'Alarme', 'nav_threats' => 'Bedrohungen', 'nav_versions' => 'Version', diff --git a/lang/en/audit.php b/lang/en/audit.php index 1e53e2d..2eb6a58 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -57,7 +57,10 @@ return [ 'fail2ban.configure' => 'fail2ban configured', 'fail2ban.ignoreip_add' => 'fail2ban: exception added', 'fail2ban.ignoreip_remove' => 'fail2ban: exception removed', + 'command.run' => 'Fleet command run', 'docker.action' => 'Container action', + 'runbook.create' => 'Runbook created', + 'runbook.delete' => 'Runbook deleted', 'file.edit' => 'File edited', 'firewall.rule_add' => 'Firewall rule added', 'firewall.rule_delete' => 'Firewall rule deleted', diff --git a/lang/en/commands.php b/lang/en/commands.php new file mode 100644 index 0000000..cc0ca78 --- /dev/null +++ b/lang/en/commands.php @@ -0,0 +1,50 @@ + 'Fleet', + 'title' => 'Commands', + 'subtitle' => 'Run a command or runbook across the fleet', + + // Ad-hoc + 'adhoc_title' => 'Ad-hoc command', + 'command_label' => 'Command', + 'command_placeholder' => 'e.g. uptime, systemctl status nginx …', + 'scope_label' => 'Target', + 'scope_all' => 'Whole fleet', + 'scope_group' => 'Group', + 'scope_servers' => 'Selection', + 'target_group_label' => 'Group', + 'target_servers_label' => 'Servers', + 'run' => 'Run', + + // Results + 'results_title' => 'Result', + 'no_results' => 'No run yet.', + 'exit_ok' => 'OK', + 'exit_fail' => 'Failed', + + // Runbooks + 'runbooks_title' => 'Runbooks', + 'new_runbook' => 'New runbook', + 'runbook_name_label' => 'Name', + 'runbook_command_label' => 'Command', + 'create' => 'Save', + 'no_runbooks_title' => 'No runbooks', + 'no_runbooks' => 'No saved commands yet.', + + // Confirm run + 'confirm_heading' => 'Run command', + 'confirm_body' => 'Run on :count server(s): “:command”?', + 'n_servers' => '{1}:count server|[2,*]:count servers', + + // Delete runbook + 'delete_heading' => 'Delete runbook', + 'delete_body' => 'The runbook “:name” will be deleted.', + + // Toasts / validation + 'ran' => ':ok/:total servers succeeded.', + 'runbook_created' => 'Runbook “:name” saved.', + 'runbook_deleted' => 'Runbook “:name” deleted.', + 'command_required' => 'Please enter a command.', + 'no_targets' => 'No servers in target (with an active credential).', +]; diff --git a/lang/en/shell.php b/lang/en/shell.php index a7e4c51..999f8e6 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -23,6 +23,7 @@ return [ 'nav_settings' => 'Settings', 'nav_system' => 'System', 'nav_docker' => 'Docker', + 'nav_commands' => 'Commands', 'nav_alerts' => 'Alerts', 'nav_threats' => 'Threats', 'nav_versions' => 'Version', diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 294cbba..5bd3d82 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -47,6 +47,9 @@ {{ __('shell.nav_servers') }} {{ __('shell.nav_services') }} {{ __('shell.nav_docker') }} + @can('operate') + {{ __('shell.nav_commands') }} + @endcan {{ __('shell.nav_files') }} {{ __('shell.nav_audit') }} @can('manage-network') diff --git a/resources/views/livewire/commands/index.blade.php b/resources/views/livewire/commands/index.blade.php new file mode 100644 index 0000000..02ec0b6 --- /dev/null +++ b/resources/views/livewire/commands/index.blade.php @@ -0,0 +1,126 @@ +@php + $field = 'w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus:border-accent/40 focus:outline-none'; + $label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; +@endphp + +
+ {{-- Header --}} +
+

{{ __('commands.eyebrow') }}

+

{{ __('commands.title') }}

+

{{ __('commands.subtitle') }}

+
+ + {{-- Ad-hoc --}} + +
+
+ + + @error('command')

{{ $message }}

@enderror +
+ +
+ +
+ @foreach (['all', 'group', 'servers'] as $s) + + @endforeach +
+
+ + @if ($scopeType === 'group') +
+ + +
+ @elseif ($scopeType === 'servers') +
+ +
+ @foreach ($servers as $srv) + + @endforeach +
+
+ @endif + +
+ + {{ __('commands.run') }} + +
+
+
+ + {{-- Results --}} + @if (! empty($results)) + +
+ @foreach ($results as $r) +
+
+ + {{ $r['server'] }} + {{ $r['ok'] ? __('commands.exit_ok') : __('commands.exit_fail') }} +
+ @if ($r['output'] !== '') +
{{ $r['output'] }}
+ @endif +
+ @endforeach +
+
+ @endif + + {{-- Runbooks --}} + + @if ($runbooks->isEmpty()) +
+

{{ __('commands.no_runbooks_title') }}

+

{{ __('commands.no_runbooks') }}

+
+ @else +
+ @foreach ($runbooks as $rb) +
+
+

{{ $rb->name }}

+

{{ $rb->command }}

+
+ {{ __('commands.run') }} + {{ __('common.delete') }} +
+ @endforeach +
+ @endif + +
+

{{ __('commands.new_runbook') }}

+
+
+ + @error('runbookName')

{{ $message }}

@enderror +
+
+ + @error('runbookCommand')

{{ $message }}

@enderror +
+ {{ __('commands.create') }} +
+
+
+
diff --git a/routes/web.php b/routes/web.php index 15f14c1..f807840 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Http\Middleware\VerifyTerminalSidecarSecret; use App\Livewire\Alerts; use App\Livewire\Audit; use App\Livewire\Auth; +use App\Livewire\Commands; use App\Livewire\Dashboard; use App\Livewire\Docker; use App\Livewire\Files; @@ -224,6 +225,7 @@ Route::middleware('auth')->group(function () { Route::get('/services', Services\Index::class)->name('services.index'); Route::get('/docker', Docker\Index::class)->name('docker'); + Route::get('/commands', Commands\Index::class)->middleware('can:operate')->name('commands'); Route::get('/files', Files\Index::class)->name('files.index'); Route::get('/audit', Audit\Index::class)->name('audit.index'); diff --git a/tests/Feature/CommandRunnerTest.php b/tests/Feature/CommandRunnerTest.php new file mode 100644 index 0000000..ba7582b --- /dev/null +++ b/tests/Feature/CommandRunnerTest.php @@ -0,0 +1,81 @@ + $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']); + } + + public function test_runs_the_command_on_every_server_and_collects_results(): void + { + $a = $this->server('a', '10.0.0.1'); + $b = $this->server('b', '10.0.0.2'); + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('runPlain')->twice()->andReturn(['ok' => true, 'output' => 'done']); + + $results = (new CommandRunner($fleet))->run('uptime', new Collection([$a, $b])); + + $this->assertCount(2, $results); + $this->assertSame('a', $results[0]['server']); + $this->assertTrue($results[0]['ok']); + $this->assertSame('done', $results[0]['output']); + } + + public function test_one_server_failing_does_not_abort_the_batch(): void + { + $a = $this->server('a', '10.0.0.1'); + $b = $this->server('b', '10.0.0.2'); + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('runPlain')->once()->with($a, Mockery::any(), Mockery::any())->andThrow(new \RuntimeException('ssh timeout')); + $fleet->shouldReceive('runPlain')->once()->with($b, Mockery::any(), Mockery::any())->andReturn(['ok' => true, 'output' => 'ok']); + + $results = (new CommandRunner($fleet))->run('uptime', new Collection([$a, $b])); + + $this->assertFalse($results[0]['ok']); // a failed + $this->assertStringContainsString('ssh timeout', $results[0]['output']); + $this->assertTrue($results[1]['ok']); // b still ran + } + + public function test_a_nonzero_exit_is_reported_as_not_ok(): void + { + $a = $this->server('a', '10.0.0.1'); + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('runPlain')->once()->andReturn(['ok' => false, 'output' => 'command not found']); + + $results = (new CommandRunner($fleet))->run('nope', new Collection([$a])); + + $this->assertFalse($results[0]['ok']); + } + + public function test_output_is_byte_capped_and_utf8_scrubbed(): void + { + $a = $this->server('a', '10.0.0.1'); + $fleet = Mockery::mock(FleetService::class); + // 300 KiB of output + an invalid UTF-8 byte → must be capped and scrubbed (no broken snapshot). + $fleet->shouldReceive('runPlain')->once()->andReturn(['ok' => true, 'output' => str_repeat('x', 300000)."\xB1"]); + + $results = (new CommandRunner($fleet))->run('cat big', new Collection([$a])); + + $this->assertLessThanOrEqual(262144, strlen($results[0]['output'])); + $this->assertSame($results[0]['output'], mb_scrub($results[0]['output'], 'UTF-8')); // already valid UTF-8 + } +} diff --git a/tests/Feature/CommandsComponentTest.php b/tests/Feature/CommandsComponentTest.php new file mode 100644 index 0000000..dda9b89 --- /dev/null +++ b/tests/Feature/CommandsComponentTest.php @@ -0,0 +1,185 @@ +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; + } + + // ── route gating (operate) ────────────────────────────────────────────────── + + public function test_viewer_cannot_open_commands(): void + { + $this->actingAs($this->viewer())->get('/commands')->assertForbidden(); + } + + public function test_operator_can_open_commands(): void + { + $this->actingAs($this->operator())->get('/commands')->assertOk(); + } + + // ── ad-hoc run through the confirm token ──────────────────────────────────── + + public function test_run_opens_a_confirm_for_the_selected_targets(): void + { + $this->actingAs($this->operator()); + $this->server(); + + Livewire::test(Index::class) + ->set('command', 'uptime') + ->set('scopeType', 'all') + ->call('run') + ->assertHasNoErrors() + ->assertDispatched('openModal'); + } + + public function test_run_requires_a_command(): void + { + $this->actingAs($this->operator()); + $this->server(); + + Livewire::test(Index::class)->set('command', ' ')->call('run')->assertHasErrors('command'); + } + + public function test_run_requires_at_least_one_target(): void + { + $this->actingAs($this->operator()); // no servers created → empty target + + Livewire::test(Index::class)->set('command', 'uptime')->set('scopeType', 'all')->call('run')->assertHasErrors('command'); + } + + public function test_execute_runs_the_command_over_the_sealed_targets(): void + { + $this->actingAs($this->operator()); + $server = $this->server(); + + $runner = Mockery::mock(CommandRunner::class); + $runner->shouldReceive('run')->once() + ->with('uptime', Mockery::on(fn ($servers) => $servers->pluck('id')->all() === [$server->id])) + ->andReturn([['server' => 'box', 'ok' => true, 'output' => 'up 3 days']]); + app()->instance(CommandRunner::class, $runner); + + $token = ConfirmToken::issue('commandRun', ['command' => 'uptime', 'serverIds' => [$server->id]], 'command.run', 'uptime · 1', null); + ConfirmToken::confirm($token); + + Livewire::test(Index::class) + ->call('execute', $token) + ->assertOk() + ->assertSet('results.0.output', 'up 3 days'); + } + + public function test_a_forged_run_token_executes_nothing(): void + { + $this->actingAs($this->operator()); + $runner = Mockery::mock(CommandRunner::class); + $runner->shouldReceive('run')->never(); + app()->instance(CommandRunner::class, $runner); + + Livewire::test(Index::class)->call('execute', 'not-a-token')->assertSet('results', []); + } + + // (A viewer cannot even mount the component — the route + mount operate-gate blocks the whole + // page, proven by test_viewer_cannot_open_commands; every mutating method re-gates as defence.) + + // ── runbooks ──────────────────────────────────────────────────────────────── + + public function test_operator_creates_a_runbook_and_it_is_audited(): void + { + $this->actingAs($this->operator()); + + Livewire::test(Index::class) + ->set('runbookName', 'Restart nginx') + ->set('runbookCommand', 'systemctl restart nginx') + ->call('createRunbook') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('runbooks', ['name' => 'Restart nginx']); + $this->assertDatabaseHas('audit_events', ['action' => 'runbook.create', 'target' => 'Restart nginx']); + } + + public function test_runbook_run_opens_a_confirm_with_the_saved_command(): void + { + $this->actingAs($this->operator()); + $this->server(); + $rb = Runbook::create(['name' => 'up', 'command' => 'uptime']); + + Livewire::test(Index::class) + ->set('scopeType', 'all') + ->call('runRunbook', $rb->uuid) + ->assertDispatched('openModal'); + } + + public function test_runbook_delete_via_a_confirmed_token(): void + { + $this->actingAs($this->operator()); + $rb = Runbook::create(['name' => 'gone', 'command' => 'x']); + $token = ConfirmToken::issue('runbookDeleted', ['uuid' => $rb->uuid], 'runbook.delete', $rb->name, null); + ConfirmToken::confirm($token); + + Livewire::test(Index::class)->call('deleteRunbook', $token)->assertOk(); + + $this->assertDatabaseMissing('runbooks', ['id' => $rb->id]); + } + + public function test_group_scope_only_targets_group_members(): void + { + $this->actingAs($this->operator()); + $group = ServerGroup::create(['name' => 'web', 'color' => 'cyan']); + $member = $this->server('web1', '10.0.0.1'); + $this->server('db1', '10.0.0.2'); // not in the group + $group->servers()->attach($member->id); + + $runner = Mockery::mock(CommandRunner::class); + $runner->shouldReceive('run')->once() + ->with('hostname', Mockery::on(fn ($servers) => $servers->pluck('id')->all() === [$member->id])) + ->andReturn([]); + app()->instance(CommandRunner::class, $runner); + + // Issue a token as the component would for a group scope, then execute. + $token = ConfirmToken::issue('commandRun', ['command' => 'hostname', 'serverIds' => [$member->id]], 'command.run', 'x', null); + ConfirmToken::confirm($token); + + Livewire::test(Index::class)->call('execute', $token)->assertOk(); + } +}