get(route('admin.hosts.create'))->assertRedirect(route('admin.login')); $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.create'))->assertForbidden(); $this->actingAs(admin(), 'operator')->get(route('admin.hosts.create'))->assertOk()->assertSee(__('hosts.create_title')); }); it('gates the host detail page', function () { $host = Host::factory()->create(); $this->get(route('admin.hosts.show', $host))->assertRedirect(route('admin.login')); $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.show', $host))->assertForbidden(); $this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host))->assertOk()->assertSee($host->name); }); it('lists real hosts for admins', function () { Host::factory()->active()->create(['name' => 'pve-zzz-1']); $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->assertOk()->assertSee('pve-zzz-1'); }); it('creates a host and starts onboarding with an encrypted password', function () { Queue::fake(); // Das Anlegen erzeugt jetzt auch das WireGuard-Schlüsselpaar und nimmt den // Peer beim Hub auf, damit der Host schon im Tunnel bekannt ist, bevor er // das erste Mal bootet (Spec §5). Ohne den gefälschten Hub griffe das nach // `wg` auf einer Maschine, die keine wg0 hat. fakeServices(); Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); Livewire::actingAs(admin(), 'operator') ->test(HostCreate::class) ->set('name', 'pve-fsn-7') ->set('datacenter', 'fsn') ->set('public_ip', '203.0.113.7') ->set('root_password', 'supersecret') ->call('save') // KEINE Weiterleitung mehr, und das ist der Punkt: die Befehlszeile für // das Rettungssystem gibt es nur hier und nur jetzt. Wer den Betreiber // weiterschickt, schickt ihn von dem einzigen Wert weg, den er braucht. ->assertNoRedirect() ->assertSee('clupilot-bootstrap.sh'); $host = Host::query()->where('name', 'pve-fsn-7')->first(); expect($host)->not->toBeNull()->and($host->status)->toBe('pending'); $run = ProvisioningRun::query()->where('subject_id', $host->id)->first(); expect($run)->not->toBeNull() ->and($run->pipeline)->toBe('host') ->and(Crypt::decryptString($run->context('root_password')))->toBe('supersecret'); Queue::assertPushed(AdvanceRunJob::class); }); it('rejects a duplicate public ip', function () { Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); Host::factory()->create(['public_ip' => '203.0.113.99']); Livewire::actingAs(admin(), 'operator') ->test(HostCreate::class) ->set('name', 'pve-dup') ->set('datacenter', 'fsn') ->set('public_ip', '203.0.113.99') ->set('root_password', 'supersecret') ->call('save') ->assertHasErrors(['public_ip']); }); it('validates the add-host form', function () { Livewire::actingAs(admin(), 'operator') ->test(HostCreate::class) ->set('name', '') ->set('public_ip', 'not-an-ip') ->set('root_password', 'short') ->call('save') ->assertHasErrors(['name', 'public_ip', 'root_password']); }); it('retries a failed run from the detail page', function () { Queue::fake(); $host = Host::factory()->create(['status' => 'error']); $run = ProvisioningRun::factory()->forHost($host)->create([ 'status' => 'failed', 'current_step' => 3, 'error' => 'boom', 'started_at' => now()->subHour(), ]); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('retry'); $run->refresh(); expect($run->status)->toBe('running') ->and($run->error)->toBeNull() ->and($run->started_at->greaterThan(now()->subMinute()))->toBeTrue() ->and($host->fresh()->status)->toBe('onboarding'); Queue::assertPushed(AdvanceRunJob::class); }); it('deactivates the host and queues an async purge', function () { Queue::fake(); $host = Host::factory()->active()->create(['wg_pubkey' => 'HOSTPUB=']); ProvisioningRun::factory()->forHost($host)->create(); Livewire::actingAs(admin(), 'operator') ->test(ConfirmRemoveHost::class, ['uuid' => $host->uuid]) ->call('remove') ->assertRedirect(route('admin.hosts')); expect($host->fresh()->status)->toBe('disabled'); Queue::assertPushed(PurgeHost::class, fn ($job) => $job->uuid === $host->uuid); }); it('purges a host: deletes its runs and record and queues peer removal', function () { Queue::fake([RemoveWireguardPeer::class]); $host = Host::factory()->active()->create(['wg_pubkey' => 'HOSTPUB=']); ProvisioningRun::factory()->forHost($host)->create(); (new PurgeHost($host->uuid))->handle(); expect(Host::query()->find($host->id))->toBeNull(); Queue::assertPushed(RemoveWireguardPeer::class, fn ($job) => $job->publicKey === 'HOSTPUB='); }); it('renders the live stepper for a running host', function () { $host = Host::factory()->create(['status' => 'onboarding']); ProvisioningRun::factory()->forHost($host)->create(['status' => 'running', 'current_step' => 2]); $this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host)) ->assertOk() ->assertSee(__('hosts.step.prepare_base_system')) ->assertSee(__('hosts.progress')); }); it('filters the host list by search and datacenter', function () { Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); Datacenter::query()->firstOrCreate(['code' => 'hel'], ['name' => 'Helsinki']); Host::factory()->create(['name' => 'pve-fsn-9', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.9']); Host::factory()->create(['name' => 'pve-hel-9', 'datacenter' => 'hel', 'public_ip' => '203.0.113.19']); Livewire::actingAs(admin(), 'operator')->test(Hosts::class) ->set('search', 'fsn-9') ->assertSee('pve-fsn-9') ->assertDontSee('pve-hel-9') ->set('search', '') ->set('datacenter', 'hel') ->assertSee('pve-hel-9') ->assertDontSee('pve-fsn-9'); }); it('edits host reserve and toggles maintenance', function () { $host = Host::factory()->active()->create(['reserve_pct' => 15]); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) ->call('saveReserve', 25) ->call('toggleMaintenance'); $host->refresh(); expect($host->reserve_pct)->toBe(25)->and($host->status)->toBe('disabled'); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('toggleMaintenance'); expect($host->fresh()->status)->toBe('active'); }); it('clamps host reserve to a sane range', function () { $host = Host::factory()->active()->create(['reserve_pct' => 15]); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('saveReserve', 500); expect($host->fresh()->reserve_pct)->toBe(90); }); it('reports host heartbeat health from last_seen_at', function () { $online = Host::factory()->create(['last_seen_at' => now()->subMinutes(2)]); $stale = Host::factory()->create(['last_seen_at' => now()->subMinutes(20)]); $offline = Host::factory()->create(['last_seen_at' => now()->subHours(3)]); $never = Host::factory()->create(['last_seen_at' => null]); expect($online->healthState())->toBe('online') ->and($stale->healthState())->toBe('stale') ->and($offline->healthState())->toBe('offline') ->and($never->healthState())->toBe('offline'); }); /* |-------------------------------------------------------------------------- | Die Befehlszeile noch einmal |-------------------------------------------------------------------------- | | Es gab sie genau einmal, auf der Seite direkt nach dem Anlegen. Wer sie dort | nicht kopierte — oder wem sie wegbrach, wie beim WireGuard-Fehler bis | 1.3.80 — hatte danach einen Host in der Liste, für den es keinen Weg zu | einer Befehlszeile mehr gab: ein zweites Anlegen scheitert an der eindeutigen | IP, also blieb nur Entfernen und von vorn. */ it('hands out a fresh command line for a host that already exists', function () { Queue::fake(); $host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]); $component = Livewire::actingAs(admin(), 'operator') ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) ->assertSet('command', null) ->call('issue'); $command = $component->get('command'); // Die ganze Zeile, nicht nur der Code — das ist der Punkt: sie trägt auch // den privaten WireGuard-Schlüssel, die Tunneladresse und den FQDN. expect($command)->toContain('clupilot-bootstrap.sh') ->and($command)->toContain('--code ') ->and($command)->toContain('--wg-private ') ->and($command)->toContain('--fqdn '); }); it('makes the previously issued code worthless', function () { // Sonst hätte eine Maschine, die im Rettungssystem hängengeblieben ist, // weiter einen gültigen Ausweis für einen Host, den der Betreiber gerade // neu übernimmt. Queue::fake(); $host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]); $alt = HostEnrolment::issue($host); Livewire::actingAs(admin(), 'operator') ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) ->call('issue'); expect(HostEnrolment::resolve($alt))->toBeNull(); }); it('does not hand the takeover line to an operator who may not manage hosts', function () { // In der Antwort steht ein privater WireGuard-Schlüssel. Ein Modal ist ohne // die Route-Middleware der Seite erreichbar (R20), also prüft es selbst. $host = Host::factory()->create(['status' => 'onboarding']); Livewire::actingAs(operator('Read-only'), 'operator') ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) ->call('issue') ->assertForbidden(); }); it('refuses to rotate the credentials of a host that is already running', function () { // Der Knopf steht dort gar nicht — aber ein Modal, das offen blieb, während // der Host aktiv wurde, ruft die Methode trotzdem, und eine // Livewire-Methode ist ohnehin von außen aufrufbar. Ein neuer Schlüssel // schnitte die laufende Maschine aus dem Tunnel. Queue::fake(); $host = Host::factory()->active()->create(['wg_ip' => null, 'wg_pubkey' => null]); $alt = HostEnrolment::issue($host); Livewire::actingAs(admin(), 'operator') ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) ->call('issue') ->assertSet('command', null) ->assertSet('refusal', __('hosts.reissue.not_eligible')); // Und der bisherige Code gilt weiter: eine abgelehnte Ausstellung darf // nichts entwerten. expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id); }); it('issues nothing while the tunnel settings are incomplete', function () { // Sonst wird ein gültiger Code entwertet und dafür eine Zeile ausgegeben, // der die Tunnel-Angaben fehlen — sie läuft und endet in einem Tunnel ohne // Handshake, und das fällt erst auf der Maschine auf. Queue::fake(); config()->set('provisioning.wireguard.hub_public_key', ''); $host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]); $alt = HostEnrolment::issue($host); Livewire::actingAs(admin(), 'operator') ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) ->call('issue') ->assertSet('command', null) ->assertSet('refusal', __('hosts.takeover.missing_title')); expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id); }); it('treats a drained host as running, not as one waiting for takeover', function () { // `disabled` sieht aus wie „noch nicht fertig" und ist das Gegenteil: // toggleMaintenance() schaltet einen LAUFENDEN Host so still, um ihn aus // der Platzierung zu nehmen. Ein Knopf, der dort eine Neuinstallation // anbietet, trifft eine Produktionsmaschine. Queue::fake(); $host = Host::factory()->active()->create(['status' => 'disabled', 'wg_ip' => null, 'wg_pubkey' => null]); $alt = HostEnrolment::issue($host); Livewire::actingAs(admin(), 'operator') ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) ->call('issue') ->assertSet('command', null) ->assertSet('refusal', __('hosts.reissue.not_eligible')); expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id) ->and(ReissueTakeover::eligible($host))->toBeFalse(); }); // --- Detailseite: lebendig und fest getrennt --------------------------------- it('shows the version number rather than the build hash it is buried in', function () { // In der schmalen Karte wurde `pve-manager/9.2.6/7f8d…` mitten in der // Bau-Kennung abgeschnitten — sichtbar war alles außer der Zahl, um die es // geht. fakeServices(); $host = Host::factory()->active()->create(['pve_version' => 'pve-manager/9.2.6/7f8d010005bd7231']); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) ->assertSee('Proxmox VE 9.2.6'); }); it('gives every measurement its own tile, so none of them needs a legend', function () { // Eine Reihe je Kachel: die Beschriftung benennt sie, und damit entfällt // die Legende, die vorher unter der gemeinsamen Kurve stand. $s = fakeServices(); // Bewusst unverwechselbare Zahlen: „50" träfe auch die 500 GB in der // Instanzenliste darunter, und ein Test, der aus Versehen recht behält, // prüft nichts. Siehe die lose Zahlen-Falle, die das Repo schon kennt. $s['pve']->rrd = [ ['time' => 1754035200, 'cpu' => 0.11, 'memused' => 11, 'memtotal' => 100, 'netin' => 1_048_576, 'netout' => 1_048_576], ['time' => 1754035260, 'cpu' => 0.37, 'memused' => 63, 'memtotal' => 100, 'netin' => 3_145_728, 'netout' => 5_242_880], ]; $host = Host::factory()->active()->create(['node' => 'pve-fns-1', 'api_token_ref' => 'ref']); // Der Sammler läuft im provisioning-Container, die Seite liest nur. Genau // diese Reihenfolge, sonst prüft der Test einen Weg, den es nicht gibt. app(HostLoadSeries::class)->collect($host); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) ->assertSee(__('hosts.detail.cpu_load')) ->assertSee(__('hosts.detail.ram_load')) ->assertSee(__('hosts.detail.net_in')) ->assertSee(__('hosts.detail.net_out')) // Der jüngste Messwert je Kachel, jeder eine Zahl, die sonst nirgends // auf dieser Seite vorkommt. ->assertSee('37') // CPU ->assertSee('63') // RAM ->assertSee('3,00') // eingehend, MiB/s ->assertSee('5,00') // ausgehend ->assertDontSee(__('hosts.detail.no_load_short')); }); it('shows nothing until the collector has run, because the page cannot fetch', function () { // Der Fehler auf echter Hardware: der `app`-Container hängt nicht im // WireGuard-Tunnel. Eine Seite, die selbst holt, zeigt dort für immer // Leerstellen — und behauptet damit etwas über den Host, das nur über den // Weg dorthin stimmt. $s = fakeServices(); $s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.25, 'memused' => 1, 'memtotal' => 2]]; $host = Host::factory()->active()->create(['api_token_ref' => 'ref']); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) ->assertSee(__('hosts.detail.no_load_short')); }); it('shows the public ip on the page, not only in the small print', function () { // Die Adresse, unter der der Host wirklich erreichbar ist, stand nirgends // in der Seite — nur klein unter der Überschrift. fakeServices(); $host = Host::factory()->active()->create(['public_ip' => '49.12.121.79']); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) ->assertSee(__('hosts.detail.public_ip')) ->assertSee('49.12.121.79'); }); it('keeps the reserve control with the settings, not on a reading tile', function () { fakeServices(); $host = Host::factory()->active()->create(['reserve_pct' => 15]); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) ->assertSee(__('hosts.detail.spec')) ->assertSee(__('hosts.detail.reserve_label')); }); it('says a silent host has no measurements instead of drawing a quiet hour', function () { // Der ganze Grund, warum HostLoadSeries eine leere Reihe liefert statt // Nullen: eine Null-Linie behauptet, der Host habe nichts zu tun gehabt. fakeServices(); // rrd bleibt leer $host = Host::factory()->active()->create(); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) // Jede Messkachel sagt es für sich, und keine zeigt eine 0. ->assertSee(__('hosts.detail.no_load_short')) ->assertSee(__('hosts.detail.cpu_load')); }); it('folds the finished onboarding away but keeps it reachable', function () { fakeServices(); $host = Host::factory()->active()->create(); ProvisioningRun::factory()->forHost($host)->create(['status' => ProvisioningRun::STATUS_COMPLETED]); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) // Fünfzehn abgehakte Schritte sind auf einem laufenden Host kein // Dauerinhalt — aufklappbar, nicht weg. ->assertSee('assertSee(__('hosts.detail.onboarding_done')); }); it('keeps the stepper open while the onboarding is still running', function () { fakeServices(); $host = Host::factory()->create(['status' => 'onboarding']); ProvisioningRun::factory()->forHost($host)->create(['status' => ProvisioningRun::STATUS_RUNNING]); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) ->assertDontSee('assertSee(__('hosts.progress')); });