'172.18.0.1', 'fingerprint' => 'SHA256:4J0zwXJCmVzzJOtphWrdG1qADFwOf0dqcQNhh0vkbn0=', 'installed_at' => '2026-08-04T10:00:00Z', ], $overrides))); } beforeEach(function () { // Beide Schlüssel liegen hier, weil genau ihre Verwechslung das ist, was // ein Test unten ausschliessen soll. app(SecretVault::class)->put(ServerIdentity::VAULT_KEY, "-----BEGIN OPENSSH PRIVATE KEY-----\nSERVER\n-----END OPENSSH PRIVATE KEY-----", Operator::factory()->create()); app(SecretVault::class)->put('ssh.private_key', "-----BEGIN OPENSSH PRIVATE KEY-----\nFLOTTE\n-----END OPENSSH PRIVATE KEY-----", Operator::factory()->create()); }); afterEach(function () { File::delete(ServerTerminalSetup::path()); }); it('lässt niemanden ohne server.terminal an eine Root-Konsole', function () { writeServerSetup(); // Admin ist die zweitmächtigste Rolle und darf Hosts verwalten — also auch // ein HOST-Terminal öffnen. Dieses hier ist eine andere Tür, und dass sie // eine andere ist, ist der ganze Punkt dieser Fähigkeit. $this->actingAs(Operator::factory()->role('Admin')->create(), 'operator') ->get(route('admin.server.terminal')) ->assertForbidden(); }); it('stellt kein Ticket aus, wenn die Anfrage von ausserhalb der Liste kommt', function () { writeServerSetup(); // Weder Management-Netz noch freigegebene Adresse. Der Schalter von // RestrictConsoleNetwork bleibt dabei AUS — für diese Tür gilt die Liste // trotzdem, und genau das hält dieser Test fest. config(['admin_access.trusted_ranges' => ['10.66.0.0/24']]); $before = Redis::connection('cache')->keys('*terminal:ticket:*'); $this->actingAs(admin(), 'operator') ->get(route('admin.server.terminal')) ->assertOk() ->assertSee(__('server.terminal.problem.off_network.title')); // Kein Fenster ist das eine; kein Ticket ist das, worauf es ankommt. expect(Redis::connection('cache')->keys('*terminal:ticket:*'))->toBe($before); }); it('trägt den Serverschlüssel ins Ticket, nicht den Flottenschlüssel', function () { writeServerSetup(); $operator = admin(); $payload = TicketStore::redeem(ServerTicket::issue($operator)); expect($payload['private_key'])->toContain('SERVER') ->and($payload['private_key'])->not->toContain('FLOTTE') // Adresse und Fingerabdruck kommen aus der Einrichtungsdatei, nicht aus // dem Code — auf der nächsten Installation sind beide anders. ->and($payload['ip'])->toBe('172.18.0.1') ->and($payload['fingerprint'])->toBe('SHA256:4J0zwXJCmVzzJOtphWrdG1qADFwOf0dqcQNhh0vkbn0=') ->and($payload['user'])->toBe('root') // Damit die Log-Zeile der Brücke lesbar bleibt, ohne sie zu ändern. ->and($payload['host_uuid'])->toBe('server') ->and($payload['operator_id'])->toBe($operator->id); }); it('sagt statt einer Fehlerseite, dass der Server noch nicht eingerichtet ist', function () { // Keine Datei — der Normalfall vor dem ersten install-agent.sh. $this->actingAs(admin(), 'operator') ->get(route('admin.server.terminal')) ->assertOk() ->assertSee(__('server.terminal.problem.not_installed.title')); }); it('nimmt eine Einrichtung ohne Fingerabdruck nicht als Einrichtung', function () { // Die gefährlichste Halbheit: mit Adresse, ohne Fingerabdruck. Die // naheliegendste Lesart davon wäre „dann eben nicht prüfen" — also eine // ungepinnte Root-Sitzung auf der Steuerungsebene. writeServerSetup(['fingerprint' => '']); expect(ServerTerminalSetup::read())->toBeNull() ->and(ServerTicket::blocker())->toBe('not_installed'); }); it('nimmt auch eine Einrichtung ohne brauchbare Adresse nicht an', function () { writeServerSetup(['address' => 'kein-hostname']); expect(ServerTerminalSetup::read())->toBeNull(); }); it('sagt auch dann warum, wenn für den Server gar keine Kennung erzeugt ist', function () { writeServerSetup(); app(SecretVault::class)->forget(ServerIdentity::VAULT_KEY); $this->actingAs(admin(), 'operator') ->get(route('admin.server.terminal')) ->assertOk() ->assertSee(__('server.terminal.problem.no_key.title')); }); it('zeigt die Karte nur dem, der sie drücken darf', function () { writeServerSetup(); $this->actingAs(Operator::factory()->role('Admin')->create(), 'operator') ->get(route('admin.settings')) ->assertOk() ->assertDontSee(__('admin_settings.server_terminal_title')); $this->actingAs(admin(), 'operator') ->get(route('admin.settings')) ->assertOk() ->assertSee(__('admin_settings.server_terminal_title')) // Und der Zeitstempel geht durch ->local(), wie jede Zeit, die ein // Mensch liest (R19). Der Wert oben ist 10:00 UTC. ->assertSee(__('admin_settings.server_terminal_installed', [ 'when' => Carbon::parse('2026-08-04T10:00:00Z')->local()->isoFormat('LLL'), ])); }); it('sagt auf der Karte, welche Zeile auf dem Server noch fehlt', function () { // Kennung erzeugt, Server noch nicht eingerichtet — der Zustand zwischen // den beiden Schritten, und der einzige, in dem die Anleitung zählt. $this->actingAs(admin(), 'operator') ->get(route('admin.settings')) ->assertOk() ->assertSee(__('admin_settings.server_terminal_not_installed')) ->assertSee('sudo bash deploy/install-agent.sh'); }); it('zeigt dem Owner den Vorspann und das Ticket, aber niemals den Schlüssel', function () { writeServerSetup(); $html = $this->actingAs(admin(), 'operator') ->get(route('admin.server.terminal')) ->assertOk() ->getContent(); expect($html)->toContain('data-terminal-stage') ->and($html)->toMatch('/data-ticket="[0-9a-f]{64}"/') // Und nichts, was die Brücke geheim halten muss. Die Adresse steht // ebenfalls nicht drin: sie ist zwar keine Zugangsdatei, aber sie // gehört zur Auskunft über den Weg und hat im Browser nichts verloren. ->and($html)->not->toContain('BEGIN OPENSSH PRIVATE KEY') ->and($html)->not->toContain('172.18.0.1'); });