CluPilotCloud/tests/Feature/Admin/HostTerminalTest.php

89 lines
3.6 KiB
PHP

<?php
use App\Models\Host;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Services\Terminal\TerminalTicket;
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('trägt genau eine Sitzung', function () {
$ticket = TerminalTicket::issue(
Host::factory()->active()->create(),
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('trägt nach dreißig Sekunden nicht mehr', function () {
$ticket = TerminalTicket::issue(
Host::factory()->active()->create(),
Operator::factory()->role('Owner')->create(),
);
$this->travel(TerminalTicket::TTL_SECONDS + 1)->seconds();
expect(TerminalTicket::redeem($ticket))->toBeNull();
});
it('öffnet mit dem Ticket für einen Host keine Sitzung auf einem anderen', function () {
$a = Host::factory()->active()->create();
$b = Host::factory()->active()->create();
$operator = Operator::factory()->role('Owner')->create();
$payload = TerminalTicket::redeem(TerminalTicket::issue($a, $operator));
expect($payload['ip'])->toBe($a->wg_ip)
->and($payload['ip'])->not->toBe($b->wg_ip);
});
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', '');
expect(fn () => TerminalTicket::issue(
Host::factory()->active()->create(),
Operator::factory()->role('Owner')->create(),
))->toThrow(RuntimeException::class);
});