Release v1.3.82 — Übernahme-Befehlszeile neu ausstellen
Die Zeile gab es 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. HostTakeoverCommand behauptet in seinem Kopfkommentar, die Zeile werde "an zwei Stellen gezeigt (beim Anlegen und beim Neuausstellen eines Codes)". Das Zweite gab es nie — ein Kommentar, der eine Absicht beschreibt und wie eine Beschreibung des Gebauten klingt. Neu: ReissueTakeover als Modal auf der Host-Detailseite. Bestätigen zuerst (R23), denn ein bisher ausgegebener Code wird dabei wertlos; danach steht die ganze Zeile mit Kopieren-Knopf darin, nicht nur der Code. Codex, zwei Runden, beide Male dieselbe Sorte Fehler von mir — die Ansicht versteckt den Knopf, die Methode prüft nichts: - P1: `issue()` erzwingt jetzt selbst, welcher Zustand zulässig ist. Ein Modal, das offen blieb, während der Host aktiv wurde, rief die Methode trotzdem; eine Livewire-Methode ist ohnehin von aussen aufrufbar. - P2: `issue()` prüft die Tunnel-Einstellungen erneut. Sonst wird ein gültiger Code entwertet und dafür eine Zeile ausgegeben, der die WireGuard-Angaben fehlen — genau die kaputte Zeile, vor der die Warnung daneben steht. - P1 der zweiten Runde: `disabled` gehörte nicht in die Liste der zulässigen Zustände. Es sieht aus wie "noch nicht fertig" und ist das Gegenteil — toggleMaintenance() schaltet einen LAUFENDEN Host so still. Der Knopf hätte eine Produktionsmaschine zur Neuinstallation angeboten. Die Bedingung steht deshalb einmal am Bauteil (ReissueTakeover::eligible) und wird von Ansicht und Methode gefragt: zwei Fassungen liefen auseinander, sobald jemand eine ändert. 2231 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main v1.3.82
parent
8ab50f5650
commit
ac81bc5b98
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Support\HostEnrolment;
|
||||
use App\Support\HostTakeoverCommand;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Stellt die Übernahme-Befehlszeile für einen bestehenden Host neu aus.
|
||||
*
|
||||
* Es gab sie bis hierher 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.
|
||||
*
|
||||
* `HostTakeoverCommand` behauptet in seinem Kopfkommentar, die Zeile werde „an
|
||||
* zwei Stellen gezeigt (beim Anlegen und beim Neuausstellen eines Codes)". Das
|
||||
* Zweite gab es nie — ein Kommentar, der eine Absicht beschreibt und wie eine
|
||||
* Beschreibung des Gebauten klingt.
|
||||
*
|
||||
* Bestätigt wird VOR dem Ausstellen (R23), denn der bisherige Code wird dabei
|
||||
* wertlos: eine Maschine, die gerade im Rettungssystem arbeitet, verlöre ihren
|
||||
* Ausweis mitten im Lauf.
|
||||
*/
|
||||
class ReissueTakeover extends ModalComponent
|
||||
{
|
||||
public string $uuid;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
/** Null, solange nur gefragt wird. Gesetzt heißt: ausgestellt, hier steht sie. */
|
||||
public ?string $command = null;
|
||||
|
||||
/** Gesetzt, wenn nicht ausgestellt wurde — und warum. */
|
||||
public ?string $refusal = null;
|
||||
|
||||
/**
|
||||
* Nur vor der Übernahme: angelegt, mitten in der Übernahme, gescheitert.
|
||||
*
|
||||
* Für einen laufenden Host wäre eine neue Befehlszeile kein Angebot,
|
||||
* sondern eine Falle: sie setzt die Maschine neu auf.
|
||||
*
|
||||
* `disabled` gehört NICHT dazu, obwohl es danach aussieht (Codex P1).
|
||||
* Es ist der Wartungszustand eines längst laufenden Hosts —
|
||||
* `HostDetail::toggleMaintenance()` schaltet aktiv → disabled, um ihn aus
|
||||
* der Platzierung zu nehmen. Wer einen Host zum Warten stilllegt, bekäme
|
||||
* sonst einen Knopf angeboten, der seine Produktionsmaschine neu aufsetzt.
|
||||
*/
|
||||
public const ELIGIBLE = ['pending', 'onboarding', 'error'];
|
||||
|
||||
/**
|
||||
* Eine Stelle für die Bedingung, gefragt von der Ansicht UND von `issue()`.
|
||||
*
|
||||
* Zwei Fassungen davon liefen auseinander, sobald jemand eine ändert — und
|
||||
* die, die dann noch gilt, ist die in der Ansicht, also gar keine.
|
||||
*/
|
||||
public static function eligible(Host $host): bool
|
||||
{
|
||||
return in_array($host->status, self::ELIGIBLE, true);
|
||||
}
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$this->uuid = $uuid;
|
||||
$this->name = Host::query()->where('uuid', $uuid)->value('name') ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Modal ist ohne die Route-Middleware der Seite erreichbar (R20) und
|
||||
* prüft die Berechtigung deshalb selbst — hier besonders, weil in der
|
||||
* Antwort ein privater WireGuard-Schlüssel steht.
|
||||
*/
|
||||
public function issue(): void
|
||||
{
|
||||
$this->authorize('hosts.manage');
|
||||
|
||||
$host = Host::query()->where('uuid', $this->uuid)->firstOrFail();
|
||||
|
||||
// Der Knopf steht nur bei passendem Zustand — aber ein Modal, das offen
|
||||
// blieb, während der Host aktiv wurde, ruft diese Methode trotzdem, und
|
||||
// eine Livewire-Methode ist ohnehin von außen aufrufbar. Die
|
||||
// Sichtbarkeit eines Knopfes ist keine Prüfung (Codex P1): sonst
|
||||
// tauschte das hier den Schlüssel einer laufenden Maschine und schnitte
|
||||
// sie aus dem Tunnel.
|
||||
if (! self::eligible($host)) {
|
||||
$this->refusal = __('hosts.reissue.not_eligible');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Dieselbe Prüfung wie in der Ansicht, aus demselben Grund (Codex P2).
|
||||
// Sonst wird ein gültiger Code entwertet und dafür eine Zeile
|
||||
// ausgegeben, der die Tunnel-Angaben fehlen — genau die kaputte Zeile,
|
||||
// vor der die Warnung daneben steht.
|
||||
if (HostTakeoverCommand::missingSettings() !== []) {
|
||||
$this->refusal = __('hosts.takeover.missing_title');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refusal = null;
|
||||
$this->command = HostTakeoverCommand::for($host, HostEnrolment::issueWithKeys($host));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.reissue-takeover', [
|
||||
'missingSettings' => HostTakeoverCommand::missingSettings(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +134,16 @@ return [
|
|||
'secure_host_firewall' => 'Host-Firewall absichern',
|
||||
'complete_host_onboarding' => 'Onboarding abschließen',
|
||||
],
|
||||
'reissue' => [
|
||||
'action' => 'Befehlszeile neu ausstellen',
|
||||
'title' => 'Neue Befehlszeile ausstellen?',
|
||||
'body' => 'Für :name entsteht ein neuer Einmal-Code und ein neues Schlüsselpaar. Die Zeile wird danach einmal angezeigt.',
|
||||
'invalidates' => 'Ein bisher ausgegebener Code wird damit wertlos. Läuft auf der Maschine gerade eine Übernahme, verliert sie mitten im Lauf ihren Ausweis — dann wird sie neu aufgesetzt, nicht nachgebessert.',
|
||||
'confirm' => 'Neu ausstellen',
|
||||
'done' => 'Fertig',
|
||||
'not_eligible' => 'Dieser Host läuft bereits. Eine neue Befehlszeile würde die Maschine neu aufsetzen — dafür wird der Host entfernt und neu angelegt.',
|
||||
],
|
||||
|
||||
'takeover' => [
|
||||
'eyebrow' => 'Host-Übernahme',
|
||||
'how_title' => 'So läuft eine Host-Übernahme ab',
|
||||
|
|
|
|||
|
|
@ -134,6 +134,16 @@ return [
|
|||
'secure_host_firewall' => 'Secure host firewall',
|
||||
'complete_host_onboarding' => 'Complete onboarding',
|
||||
],
|
||||
'reissue' => [
|
||||
'action' => 'Re-issue the command line',
|
||||
'title' => 'Issue a new command line?',
|
||||
'body' => 'A new one-time code and a new key pair are created for :name. The line is then shown once.',
|
||||
'invalidates' => 'Any code handed out so far becomes worthless. If a takeover is running on the machine right now, it loses its credentials mid-run — and then it is reinstalled rather than repaired.',
|
||||
'confirm' => 'Re-issue',
|
||||
'done' => 'Done',
|
||||
'not_eligible' => 'This host is already running. A new command line would reinstall the machine — for that, remove the host and create it again.',
|
||||
],
|
||||
|
||||
'takeover' => [
|
||||
'eyebrow' => 'Host takeover',
|
||||
'how_title' => 'How a host takeover works',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@
|
|||
{{ $host->status === 'active' ? __('hosts.detail.drain') : __('hosts.detail.activate') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
{{-- Die Bedingung steht am Bauteil, nicht hier: `issue()` prüft
|
||||
dieselbe, und zwei Fassungen liefen auseinander. --}}
|
||||
@if (\App\Livewire\Admin\ReissueTakeover::eligible($host))
|
||||
<x-ui.button variant="secondary" size="sm"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.reissue-takeover', arguments: { uuid: '{{ $host->uuid }}' } })">
|
||||
<x-ui.icon name="refresh" class="size-4" />{{ __('hosts.reissue.action') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
<x-ui.button variant="danger" size="sm"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-remove-host', arguments: { uuid: '{{ $host->uuid }}' } })">
|
||||
<x-ui.icon name="trash-2" class="size-4" />{{ __('hosts.remove') }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
{{-- R24: Kopf und Fuß stehen fest, gescrollt wird nur die Mitte. Die
|
||||
Befehlszeile ist lang, und sie darf ihren eigenen Kopieren-Knopf nicht aus
|
||||
dem Fenster schieben. --}}
|
||||
<x-ui.modal>
|
||||
<x-slot:header>
|
||||
<h2 class="text-lg font-semibold text-ink">
|
||||
{{ $command === null ? __('hosts.reissue.title') : __('hosts.takeover.command_title') }}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-[60ch] text-sm text-muted">
|
||||
{{ $command === null ? __('hosts.reissue.body', ['name' => $name]) : __('hosts.takeover.command_sub') }}
|
||||
</p>
|
||||
</x-slot:header>
|
||||
|
||||
@if ($command === null)
|
||||
@if ($missingSettings !== [])
|
||||
{{-- Erst gar nicht ausstellen: eine Zeile ohne Hub-Schlüssel oder
|
||||
Endpunkt sieht vollständig aus und scheitert erst auf der
|
||||
Maschine, nach dem Neuaufsetzen der Platten. --}}
|
||||
<x-ui.alert variant="warning">
|
||||
<p class="font-medium">{{ __('hosts.takeover.missing_title') }}</p>
|
||||
<p class="mt-1">{{ __('hosts.takeover.missing_body', ['settings' => implode(', ', $missingSettings)]) }}</p>
|
||||
</x-ui.alert>
|
||||
@elseif ($refusal !== null)
|
||||
<x-ui.alert variant="warning">
|
||||
<p>{{ $refusal }}</p>
|
||||
</x-ui.alert>
|
||||
@else
|
||||
<p class="text-sm leading-relaxed text-body">{{ __('hosts.reissue.invalidates') }}</p>
|
||||
@endif
|
||||
@else
|
||||
<div x-data="{ copied: false }" class="overflow-hidden rounded-lg border border-accent-border bg-surface shadow-xs">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line bg-accent-subtle px-4 py-2.5">
|
||||
<p class="min-w-0 text-xs font-medium text-accent-text">{{ __('hosts.takeover.once_title') }}</p>
|
||||
<button type="button"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded border border-line bg-surface px-2.5 py-1 text-xs font-medium text-body transition hover:text-ink"
|
||||
x-on:click="navigator.clipboard.writeText($refs.command.textContent.trim()); copied = true; setTimeout(() => copied = false, 2000)">
|
||||
<x-ui.icon name="copy" class="size-4" x-show="!copied" />
|
||||
<x-ui.icon name="check" class="size-4" x-show="copied" x-cloak />
|
||||
<span x-text="copied ? @js(__('hosts.takeover.copied')) : @js(__('hosts.takeover.copy'))">{{ __('hosts.takeover.copy') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Umbrechen statt waagerecht rollen: aus einem Kasten mit
|
||||
Rollbalken markiert jemand die Hälfte und merkt es erst auf der
|
||||
Maschine. --}}
|
||||
<pre class="max-h-64 overflow-y-auto px-4 py-3.5 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap text-ink"
|
||||
x-ref="command">{{ $command }}</pre>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-xs leading-relaxed text-muted">{{ __('hosts.takeover.once_body') }}</p>
|
||||
@endif
|
||||
|
||||
<x-slot:footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
@if ($command === null)
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('hosts.cancel') }}</x-ui.button>
|
||||
@if ($missingSettings === [] && $refusal === null)
|
||||
<x-ui.button wire:click="issue" wire:loading.attr="disabled">
|
||||
<x-ui.icon name="refresh" class="size-4" />{{ __('hosts.reissue.confirm') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
@else
|
||||
{{-- „Fertig" und nicht „Abbrechen": ausgestellt ist ausgestellt,
|
||||
und das Schließen nimmt nichts zurück. --}}
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('hosts.reissue.done') }}</x-ui.button>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot:footer>
|
||||
</x-ui.modal>
|
||||
|
|
@ -4,12 +4,15 @@ use App\Livewire\Admin\ConfirmRemoveHost;
|
|||
use App\Livewire\Admin\HostCreate;
|
||||
use App\Livewire\Admin\HostDetail;
|
||||
use App\Livewire\Admin\Hosts;
|
||||
use App\Livewire\Admin\ReissueTakeover;
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Host;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\User;
|
||||
use App\Provisioning\Jobs\AdvanceRunJob;
|
||||
use App\Provisioning\Jobs\PurgeHost;
|
||||
use App\Provisioning\Jobs\RemoveWireguardPeer;
|
||||
use App\Support\HostEnrolment;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
|
@ -40,7 +43,7 @@ it('creates a host and starts onboarding with an encrypted password', function (
|
|||
// das erste Mal bootet (Spec §5). Ohne den gefälschten Hub griffe das nach
|
||||
// `wg` auf einer Maschine, die keine wg0 hat.
|
||||
fakeServices();
|
||||
\App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
|
||||
Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')
|
||||
->test(HostCreate::class)
|
||||
|
|
@ -67,7 +70,7 @@ it('creates a host and starts onboarding with an encrypted password', function (
|
|||
});
|
||||
|
||||
it('rejects a duplicate public ip', function () {
|
||||
\App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
|
||||
Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
|
||||
Host::factory()->create(['public_ip' => '203.0.113.99']);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')
|
||||
|
|
@ -144,8 +147,8 @@ it('renders the live stepper for a running host', function () {
|
|||
});
|
||||
|
||||
it('filters the host list by search and datacenter', function () {
|
||||
\App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
|
||||
\App\Models\Datacenter::query()->firstOrCreate(['code' => 'hel'], ['name' => 'Helsinki']);
|
||||
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']);
|
||||
|
||||
|
|
@ -191,3 +194,118 @@ it('reports host heartbeat health from last_seen_at', function () {
|
|||
->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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue