169 lines
6.9 KiB
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');
|
|
});
|