put('ssh.private_key', "-----BEGIN OPENSSH PRIVATE KEY-----\nTEST\n-----END OPENSSH PRIVATE KEY-----", Operator::factory()->create()); }); it('trägt alles, was die Brücke braucht — und nichts davon im Klartext an den Browser', function () { $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); $operator = Operator::factory()->role('Owner')->create(); $ticket = TerminalTicket::issue($host, $operator); // Undurchsichtig: keine Adresse, kein Name, nichts Ratbares. expect($ticket)->toMatch('/^[a-f0-9]{64}$/') ->and($ticket)->not->toContain($host->wg_ip) ->and($ticket)->not->toContain($host->name); $payload = TerminalTicket::redeem($ticket); expect($payload['host_uuid'])->toBe($host->uuid) ->and($payload['operator_id'])->toBe($operator->id) // Die TUNNELADRESSE, nie die öffentliche: der Container steht im // Tunnel, und die öffentliche IP wäre der Weg, den SecureHostFirewall // ausdrücklich zumacht. ->and($payload['ip'])->toBe($host->wg_ip) ->and($payload['user'])->toBe('root') ->and($payload['fingerprint'])->toBe('SHA256:abc') ->and($payload['private_key'])->toContain('BEGIN OPENSSH PRIVATE KEY'); }); it('legt das Ticket in Redis als reines JSON ab, nicht als PHP-serialisierten Wert', function () { // Fix-Runde 1: Cache::put() lief über Illuminate\Cache\RedisStore, und // die verpackt jeden Wert mit PHP serialize(), solange kein 'serializer' // konfiguriert ist (ist er hier nicht) — aus dem JSON wäre in Redis ein // `s:412:"{...}";` geworden. Der Python-Container aus Aufgabe 3 kann // json_decode(), aber kein PHP serialize() — das muss also tatsächlich // ankommen, nicht nur im Kopfkommentar behauptet werden. $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); $ticket = TerminalTicket::issue($host, Operator::factory()->role('Owner')->create()); // Derselbe volle Schlüssel, den der Kopfkommentar von TerminalTicket für // Aufgabe 3 verspricht — absichtlich hier als Literal wiederholt statt // aus einer (privaten) Klassenkonstante gelesen, damit dieser Test genau // das nachvollzieht, was ein fremder Client tun müsste. $raw = Redis::connection('cache')->get('terminal:ticket:'.$ticket); expect($raw)->not->toStartWith('s:') ->and(json_decode($raw, true, flags: JSON_THROW_ON_ERROR)) ->toMatchArray(['host_uuid' => $host->uuid, 'user' => 'root']); // Aufräumen statt auf die TTL zu warten — sonst bleibt der Testlauf // einen Redis-Schlüssel schuldig, den redeem() nie zu sehen bekam. Redis::connection('cache')->del('terminal:ticket:'.$ticket); }); it('setzt in Redis eine Ablaufzeit von höchstens dreißig Sekunden', function () { // Redis führt seine Ablaufzeit über die reale Uhr des Servers, nicht // über die von travel() verschobene PHP-Zeit — ein 31-Sekunden-Sleep wäre // die einzige ehrliche Art, den tatsächlichen Ablauf hier zu erzwingen, // und das ist für einen Testlauf nicht verhältnismäßig (R22). Was von // hier aus ehrlich geprüft werden kann: dass issue() den SETEX mit der // richtigen Sekundenzahl aufruft. Dass eine abgelaufene Ablaufzeit Redis // tatsächlich leert, ist Redis' eigene, unabhängig getestete Aufgabe. $ticket = TerminalTicket::issue( Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']), Operator::factory()->role('Owner')->create(), ); $ttl = Redis::connection('cache')->ttl('terminal:ticket:'.$ticket); expect($ttl)->toBeGreaterThan(0)->toBeLessThanOrEqual(TerminalTicket::TTL_SECONDS); TerminalTicket::redeem($ticket); }); it('trägt genau eine Sitzung', function () { $ticket = TerminalTicket::issue( Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']), Operator::factory()->role('Owner')->create(), ); expect(TerminalTicket::redeem($ticket))->not->toBeNull() // Die zweite Einlösung läuft ins Leere. Das Löschen beim Lesen IST die // Regel — ein Ticket, das zweimal trägt, ist ein Nachschlüssel. ->and(TerminalTicket::redeem($ticket))->toBeNull(); }); it('gibt kein Ticket ohne hinterlegten Schlüssel aus', function () { // Ein Ticket ohne Schlüssel führt zu einem Fenster, das aufgeht und nie // verbindet — der Fehler gehört hierher, nicht in den Container. app(SecretVault::class)->forget('ssh.private_key'); // Diese Installation hat in .env tatsächlich einen SSH-Schlüssel // konfiguriert (CLUPILOT_SSH_PRIVATE_KEY_PATH), auf den SecretVault::get() // zurückfällt, solange kein Tresor-Eintrag existiert (siehe // OnboardingChecksTest, derselbe Rückfall) — direkt zurückgesetzt, damit // der Test nicht vom Entwicklerrechner abhängt. config()->set('provisioning.ssh.private_key', ''); // Mit Meldungstext: SecretVault::get() wirft dieselbe Klasse auch bei // einem Entschlüsselungsfehler oder einem unbekannten Schlüssel — ohne // die Meldung könnte dieser Test aus einem ganz anderen Grund bestehen. expect(fn () => TerminalTicket::issue( Host::factory()->active()->create(), Operator::factory()->role('Owner')->create(), ))->toThrow(RuntimeException::class, 'Kein SSH-Schlüssel hinterlegt — ohne ihn kann keine Terminalsitzung entstehen.'); }); it('gibt kein Ticket ohne Tunneladresse aus', function () { // Kein active(): die Fabrik setzt wg_ip nur in diesem Zustand. $host = Host::factory()->create(['ssh_host_key' => 'SHA256:abc']); expect(fn () => TerminalTicket::issue($host, Operator::factory()->role('Owner')->create())) ->toThrow(RuntimeException::class, "Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket."); }); it('gibt kein Ticket ohne geprüften Fingerabdruck aus', function () { // Fix-Runde 1: active() setzt wg_ip, aber nie ssh_host_key — genau die // Lücke, durch die vorher drei der Tests oben Tickets ohne Fingerabdruck // ausstellten, ohne dass es auffiel. Ein fehlender Fingerabdruck ist der // gefährlichere der beiden Fälle: er übergäbe die Prüfung an Code, der // noch nicht existiert (Aufgabe 3), und dessen naheliegendste Fassung // ("kein Fingerabdruck im Ticket → nicht prüfen") eine ungepinnte // Root-SSH-Sitzung im Tunnel wäre. $host = Host::factory()->active()->create(); expect(fn () => TerminalTicket::issue($host, Operator::factory()->role('Owner')->create())) ->toThrow(RuntimeException::class, "Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket."); });