CluPilotCloud/tests/Feature/Admin/ServerTerminalTest.php

169 lines
6.9 KiB
PHP

<?php
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Services\Terminal\ServerIdentity;
use App\Services\Terminal\ServerTerminalSetup;
use App\Services\Terminal\ServerTicket;
use App\Services\Terminal\TicketStore;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Redis;
/**
* Die Einrichtung, wie `install-server-terminal` sie auf dem Wirt hinterlässt.
*
* Echte Datei statt einer vorgetäuschten: der Weg vom Skript zur Seite führt
* durch JSON und durch zwei Prüfungen, und beide sind genau die Stellen, an
* denen sich später etwas verschiebt.
*/
function writeServerSetup(array $overrides = []): void
{
File::ensureDirectoryExists(dirname(ServerTerminalSetup::path()));
File::put(ServerTerminalSetup::path(), json_encode(array_merge([
'address' => '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');
});