138 lines
7.0 KiB
PHP
138 lines
7.0 KiB
PHP
<?php
|
|
|
|
use App\Models\Host;
|
|
use App\Models\Operator;
|
|
use App\Services\Secrets\SecretVault;
|
|
use App\Services\Terminal\TerminalTicket;
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
beforeEach(function () {
|
|
// Der Schlüssel, den das Ticket mitgeben soll. Ohne ihn stünde im Ticket
|
|
// ein leerer Wert, und der Test bewiese nichts.
|
|
app(SecretVault::class)->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.");
|
|
});
|