From fe4a74773e7cc5e0425ab2d38652ef305fd18045 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 10:23:11 +0200 Subject: [PATCH] Break the mode switch and the readiness page into twelve steps Co-Authored-By: Claude Opus 5 --- ...-betriebsmodus-und-betriebsbereitschaft.md | 2368 +++++++++++++++++ 1 file changed, 2368 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-betriebsmodus-und-betriebsbereitschaft.md diff --git a/docs/superpowers/plans/2026-07-30-betriebsmodus-und-betriebsbereitschaft.md b/docs/superpowers/plans/2026-07-30-betriebsmodus-und-betriebsbereitschaft.md new file mode 100644 index 0000000..dedca01 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-betriebsmodus-und-betriebsbereitschaft.md @@ -0,0 +1,2368 @@ +# Betriebsmodus und Betriebsbereitschaft — Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ein Test/Live-Umschalter, der je Zugangsdatum zwei Plätze verwaltet, und eine Konsolenseite, die jedes fehlende Pflichtfeld benennt, bevor ein Kunde bezahlt. + +**Architecture:** `platform.mode` liegt in `Settings` (sofort wirksam, auch in laufenden Warteschlangen-Prozessen) und wird ausschließlich über das Enum `App\Support\OperatingMode` gelesen. Der Tresor bleibt eine kuratierte Liste; nur seine Zeilenschlüssel werden zu `{key}:{mode}` zweigeteilt, und `get()` löst mit Rückfall auf Live auf — außer bei Stripe, das ein `strict`-Merkmal trägt und in beide Richtungen nicht zurückfällt. `App\Support\Readiness` sammelt alle Pflichtfeldprüfungen an einer Stelle und berichtet nur; die harten Sperren bleiben im Ablauf, wo sie heute schon stehen. + +**Tech Stack:** PHP 8.3, Laravel 13.8, Livewire 3, Pest 4.7, MariaDB, Tailwind v3, Docker Compose. + +## Global Constraints + +- **Vollfassung des Entwurfs:** `docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md`. Bei Widerspruch gewinnt die Spec. +- **Commit-Disziplin, nicht verhandelbar.** Eine zweite Claude-Session arbeitet im selben Arbeitsverzeichnis und im selben Git-Index. **Immer** `git add -- ` und `git commit -F - -- `. **Nie** `git add -A`, `git add .`, `git commit -a` oder ein nacktes `git commit`. +- **`pint` nur auf eigene Pfade, nie `--dirty`.** Das Repo ist unter der Standardvorgabe nicht durchgängig formatiert. +- **R22:** eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff. Danach wird ein offener Befund geparkt. +- **R23:** kein `wire:confirm`, kein `confirm(` in JavaScript. Bestätigung im Modal. +- **R24:** jedes Modal mit einem Eingabefeld benutzt ``. +- **R18:** Icons `size-4` in Tabellen und Buttons, `size-5` in der Navigation, einzeilig. +- **R19:** jede angezeigte Zeit geht durch `->local()`. +- **Tests laufen im Container:** `docker compose exec -T app php artisan test`. SQLite im Speicher, erzwungen von `phpunit.xml` — gleichzeitige Läufe kollidieren nicht. +- **Pest-Falle:** `toThrow(SomeInterface::class)` beweist nichts. Immer eine **konkrete Klasse** erwarten. +- **Bricht die ganze Suite auf einmal zusammen**, ist das fast immer eine Datei der anderen Session mitten im Schreiben — einmal neu laufen lassen, bevor man sucht. + +--- + +## Dateistruktur + +| Datei | Verantwortung | +|---|---| +| `app/Support/OperatingMode.php` | **neu** — Enum `Test`/`Live` und `current()`. Die einzige Stelle, die den String kennt. | +| `app/Services/Secrets/SecretVault.php` | Zeilenschlüssel je Modus, `strict`-Merkmal, Auflösungsregel. | +| `app/Services/Stripe/HttpStripeClient.php` | `secret()` wirft statt tokenlos zu senden. | +| `app/Exceptions/StripeNotConfigured.php` | **neu** — konkrete Ausnahme, damit Tests sie greifen können. | +| `app/Http/Controllers/StripeWebhookController.php` | Webhook-Schlüssel nach Modus. | +| `app/Livewire/Billing.php` | Kauf abweisen, wenn dem aktiven Modus der Stripe-Schlüssel fehlt. | +| `app/Support/Readiness.php` | **neu** — Sammler. Kennt keine einzelne Prüfung, nur die Liste. | +| `app/Support/Readiness/Check.php` | **neu** — ein Befund: Schlüssel, Gruppe, Grad, Text, Reiter. | +| `app/Support/Readiness/BillingChecks.php` | **neu** — Gruppe Abrechnung. | +| `app/Support/Readiness/OnboardingChecks.php` | **neu** — Gruppe Host-Onboarding. | +| `app/Support/Readiness/ProvisioningChecks.php` | **neu** — Gruppe Bereitstellung. | +| `app/Support/Readiness/DeliveryChecks.php` | **neu** — Gruppe Zustellung. | +| `app/Support/Readiness/OperationChecks.php` | **neu** — Gruppe Betrieb (Herzschläge). | +| `app/Services/Dns/DnsTokenCheck.php` | **neu** — Schreibrecht auf der Zone. | +| `app/Services/Vpn/WireguardEndpointCheck.php` | **neu** — Endpunkt ist öffentlich. | +| `app/Services/Proxmox/VmTemplateCheck.php` | **neu** — Vorlage liegt auf einem aktiven Host. | +| `app/Livewire/Admin/Readiness.php` | **neu** — die Seite. | +| `app/Livewire/Admin/ConfirmSwitchMode.php` | **neu** — Bestätigung nach R23. | +| `app/Livewire/Admin/Integrations.php` | Umschalter, zwei Felder je Eintrag. | +| `routes/console.php` | Zwei Herzschläge. | + +Die Prüfgruppen sind **fünf kleine Dateien statt einer großen**: jede Gruppe hat eigene Abhängigkeiten (Stripe, Proxmox, DNS, Mail), und eine Datei, die alle vier importiert, wäre in jedem Test die ganze Welt. + +--- + +### Task 1: Der Betriebsmodus als Enum + +**Files:** +- Create: `app/Support/OperatingMode.php` +- Test: `tests/Feature/OperatingModeTest.php` + +**Interfaces:** +- Consumes: `App\Support\Settings` (existiert). +- Produces: `OperatingMode::current(): OperatingMode`, Fälle `OperatingMode::Test` und `OperatingMode::Live`, `->value` als `'test'`/`'live'`, `OperatingMode::set(OperatingMode $mode): void`. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +toBe(OperatingMode::Live); +}); + +it('reads the stored mode', function () { + Settings::set('platform.mode', 'test'); + + expect(OperatingMode::current())->toBe(OperatingMode::Test); +}); + +it('falls back to live when the stored value is not a mode', function () { + // Eine Zeile, die von Hand oder von einer alten Fassung geschrieben wurde. + // Live ist die sichere Richtung: eine echte Zahlung wird korrekt behandelt, + // ein Testereignis scheitert laut. + Settings::set('platform.mode', 'staging'); + + expect(OperatingMode::current())->toBe(OperatingMode::Live); +}); + +it('writes the mode and takes effect immediately', function () { + OperatingMode::set(OperatingMode::Test); + + expect(OperatingMode::current())->toBe(OperatingMode::Test); +}); +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=OperatingModeTest` +Expected: FAIL mit `Class "App\Support\OperatingMode" not found` + +- [ ] **Step 3: Das Enum schreiben** + +```php +value)) + ?? self::Live; + } + + public static function set(self $mode): void + { + Settings::set(self::SETTING, $mode->value); + } + + public function isTest(): bool + { + return $this === self::Test; + } +} +``` + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=OperatingModeTest` +Expected: PASS, 4 Tests + +- [ ] **Step 5: Committen** + +```bash +git add -- app/Support/OperatingMode.php tests/Feature/OperatingModeTest.php +git commit -F - -- app/Support/OperatingMode.php tests/Feature/OperatingModeTest.php <<'EOF' +Give the installation a test mode and a live mode + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 2: Der Tresor bekommt zwei Plätze je Eintrag + +**Files:** +- Modify: `app/Services/Secrets/SecretVault.php` +- Test: `tests/Feature/SecretVaultModeTest.php` + +**Interfaces:** +- Consumes: `OperatingMode::current()` aus Task 1. +- Produces: `SecretVault::get(string $key): ?string` — **Signatur unverändert**, der Modus wird innen aufgelöst. `put(string $key, string $value, Operator $by, ?OperatingMode $mode = null)`, `forget(string $key, ?OperatingMode $mode = null)`, `source(string $key, ?OperatingMode $mode = null)`, `outline(string $key, ?OperatingMode $mode = null)`, `updatedAt(string $key, ?OperatingMode $mode = null)`. `null` heißt „aktiver Modus". + +**Wichtig:** `get()` behält seine Signatur mit Absicht. Die Aufrufer (`HttpStripeClient:367`, `StripeCheck:26`, `SshTraefikWriter:45`) bleiben unverändert — nur die Konsole muss beide Plätze kennen. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +by = Operator::factory()->create(); + $this->vault = app(SecretVault::class); +}); + +it('reads the slot of the active mode', function () { + $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live); + $this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test); + + OperatingMode::set(OperatingMode::Test); + expect($this->vault->get('dns.token'))->toBe('test-token'); + + OperatingMode::set(OperatingMode::Live); + expect($this->vault->get('dns.token'))->toBe('live-token'); +}); + +it('falls back to the live slot when the test slot is empty', function () { + // Die Regel des Inhabers: wo es keinen Testzugang gibt, wird der echte + // benutzt, damit der Testbetrieb überhaupt arbeiten kann. + $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live); + OperatingMode::set(OperatingMode::Test); + + expect($this->vault->get('dns.token'))->toBe('live-token'); +}); + +it('does not fall back from live to test', function () { + // Die andere Richtung wäre absurd: im Livebetrieb einen Testzugang zu + // benutzen, weil der echte fehlt. + $this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test); + OperatingMode::set(OperatingMode::Live); + + expect($this->vault->get('dns.token'))->toBeNull(); +}); + +it('still falls back to the environment when neither slot is filled', function () { + // Der Weg für eine Installation, in der noch gar nichts gespeichert ist. + // Einwertig und modusfrei — den Anfangszustand gibt es nur einmal. + config()->set('provisioning.dns.token', 'from-env'); + + expect($this->vault->get('dns.token'))->toBe('from-env'); +}); + +it('reports the source per slot', function () { + $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live); + + expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored'); + expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none'); +}); + +it('forgets one slot without touching the other', function () { + $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live); + $this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test); + + $this->vault->forget('dns.token', OperatingMode::Test); + + expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none'); + expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored'); +}); +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=SecretVaultModeTest` +Expected: FAIL — `put()` nimmt noch kein viertes Argument + +- [ ] **Step 3: Den Tresor umbauen** + +In `app/Services/Secrets/SecretVault.php`. Der Zeilenschlüssel wird zweigeteilt, alles andere bleibt: + +```php +/** Der Zeilenschlüssel im Speicher: ein Eintrag hat zwei Plätze. */ +private function rowKey(string $key, ?OperatingMode $mode = null): string +{ + return $key.':'.($mode ?? OperatingMode::current())->value; +} + +private function row(string $key, ?OperatingMode $mode = null): ?object +{ + return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first(); +} +``` + +`get()` bekommt die Auflösungsregel: + +```php +public function get(string $key): ?string +{ + $this->assertKnown($key); + + $mode = OperatingMode::current(); + $row = $this->row($key, $mode); + + // Rückfall auf Live, wenn der Platz des aktiven Modus leer ist. NUR von + // Test auf Live, nie umgekehrt: im Livebetrieb einen Testzugang zu + // benutzen, weil der echte fehlt, wäre die gefährliche Richtung. + if ($row === null && $mode->isTest() && ! $this->isStrict($key)) { + $row = $this->row($key, OperatingMode::Live); + } + + if ($row === null) { + // Strikte Einträge sehen die Umgebung nicht: siehe isStrict(). + if ($this->isStrict($key)) { + return null; + } + + $configured = config(self::REGISTRY[$key]['config']); + + return $configured === null ? null : (string) $configured; + } + + try { + return app(SecretCipher::class)->decrypt($row->value); + } catch (DecryptException $e) { + Log::error('A stored secret could not be decrypted', ['key' => $key]); + + throw new RuntimeException("Stored secret [{$key}] cannot be decrypted.", previous: $e); + } +} + +/** Trägt dieser Eintrag das strict-Merkmal? Gefüllt in Task 3. */ +private function isStrict(string $key): bool +{ + return (bool) (self::REGISTRY[$key]['strict'] ?? false); +} +``` + +`put()`, `forget()`, `source()`, `outline()`, `updatedAt()` bekommen je einen +`?OperatingMode $mode = null`-Parameter und reichen ihn an `rowKey()`/`row()` +durch. In `put()` wird `DB::table('app_secrets')->updateOrInsert(['key' => $this->rowKey($key, $mode)], $attributes)`. + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=SecretVaultModeTest` +Expected: PASS, 6 Tests + +- [ ] **Step 5: Die bestehende Tresor-Suite laufen lassen** + +Run: `docker compose exec -T app php artisan test --filter=Secret` +Expected: PASS — `get()` hat seine Signatur behalten, kein Aufrufer ändert sich + +- [ ] **Step 6: Committen** + +```bash +git add -- app/Services/Secrets/SecretVault.php tests/Feature/SecretVaultModeTest.php +git commit -F - -- app/Services/Secrets/SecretVault.php tests/Feature/SecretVaultModeTest.php <<'EOF' +Give every credential a test slot beside its live one + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 3: Stripe fällt nicht zurück + +**Files:** +- Modify: `app/Services/Secrets/SecretVault.php` (Registry-Eintrag) +- Test: `tests/Feature/StripeStrictModeTest.php` + +**Interfaces:** +- Consumes: `isStrict()` aus Task 2. +- Produces: `SecretVault::REGISTRY['stripe.secret']['strict'] === true`. + +**Das ist die wichtigste Zeile dieses Plans.** Ohne sie bucht ein Testkauf bei +fehlendem Testschlüssel **echtes Geld** ab, während die Konsole „Testbetrieb" +anzeigt. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +by = Operator::factory()->create(); + $this->vault = app(SecretVault::class); +}); + +it('does not use the live key while the test mode is active', function () { + $this->vault->put('stripe.secret', 'sk_live_real', $this->by, OperatingMode::Live); + OperatingMode::set(OperatingMode::Test); + + expect($this->vault->get('stripe.secret'))->toBeNull(); +}); + +it('does not use the test key while the live mode is active', function () { + $this->vault->put('stripe.secret', 'sk_test_fake', $this->by, OperatingMode::Test); + OperatingMode::set(OperatingMode::Live); + + expect($this->vault->get('stripe.secret'))->toBeNull(); +}); + +it('ignores the environment for stripe as well', function () { + // Sonst wäre die .env die Hintertür, durch die der Live-Schlüssel doch in + // den Testbetrieb kommt. + config()->set('services.stripe.secret', 'sk_live_from_env'); + OperatingMode::set(OperatingMode::Test); + + expect($this->vault->get('stripe.secret'))->toBeNull(); +}); + +it('uses the key of the active mode when it is there', function () { + $this->vault->put('stripe.secret', 'sk_test_fake', $this->by, OperatingMode::Test); + OperatingMode::set(OperatingMode::Test); + + expect($this->vault->get('stripe.secret'))->toBe('sk_test_fake'); +}); + +it('lets every other entry keep falling back', function () { + // Die Ausnahme ist eine Ausnahme, keine neue Regel. + $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live); + OperatingMode::set(OperatingMode::Test); + + expect($this->vault->get('dns.token'))->toBe('live-token'); +}); +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=StripeStrictModeTest` +Expected: FAIL — die ersten drei Tests, weil noch zurückgefallen wird + +- [ ] **Step 3: Das Merkmal setzen** + +In `SecretVault::REGISTRY`, Eintrag `stripe.secret`: + +```php +'stripe.secret' => [ + 'config' => 'services.stripe.secret', + 'label' => 'secrets.item.stripe_secret', + 'check' => StripeCheck::class, + 'env_key' => 'STRIPE_SECRET', + // Kein Rückfall, in keine Richtung, und auch nicht auf die Umgebung. + // Ohne das benutzt ein Testkauf bei fehlendem Testschlüssel still den + // Live-Schlüssel und bucht echtes Geld ab, während die Konsole + // „Testbetrieb aktiv" anzeigt. Jeder andere Eintrag hier bezeichnet + // dasselbe Konto in beiden Modi; dieser nicht. + 'strict' => true, +], +``` + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=StripeStrictModeTest` +Expected: PASS, 5 Tests + +- [ ] **Step 5: Committen** + +```bash +git add -- app/Services/Secrets/SecretVault.php tests/Feature/StripeStrictModeTest.php +git commit -F - -- app/Services/Secrets/SecretVault.php tests/Feature/StripeStrictModeTest.php <<'EOF' +Never spend real money while the switch says test + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 4: Migration — bestehende Zeilen einsortieren, ohne zu raten + +**Files:** +- Create: `database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php` +- Test: `tests/Feature/SecretSlotMigrationTest.php` + +**Interfaces:** +- Consumes: `app_secrets.key`, `OperatingMode::SETTING`. +- Produces: keine neue Schnittstelle. Nach der Migration trägt jede Zeile einen Modus im Schlüssel. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +up(); +} + +it('files a stripe test key in the test slot and switches the mode', function () { + DB::table('app_secrets')->insert([ + 'key' => 'stripe.secret', + 'value' => encrypt('sk_test_abc'), + 'created_at' => now(), 'updated_at' => now(), + ]); + + runSlotMigration(); + + expect(DB::table('app_secrets')->where('key', 'stripe.secret:test')->exists())->toBeTrue(); + expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->exists())->toBeFalse(); + expect(Settings::get(OperatingMode::SETTING))->toBe('test'); +}); + +it('files a stripe live key in the live slot and leaves the mode alone', function () { + DB::table('app_secrets')->insert([ + 'key' => 'stripe.secret', + 'value' => encrypt('sk_live_abc'), + 'created_at' => now(), 'updated_at' => now(), + ]); + + runSlotMigration(); + + expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->exists())->toBeTrue(); + expect(OperatingMode::current())->toBe(OperatingMode::Live); +}); + +it('files every other credential in the live slot', function () { + DB::table('app_secrets')->insert([ + 'key' => 'dns.token', + 'value' => encrypt('whatever'), + 'created_at' => now(), 'updated_at' => now(), + ]); + + runSlotMigration(); + + expect(DB::table('app_secrets')->where('key', 'dns.token:live')->exists())->toBeTrue(); + expect(DB::table('app_secrets')->where('key', 'dns.token')->exists())->toBeFalse(); +}); + +it('leaves an installation with no stripe key on live', function () { + runSlotMigration(); + + expect(OperatingMode::current())->toBe(OperatingMode::Live); +}); +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=SecretSlotMigrationTest` +Expected: FAIL — die Migrationsdatei existiert nicht + +- [ ] **Step 3: Die Migration schreiben** + +Der entschlüsselte Wert wird gebraucht, um das Präfix zu lesen. Die Migration +benutzt denselben Chiffrierer wie der Tresor; scheitert die Entschlüsselung, +wandert die Zeile nach `:live` — das ist der Zustand vor dieser Migration und +damit die Fortsetzung des Status quo, nicht eine neue Behauptung. + +```php +get() as $row) { + // Schon einsortiert (etwa bei einem zweiten Lauf): unangetastet. + if (Str::contains($row->key, ':')) { + continue; + } + + $mode = OperatingMode::Live; + + if ($row->key === 'stripe.secret') { + $mode = $this->stripeModeOf($row->value); + $stripeMode = $mode; + } + + DB::table('app_secrets') + ->where('id', $row->id) + ->update(['key' => $row->key.':'.$mode->value]); + } + + // Nur wenn ein Stripe-Schlüssel da war. Ohne ihn bleibt die Vorgabe + // stehen, und die ist live. + if ($stripeMode !== null) { + Settings::set(OperatingMode::SETTING, $stripeMode->value); + } + } + + private function stripeModeOf(string $stored): OperatingMode + { + try { + $secret = app(SecretCipher::class)->decrypt($stored); + } catch (\Throwable) { + // Unlesbar heißt: wir wissen es nicht. `:live` ist der Zustand vor + // dieser Migration, also die Fortsetzung des Status quo statt einer + // neuen Behauptung. Die Bereitschaftsseite meldet ihn ohnehin. + return OperatingMode::Live; + } + + return Str::contains($secret, '_test_') ? OperatingMode::Test : OperatingMode::Live; + } + + public function down(): void + { + foreach (DB::table('app_secrets')->get() as $row) { + if (! Str::endsWith($row->key, [':live', ':test'])) { + continue; + } + + $bare = Str::beforeLast($row->key, ':'); + + // Eine Zeile je Eintrag kann zurück; die zweite hat im alten Schema + // keinen Platz und würde am Eindeutigkeitsindex scheitern. + if (DB::table('app_secrets')->where('key', $bare)->exists()) { + DB::table('app_secrets')->where('id', $row->id)->delete(); + + continue; + } + + DB::table('app_secrets')->where('id', $row->id)->update(['key' => $bare]); + } + + Settings::forget(OperatingMode::SETTING); + } +}; +``` + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=SecretSlotMigrationTest` +Expected: PASS, 4 Tests + +- [ ] **Step 5: Auf dem Entwicklungsserver laufen lassen und nachsehen** + +Nicht „grün heißt fertig". Die Migration fasst echte Zugangsdaten an. + +```bash +docker compose exec -T app php artisan migrate --force +``` + +Dann prüfen, dass der vorhandene `sk_test_`-Schlüssel im Testplatz gelandet ist +und der Modus auf `test` steht: + +```bash +docker compose exec -T app php artisan tinker --execute=' +foreach(\Illuminate\Support\Facades\DB::table("app_secrets")->pluck("key") as $k) echo " $k\n"; +echo "Modus: ".\App\Support\OperatingMode::current()->value."\n";' +``` + +Expected: `stripe.secret:test`, alle übrigen auf `:live`, `Modus: test` + +- [ ] **Step 6: Committen** + +```bash +git add -- database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php tests/Feature/SecretSlotMigrationTest.php +git commit -F - -- database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php tests/Feature/SecretSlotMigrationTest.php <<'EOF' +Let the stored key say which mode it belongs to + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 5: Ohne Schlüssel wird nicht verkauft + +**Files:** +- Create: `app/Exceptions/StripeNotConfigured.php` +- Modify: `app/Services/Stripe/HttpStripeClient.php:367` +- Modify: `app/Livewire/Billing.php` (`purchase()` bei Zeile 61) +- Test: `tests/Feature/CheckoutWithoutStripeKeyTest.php` + +**Interfaces:** +- Consumes: `SecretVault::get('stripe.secret')` aus Task 3 (liefert `null`). +- Produces: `App\Exceptions\StripeNotConfigured` (konkrete Klasse — eine Schnittstelle im `toThrow()` bewiese nichts). + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php + app(HttpStripeClient::class)->createPrice('prod_x', 100, 'EUR', 'month')) + ->toThrow(StripeNotConfigured::class); +}); + +it('does not create an order when the key is missing', function () { + OperatingMode::set(OperatingMode::Test); + $customer = Customer::factory()->create(); + $user = User::factory()->for($customer)->create(); + + Livewire::actingAs($user) + ->test(Billing::class) + ->call('purchase', 'upgrade', 'test') + ->assertHasNoErrors(); + + // Der Punkt: nach der Zahlung wäre es zu spät. Es darf gar nichts entstehen. + expect(Order::count())->toBe(0); +}); + +it('says why instead of failing silently', function () { + OperatingMode::set(OperatingMode::Test); + $customer = Customer::factory()->create(); + $user = User::factory()->for($customer)->create(); + + Livewire::actingAs($user) + ->test(Billing::class) + ->call('purchase', 'upgrade', 'test') + ->assertSee(__('billing.stripe_not_configured')); +}); +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=CheckoutWithoutStripeKeyTest` +Expected: FAIL mit `Class "App\Exceptions\StripeNotConfigured" not found` + +- [ ] **Step 3: Ausnahme und Sperre schreiben** + +`app/Exceptions/StripeNotConfigured.php`: + +```php +get('stripe.secret'); + + // Ohne Token abzusenden hieße, Stripe mit einem leeren Bearer zu fragen + // und einen 401 zu bekommen, den niemand einem fehlenden Schlüssel + // zuordnet. Hier abzubrechen sagt, was fehlt. + if (blank($secret)) { + throw StripeNotConfigured::forMode(OperatingMode::current()->value); + } + + return $secret; +} +``` + +In `app/Livewire/Billing.php`, ganz am Anfang von `purchase()` — vor +`requireCustomer()`, damit gar nichts angefasst wird: + +```php +public function purchase(string $type, ?string $key = null, int $quantity = 1): void +{ + // Vor allem anderen: ohne Schlüssel des aktiven Modus entsteht kein + // Auftrag. Nach der Zahlung wäre es zu spät, und ein halb angelegter + // Auftrag ohne Stripe-Sitzung ist genau die Leiche, die niemand findet. + if (blank(app(SecretVault::class)->get('stripe.secret'))) { + $this->addError('purchase', __('billing.stripe_not_configured')); + + return; + } + + $customer = $this->requireCustomer(); + // ... unverändert weiter +``` + +In `lang/de/billing.php` (und `lang/en/billing.php`, falls vorhanden): + +```php +'stripe_not_configured' => 'Die Bezahlung ist derzeit nicht eingerichtet. Bitte versuchen Sie es später erneut.', +``` + +Der Kunde erfährt bewusst **nicht**, dass ein Schlüssel fehlt oder welcher +Betriebsmodus läuft — das ist eine Betreiberangelegenheit und steht auf der +Bereitschaftsseite. + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=CheckoutWithoutStripeKeyTest` +Expected: PASS, 3 Tests + +- [ ] **Step 5: Die Kassen-Suite laufen lassen** + +Run: `docker compose exec -T app php artisan test --filter=Billing` +Expected: PASS — bestehende Tests hinterlegen einen Schlüssel und laufen weiter + +- [ ] **Step 6: Committen** + +```bash +git add -- app/Exceptions/StripeNotConfigured.php app/Services/Stripe/HttpStripeClient.php app/Livewire/Billing.php lang/de/billing.php tests/Feature/CheckoutWithoutStripeKeyTest.php +git commit -F - -- app/Exceptions/StripeNotConfigured.php app/Services/Stripe/HttpStripeClient.php app/Livewire/Billing.php lang/de/billing.php tests/Feature/CheckoutWithoutStripeKeyTest.php <<'EOF' +Refuse the sale instead of reaching Stripe without a key + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 6: Der Webhook-Schlüssel folgt dem Modus + +**Files:** +- Modify: `app/Http/Controllers/StripeWebhookController.php:23` +- Modify: `config/services.php:39` +- Modify: `.env.example` +- Test: `tests/Feature/StripeWebhookSecretByModeTest.php` + +**Interfaces:** +- Consumes: `OperatingMode::current()`. +- Produces: `config('services.stripe.webhook_secret')` (live) und `config('services.stripe.webhook_secret_test')`. + +Der Schlüssel bleibt in der `.env` — seine Begründung gilt unverändert: er wird +bei **jedem** eingehenden Zahlungsereignis gelesen, und ein Datenbankproblem +würde die Signaturprüfung still fehlschlagen lassen. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +set('services.stripe.webhook_secret', 'whsec_live'); + config()->set('services.stripe.webhook_secret_test', 'whsec_test'); +}); + +it('uses the live signing secret in live mode', function () { + OperatingMode::set(OperatingMode::Live); + + expect(webhookSecret())->toBe('whsec_live'); +}); + +it('uses the test signing secret in test mode', function () { + OperatingMode::set(OperatingMode::Test); + + expect(webhookSecret())->toBe('whsec_test'); +}); + +it('uses the live secret when the settings table cannot be read', function () { + OperatingMode::set(OperatingMode::Test); + + // Der Ausfall, gegen den der Wert ursprünglich aus der Datenbank + // herausgehalten wurde. + Schema::drop('app_settings'); + + expect(webhookSecret())->toBe('whsec_live'); +}); +``` + +Die Hilfsfunktion `webhookSecret()` gehört als `private static` auf den +Controller und wird im Test über eine kleine Brücke gerufen — in Pest genügt: + +```php +function webhookSecret(): string +{ + return (new ReflectionMethod(\App\Http\Controllers\StripeWebhookController::class, 'signingSecret')) + ->invoke(null); +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=StripeWebhookSecretByModeTest` +Expected: FAIL — `signingSecret()` existiert nicht + +- [ ] **Step 3: Auswahl einbauen** + +`config/services.php`, im `stripe`-Block neben `webhook_secret`: + +```php +'webhook_secret_test' => env('STRIPE_WEBHOOK_SECRET_TEST'), +``` + +`.env.example`, neben der bestehenden Zeile: + +``` +STRIPE_WEBHOOK_SECRET_TEST= +``` + +`StripeWebhookController`, Zeile 23 ersetzen: + +```php +$secret = self::signingSecret(); +``` + +und die Methode dazu: + +```php +/** + * Der Signaturschlüssel des aktiven Betriebsmodus. + * + * Beide bleiben in der Serverdatei, nicht im Tresor: dieser Wert wird bei jedem + * eingehenden Zahlungsereignis gelesen, und ein Datenbankproblem würde die + * Signaturprüfung still fehlschlagen lassen — die eine Fehlerart, die laut + * bleiben muss. + * + * Die AUSWAHL fragt allerdings die Datenbank, was diese Abhängigkeit auf einem + * Umweg wieder einführt. Entschärft durch die Richtung des Ausfalls: + * `Settings::get()` liefert bei unerreichbarer Tabelle seinen Vorgabewert + * `live`, also wird der Live-Schlüssel benutzt. Ein echtes Zahlungsereignis + * wird weiter korrekt geprüft, ein Testereignis scheitert laut. Andersherum + * wäre es die gefährliche Richtung. + */ +private static function signingSecret(): string +{ + return OperatingMode::current()->isTest() + ? (string) config('services.stripe.webhook_secret_test') + : (string) config('services.stripe.webhook_secret'); +} +``` + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=StripeWebhookSecretByModeTest` +Expected: PASS, 3 Tests + +- [ ] **Step 5: Committen** + +```bash +git add -- app/Http/Controllers/StripeWebhookController.php config/services.php .env.example tests/Feature/StripeWebhookSecretByModeTest.php +git commit -F - -- app/Http/Controllers/StripeWebhookController.php config/services.php .env.example tests/Feature/StripeWebhookSecretByModeTest.php <<'EOF' +Verify webhooks against the secret of the mode we are in + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 7: Der Sammler und die Gruppe Abrechnung + +**Files:** +- Create: `app/Support/Readiness/Check.php` +- Create: `app/Support/Readiness/BillingChecks.php` +- Create: `app/Support/Readiness.php` +- Test: `tests/Feature/Readiness/BillingChecksTest.php` + +**Interfaces:** +- Consumes: `SecretVault`, `CompanyProfile::missingForInvoicing()`, `OperatingMode`, `InvoiceSeries`, `PlanPrice`. +- Produces: + - `Check::__construct(string $key, string $group, string $severity, string $label, string $breaks, string $tab, bool $satisfied)` + - `Check::SEVERITY_BLOCKING = 'blocking'`, `Check::SEVERITY_WARNING = 'warning'` + - `Readiness::all(): array` + - `Readiness::blocking(): array` — nur unerfüllte mit Grad blocking + - `Readiness::isReady(): bool` + - `BillingChecks::all(): array` + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +firstWhere('key', $key); +} + +it('reports the stripe key of the active mode as missing', function () { + OperatingMode::set(OperatingMode::Test); + + expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse(); + expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING); +}); + +it('reports it as satisfied once the key of that mode is stored', function () { + OperatingMode::set(OperatingMode::Test); + app(SecretVault::class)->put('stripe.secret', 'sk_test_x', Operator::factory()->create(), OperatingMode::Test); + + expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue(); +}); + +it('does not accept a live key as proof while the test mode is active', function () { + // Sonst meldete die Seite Bereitschaft für einen Modus, in dem die Kasse + // sperrt — die Seite widerspräche der Sperre aus Task 5. + OperatingMode::set(OperatingMode::Test); + app(SecretVault::class)->put('stripe.secret', 'sk_live_x', Operator::factory()->create(), OperatingMode::Live); + + expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse(); +}); + +it('reports incomplete company details without repeating the list', function () { + Settings::forget('company.name'); + + expect(checkFor('billing.company_details')->satisfied)->toBeFalse(); +}); + +it('names what breaks, not just what is missing', function () { + expect(checkFor('billing.company_details')->breaks)->not->toBe(''); +}); + +it('says the installation is not ready while something blocking is open', function () { + expect(Readiness::isReady())->toBeFalse(); + expect(Readiness::blocking())->not->toBeEmpty(); +}); +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=BillingChecksTest` +Expected: FAIL mit `Class "App\Support\Readiness" not found` + +- [ ] **Step 3: Befund, Gruppe und Sammler schreiben** + +`app/Support/Readiness/Check.php`: + +```php +severity === self::SEVERITY_BLOCKING && ! $this->satisfied; + } +} +``` + +`app/Support/Readiness/BillingChecks.php`: + +```php + */ + public static function all(): array + { + $mode = OperatingMode::current(); + + return [ + new Check( + key: 'billing.stripe_secret', + group: self::GROUP, + severity: Check::SEVERITY_BLOCKING, + label: __('readiness.billing.stripe_secret', ['mode' => __('readiness.mode.'.$mode->value)]), + breaks: __('readiness.billing.stripe_secret_breaks'), + tab: 'services', + // get() löst den aktiven Modus selbst auf UND ist für Stripe + // strikt — ein Live-Schlüssel zählt im Testbetrieb nicht als + // Nachweis, sonst widerspräche diese Seite der Kassensperre. + satisfied: filled(app(SecretVault::class)->get('stripe.secret')), + ), + new Check( + key: 'billing.webhook_secret', + group: self::GROUP, + severity: Check::SEVERITY_BLOCKING, + label: __('readiness.billing.webhook_secret'), + breaks: __('readiness.billing.webhook_secret_breaks'), + tab: 'env', + satisfied: filled($mode->isTest() + ? config('services.stripe.webhook_secret_test') + : config('services.stripe.webhook_secret')), + ), + new Check( + key: 'billing.company_details', + group: self::GROUP, + severity: Check::SEVERITY_BLOCKING, + label: __('readiness.billing.company_details'), + breaks: __('readiness.billing.company_details_breaks'), + tab: 'company', + // Die vorhandene Liste wird gerufen, nicht verdoppelt: zwei + // Quellen für eine Frage ist der Weg, auf dem sie auseinanderlaufen. + satisfied: CompanyProfile::missingForInvoicing() === [], + ), + new Check( + key: 'billing.invoice_series', + group: self::GROUP, + severity: Check::SEVERITY_BLOCKING, + label: __('readiness.billing.invoice_series'), + breaks: __('readiness.billing.invoice_series_breaks'), + tab: 'company', + satisfied: InvoiceSeries::query() + ->whereIn('kind', ['invoice', 'credit_note', 'cancellation']) + ->distinct() + ->count('kind') === 3, + ), + new Check( + key: 'billing.catalogue_synced', + group: self::GROUP, + severity: Check::SEVERITY_BLOCKING, + label: __('readiness.billing.catalogue_synced'), + breaks: __('readiness.billing.catalogue_synced_breaks'), + tab: 'services', + satisfied: PlanPrice::query()->whereNull('stripe_price_id')->doesntExist(), + ), + ]; + } +} +``` + +`app/Support/Readiness.php`: + +```php + */ + public static function all(): array + { + return BillingChecks::all(); + } + + /** @return array */ + public static function blocking(): array + { + return array_values(array_filter(self::all(), fn (Check $c) => $c->isBlocking())); + } + + public static function isReady(): bool + { + return self::blocking() === []; + } + + /** @return array> */ + public static function byGroup(): array + { + $grouped = []; + + foreach (self::all() as $check) { + $grouped[$check->group][] = $check; + } + + return $grouped; + } +} +``` + +`lang/de/readiness.php` anlegen mit den benutzten Schlüsseln, darunter: + +```php +'mode' => ['test' => 'Testbetrieb', 'live' => 'Livebetrieb'], +'billing' => [ + 'stripe_secret' => 'Stripe-Schlüssel (:mode)', + 'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.', + 'webhook_secret' => 'Stripe-Signaturschlüssel', + 'webhook_secret_breaks' => 'Ohne ihn wird eine Zahlung nie verbucht: der Kunde zahlt, und nichts passiert.', + 'company_details' => 'Firmendaten vollständig', + 'company_details_breaks' => 'IssueInvoice verweigert die Ausstellung. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg.', + 'invoice_series' => 'Rechnungsserie je Belegart', + 'invoice_series_breaks' => 'Ein Beleg kann keine Nummer ziehen.', + 'catalogue_synced' => 'Stripe-Katalog abgeglichen', + 'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.', +], +``` + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=BillingChecksTest` +Expected: PASS, 6 Tests + +- [ ] **Step 5: Committen** + +```bash +git add -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/BillingChecksTest.php +git commit -F - -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/BillingChecksTest.php <<'EOF' +Say what breaks, not just which field is empty + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 8: Die übrigen drei Gruppen + +**Files:** +- Create: `app/Support/Readiness/OnboardingChecks.php` +- Create: `app/Support/Readiness/ProvisioningChecks.php` +- Create: `app/Support/Readiness/DeliveryChecks.php` +- Modify: `app/Support/Readiness.php` (die drei Gruppen einhängen) +- Modify: `lang/de/readiness.php` +- Test: `tests/Feature/Readiness/OnboardingChecksTest.php`, `tests/Feature/Readiness/ProvisioningChecksTest.php`, `tests/Feature/Readiness/DeliveryChecksTest.php` + +**Interfaces:** +- Consumes: `Check` und `checkFor()`-Muster aus Task 7. +- Produces: `OnboardingChecks::all()`, `ProvisioningChecks::all()`, `DeliveryChecks::all()` — je `array`. `Readiness::all()` fügt sie in der Reihenfolge Abrechnung, Onboarding, Bereitstellung, Zustellung zusammen. + +Schlüssel der Prüfungen, vollständig — die Gruppe ist das Präfix: + +| Schlüssel | Grad | Erfüllt, wenn | +|---|---|---| +| `onboarding.ssh_key` | blocking | `SecretVault::get('ssh.private_key')` gefüllt | +| `onboarding.secrets_key` | blocking | `config('app.secrets_key')` bzw. `env('SECRETS_KEY')` gefüllt | +| `onboarding.vpn_config_key` | blocking | `env('VPN_CONFIG_KEY')` gefüllt | +| `onboarding.wg_hub` | blocking | Hub-Pubkey, Endpunkt und Subnetz alle gefüllt | +| `onboarding.datacenter` | blocking | `Datacenter::exists()` | +| `provisioning.dns_token` | blocking | `SecretVault::get('dns.token')` gefüllt | +| `provisioning.dns_zone` | blocking | `ProvisioningSettings::dnsZone()` gefüllt | +| `provisioning.traefik_path` | warning | `ProvisioningSettings::traefikDynamicPath()` gefüllt | +| `provisioning.usable_host` | blocking | `Host::where('status','active')->whereNotNull('api_token_ref')->exists()` | +| `provisioning.vm_template` | blocking | Reine Feldprüfung: jede veröffentlichte Version hat eine `template_vmid`. Ob sie auf einem Knoten **liegt**, beantwortet erst der Knopf aus Task 9 — dafür muss Proxmox befragt werden, und das gehört nicht in jeden Seitenaufruf. | +| `provisioning.monitoring_token` | warning | `SecretVault::get('monitoring.token')` gefüllt | +| `delivery.mailer_not_log` | blocking | `config('mail.default') !== 'log'` | +| `delivery.mailbox` | blocking | `Mailbox::exists()` | +| `delivery.mail_templates` | warning | `MailTemplate::exists()` | +| `delivery.inbound_password` | warning | `SecretVault::get('inbound_mail.password')` gefüllt | + +- [ ] **Step 1: Die drei fehlschlagenden Testdateien schreiben** + +Beispiel für die Prüfung, die den Befund vom 2026-07-30 fängt — +`tests/Feature/Readiness/ProvisioningChecksTest.php`: + +```php +firstWhere('key', $key); +} + +/** + * Am 2026-07-30 standen zwei Hosts auf `active`, ohne api_token_ref und mit + * Adressen aus dem Dokumentationsbereich (203.0.113.0/24, RFC 5737). In der + * Konsole waren sie von einem echten Host nicht zu unterscheiden — und + * ReserveResources wählt aus `status = active`. Ein Kauf hätte sich einen + * dieser Phantom-Hosts reserviert und wäre NACH der Zahlung gestorben. + */ +it('does not accept an active host without a readable token', function () { + Host::factory()->create(['status' => 'active', 'api_token_ref' => null]); + + expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeFalse(); +}); + +it('accepts an active host that carries a token', function () { + Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1']); + + expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeTrue(); +}); + +it('does not accept a host that carries a token but is not active', function () { + Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => 'ref-1']); + + expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeFalse(); +}); + +it('treats the monitoring token as a warning, never as a blocker', function () { + // Überwachung darf eine Bereitstellung nie aufhalten. + expect(provisioningCheck('provisioning.monitoring_token')->severity) + ->toBe(Check::SEVERITY_WARNING); +}); +``` + +Und in `tests/Feature/Readiness/DeliveryChecksTest.php` der Fall, der heute +still danebengeht: + +```php +firstWhere('key', $key); +} + +/** + * `mail.default = log` heißt: die VM läuft, der Beleg ist geschrieben, und die + * Zugangsdaten-Mail liegt in einer Datei auf dem Server. Der Kunde erfährt nie, + * dass er eine Cloud hat. Kein Fehler taucht irgendwo auf. + */ +it('refuses to call an installation ready while mail goes to the log file', function () { + config()->set('mail.default', 'log'); + + expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeFalse(); +}); + +it('accepts a real mailer', function () { + config()->set('mail.default', 'smtp'); + + expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeTrue(); +}); + +it('needs at least one mailbox to send from', function () { + expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeFalse(); + + Mailbox::factory()->create(); + + expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeTrue(); +}); +``` + +`tests/Feature/Readiness/OnboardingChecksTest.php`: + +```php +firstWhere('key', $key); +} + +it('needs at least one datacenter', function () { + // ValidateHostInput ist der erste Schritt der Host-Kette und bricht ohne + // Rechenzentrum ab — bevor überhaupt eine SSH-Verbindung versucht wird. + expect(onboardingCheck('onboarding.datacenter')->satisfied)->toBeFalse(); + + Datacenter::factory()->create(); + + expect(onboardingCheck('onboarding.datacenter')->satisfied)->toBeTrue(); +}); + +it('needs an ssh private key', function () { + expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeFalse(); + + app(SecretVault::class)->put( + 'ssh.private_key', + '-----BEGIN OPENSSH PRIVATE KEY-----', + Operator::factory()->create(), + OperatingMode::Live, + ); + + expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeTrue(); +}); + +it('needs all three wireguard values, not just some', function () { + // Zwei von drei ist kein halber Tunnel, sondern gar keiner. + Settings::set('wg.hub_pubkey', 'abc='); + Settings::set('wg.endpoint', '198.51.45.9:51820'); + + expect(onboardingCheck('onboarding.wg_hub')->satisfied)->toBeFalse(); + + Settings::set('wg.subnet', '10.66.0.0/24'); + + expect(onboardingCheck('onboarding.wg_hub')->satisfied)->toBeTrue(); +}); +``` + +Die `Settings`-Schlüssel für WireGuard bitte an `ProvisioningSettings` ablesen, +bevor die Prüfung geschrieben wird — dort steht, wie sie tatsächlich heißen. + +- [ ] **Step 2: Tests laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=ChecksTest` +Expected: FAIL — die drei Gruppenklassen existieren nicht + +- [ ] **Step 3: Die drei Gruppen schreiben** + +Jede Klasse nach dem Vorbild von `BillingChecks`: eine `public const GROUP`, eine +`public static function all(): array`, ein `Check` je Zeile der Tabelle oben, mit +`label` und `breaks` aus `lang/de/readiness.php`. Beispiel für die entscheidende +Zeile in `ProvisioningChecks`: + +```php +new Check( + key: 'provisioning.usable_host', + group: self::GROUP, + severity: Check::SEVERITY_BLOCKING, + label: __('readiness.provisioning.usable_host'), + breaks: __('readiness.provisioning.usable_host_breaks'), + tab: 'hosts', + // `active` ALLEIN reicht nicht. Ein Host ohne lesbaren Token ist für + // ReserveResources trotzdem wählbar, und der Auftrag stirbt dann in + // CloneVirtualMachine — nach der Zahlung. + satisfied: Host::query() + ->where('status', 'active') + ->whereNotNull('api_token_ref') + ->exists(), +), +``` + +Danach `Readiness::all()` erweitern: + +```php +public static function all(): array +{ + return [ + ...BillingChecks::all(), + ...OnboardingChecks::all(), + ...ProvisioningChecks::all(), + ...DeliveryChecks::all(), + ]; +} +``` + +- [ ] **Step 4: Tests laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=ChecksTest` +Expected: PASS + +- [ ] **Step 5: Gegen den echten Server halten** + +Nicht „grün heißt fertig" — die Prüfungen müssen auf dieser Installation das +melden, was am 2026-07-30 gemessen wurde. + +```bash +docker compose exec -T app php artisan tinker --execute=' +foreach(\App\Support\Readiness::all() as $c) + printf(" %-34s %-8s %s\n", $c->key, $c->severity, $c->satisfied ? "OK" : "FEHLT");' +``` + +Expected: `provisioning.usable_host` FEHLT (die zwei Phantom-Hosts), `delivery.mailer_not_log` FEHLT (`mail.default=log`), `delivery.inbound_password` FEHLT, Firmendaten OK, Rechenzentrum OK. + +- [ ] **Step 6: Committen** + +```bash +git add -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/ +git commit -F - -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/ +``` + +Nachricht: + +``` +Check the machines, the DNS and the post as well + +Co-Authored-By: Claude Opus 5 +``` + +--- + +### Task 9: Die drei Prüfungen, die wirklich nachsehen + +**Files:** +- Create: `app/Services/Dns/DnsTokenCheck.php` +- Create: `app/Services/Vpn/WireguardEndpointCheck.php` +- Create: `app/Services/Proxmox/VmTemplateCheck.php` +- Test: `tests/Feature/Readiness/ActiveChecksTest.php` + +**Interfaces:** +- Consumes: `HetznerDnsClient`, `ProvisioningSettings`, `ProxmoxClient::vmExists()`, `PlanVersion`. +- Produces: je eine `final class` mit `run(): array` in **genau der Form, die + `StripeCheck` schon liefert** — `['ok' => bool, 'reason' => string]`, bei + Bedarf weitere Schlüssel. Kein eigener Rückgabetyp daneben: die Konsole + behandelt heute schon ein solches Array, und ein zweites Format wäre die + zweite Wahrheit, die dieser Entwurf vermeidet. + +Diese drei sind der Unterschied zwischen „gesetzt" und „funktioniert". + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +run('10.10.90.185:51820')['ok'])->toBeFalse(); +}); + +it('rejects a documentation address', function () { + // RFC 5737 — Seed- und Beispieldaten landen erfahrungsgemäß in echten + // Installationen. + expect((new WireguardEndpointCheck)->run('203.0.113.11:51820')['ok'])->toBeFalse(); +}); + +it('rejects loopback', function () { + expect((new WireguardEndpointCheck)->run('127.0.0.1:51820')['ok'])->toBeFalse(); +}); + +it('accepts a public address', function () { + expect((new WireguardEndpointCheck)->run('198.51.45.9:51820')['ok'])->toBeTrue(); +}); + +it('accepts a hostname, which it cannot judge', function () { + // Ein Name kann öffentlich auflösen; hier zu raten wäre schlechter als + // durchzulassen — die Prüfung sagt, was sie weiß, nicht was sie vermutet. + expect((new WireguardEndpointCheck)->run('vpn.clupilot.cloud:51820')['ok'])->toBeTrue(); +}); + +it('rejects an endpoint without a port', function () { + expect((new WireguardEndpointCheck)->run('198.51.45.9')['ok'])->toBeFalse(); +}); + +it('names the offending address, so the page can show it', function () { + expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['reason'])->toBe('not_public'); +}); +``` + +Für `DnsTokenCheck` mit `Http::fake()`: ein Fake, der die Zone listet und +Schreibrecht bestätigt (`ok`), einer der die Zone nicht enthält (`nicht ok`), +und einer, der bei einem Schreibversuch 403 liefert (`nicht ok` — **genau der +Leserecht-Token aus dem Handoff**). + +Für `VmTemplateCheck` mit einem gefälschten `ProxmoxClient`: Vorlage vorhanden +(`ok`), Vorlage fehlt (`nicht ok`, mit der VMID im Text), kein aktiver Host +(`nicht ok`). + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=ActiveChecksTest` +Expected: FAIL — die Klassen existieren nicht + +- [ ] **Step 3: Die drei Prüfungen schreiben** + +`WireguardEndpointCheck` — kein Netzverkehr, nur eine Entscheidung über die +Adresse: + +```php +/** @return array */ +public function run(?string $endpoint = null): array +{ + $endpoint ??= ProvisioningSettings::wgEndpoint(); + + if (blank($endpoint)) { + return ['ok' => false, 'reason' => 'missing']; + } + + if (! str_contains($endpoint, ':')) { + return ['ok' => false, 'reason' => 'no_port']; + } + + $host = Str::beforeLast($endpoint, ':'); + + // Keine gültige IP heißt: ein Name. Der kann öffentlich auflösen, und hier + // zu raten wäre schlechter als durchzulassen — die Prüfung sagt, was sie + // weiß, nicht was sie vermutet. + if (! filter_var($host, FILTER_VALIDATE_IP)) { + return ['ok' => true, 'reason' => 'hostname', 'address' => $host]; + } + + // NO_PRIV_RANGE deckt RFC 1918 ab, NO_RES_RANGE unter anderem Loopback und + // 203.0.113.0/24 — genau die Adressen, die am 2026-07-30 an zwei Hosts + // standen. + $public = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); + + return $public === false + ? ['ok' => false, 'reason' => 'not_public', 'address' => $host] + : ['ok' => true, 'reason' => 'public', 'address' => $host]; +} +``` + +`DnsTokenCheck` — die Zone zu **listen** beweist nichts, weil ein +Leserecht-Token das anstandslos tut. Also wird geschrieben und sofort +zurückgenommen: + +```php +/** @return array */ +public function run(?string $candidate = null): array +{ + $token = $candidate ?: app(SecretVault::class)->get('dns.token'); + $zone = ProvisioningSettings::dnsZone(); + + if (blank($token) || blank($zone)) { + return ['ok' => false, 'reason' => 'missing']; + } + + try { + $zones = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15) + ->get('https://dns.hetzner.com/api/v1/zones'); + } catch (Throwable) { + return ['ok' => false, 'reason' => 'unreachable']; + } + + if ($zones->status() === 401 || $zones->status() === 403) { + return ['ok' => false, 'reason' => 'rejected']; + } + + $zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null; + + if ($zoneId === null) { + return ['ok' => false, 'reason' => 'zone_not_found', 'zone' => $zone]; + } + + // Der eigentliche Punkt. Ein Leserecht-Token kommt bis hierher und sieht in + // der Konsole aus wie ein funktionierender; er scheitert erst, wenn eine + // Kunden-VM ihren A-Record braucht — nach der Zahlung. + $probe = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15) + ->post('https://dns.hetzner.com/api/v1/records', [ + 'zone_id' => $zoneId, + 'type' => 'TXT', + 'name' => '_clupilot-write-probe-'.Str::lower(Str::random(12)), + 'value' => 'clupilot readiness probe', + 'ttl' => 60, + ]); + + if (! $probe->successful()) { + return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()]; + } + + // Immer aufräumen, auch wenn das Löschen scheitert: ein liegengebliebener + // TXT-Eintrag mit diesem Namen ist harmlos und wird im Ergebnis benannt, + // damit ihn jemand von Hand entfernen kann. + $recordId = $probe->json('record.id'); + $removed = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15) + ->delete('https://dns.hetzner.com/api/v1/records/'.$recordId) + ->successful(); + + return ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed]; +} +``` + +`VmTemplateCheck` stellt dieselbe Frage wie `VerifyVmTemplate` — jede +veröffentlichte Paketversion zeigt auf eine `template_vmid`, die auf einem Knoten +liegen muss —, nur über **alle aktiven Hosts** statt über einen, und **vor** dem +Kauf statt danach: + +```php +/** @return array */ +public function run(): array +{ + $required = PlanVersion::query() + ->whereNotNull('published_at') + ->whereNotNull('template_vmid') + ->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now())) + ->pluck('template_vmid')->map(fn ($v) => (int) $v)->unique()->values()->all(); + + // Ein Katalog ohne veröffentlichte Version verlangt keine Vorlage. Derselbe + // Fall, den VerifyVmTemplate ausdrücklich durchlässt. + if ($required === []) { + return ['ok' => true, 'reason' => 'nothing_published']; + } + + $hosts = Host::query()->where('status', 'active')->whereNotNull('api_token_ref')->get(); + + if ($hosts->isEmpty()) { + return ['ok' => false, 'reason' => 'no_usable_host']; + } + + $missing = []; + + foreach ($required as $vmid) { + $found = false; + + foreach ($hosts as $host) { + try { + if ($this->pve->forHost($host)->vmExists($host->node ?? 'pve', $vmid)) { + $found = true; + break; + } + } catch (Throwable) { + // Ein Host, der gerade nicht antwortet, ist kein Beweis für eine + // fehlende Vorlage. Weitersuchen. + continue; + } + } + + if (! $found) { + $missing[] = $vmid; + } + } + + return $missing === [] + ? ['ok' => true, 'reason' => 'present'] + : ['ok' => false, 'reason' => 'template_missing', 'vmids' => $missing]; +} +``` + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=ActiveChecksTest` +Expected: PASS + +- [ ] **Step 5: Gegen den echten Server halten** + +```bash +docker compose exec -T app php artisan tinker --execute=' +$r = app(\App\Services\Vpn\WireguardEndpointCheck::class)->run(); +echo "WG: ".($r["ok"] ? "OK" : "FEHLT")." — ".json_encode($r)."\n"; +$d = app(\App\Services\Dns\DnsTokenCheck::class)->run(); +echo "DNS: ".($d["ok"] ? "OK" : "FEHLT")." — ".json_encode($d)."\n";' +``` + +Expected: WG meldet die private Adresse. DNS sagt, ob der hinterlegte Token die +Zone `clupilot.cloud` wirklich beschreiben darf — die Frage, die seit dem +Handoff offen ist. + +- [ ] **Step 6: Committen** + +```bash +git add -- app/Services/Dns/DnsTokenCheck.php app/Services/Vpn/WireguardEndpointCheck.php app/Services/Proxmox/VmTemplateCheck.php lang/de/readiness.php tests/Feature/Readiness/ActiveChecksTest.php +git commit -F - -- app/Services/Dns/DnsTokenCheck.php app/Services/Vpn/WireguardEndpointCheck.php app/Services/Proxmox/VmTemplateCheck.php lang/de/readiness.php tests/Feature/Readiness/ActiveChecksTest.php +``` + +Nachricht: + +``` +Tell a read-only token apart from one that can write + +Co-Authored-By: Claude Opus 5 +``` + +--- + +### Task 10: Herzschläge — beweisen, dass die Worker leben + +**Files:** +- Create: `app/Provisioning/Jobs/RecordProvisioningHeartbeat.php` +- Create: `app/Support/Readiness/OperationChecks.php` +- Modify: `routes/console.php` +- Modify: `app/Support/Readiness.php` +- Test: `tests/Feature/Readiness/HeartbeatTest.php` + +**Interfaces:** +- Consumes: `Settings::set`/`get`, `OperationChecks` folgt dem Muster aus Task 7. +- Produces: `Settings`-Schlüssel `heartbeat.scheduler` und `heartbeat.queue_provisioning`, je ein ISO-8601-Zeitstempel. Prüfungen `operation.scheduler` und `operation.queue_provisioning`. + +Beide in `Settings`, **nicht** im Zwischenspeicher: ein Herzschlag, der mit dem +Redis-Neustart verschwindet, meldet einen Ausfall, den es nicht gab. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +firstWhere('key', $key); +} + +/** + * „Ohne Worker passiert schlicht nichts, ohne Fehlermeldung." Genau dieser + * Zustand war in der Konsole bisher nicht von „alles ruhig" zu unterscheiden. + * + * Zwei getrennte Herzschläge, weil Zeitplaner und Warteschlangen-Worker + * getrennt ausfallen: der Zeitplaner kann laufen und Aufträge einstellen, die + * niemand abholt. + */ +it('reports a missing heartbeat as not satisfied', function () { + expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse(); +}); + +it('accepts a fresh heartbeat', function () { + Settings::set('heartbeat.scheduler', now()->toIso8601String()); + + expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue(); +}); + +it('rejects a heartbeat older than five minutes', function () { + Settings::set('heartbeat.scheduler', now()->subMinutes(6)->toIso8601String()); + + expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse(); +}); + +it('proves the worker, not just the scheduler', function () { + // Der Zeitplaner läuft, der Bereitstellungs-Worker nicht: der eine + // Herzschlag ist frisch, der andere nicht. + Settings::set('heartbeat.scheduler', now()->toIso8601String()); + + expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue(); + expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeFalse(); +}); + +it('records the worker heartbeat when the job runs', function () { + (new RecordProvisioningHeartbeat)->handle(); + + expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeTrue(); +}); +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=HeartbeatTest` +Expected: FAIL — Job und Gruppe existieren nicht + +- [ ] **Step 3: Job, Gruppe und Zeitplan schreiben** + +`app/Provisioning/Jobs/RecordProvisioningHeartbeat.php`: + +```php +toIso8601String()); + } +} +``` + +In `routes/console.php`, bei den übrigen Zeitplänen: + +```php +// Zwei Lebenszeichen für die Bereitschaftsseite. In Settings und nicht im +// Zwischenspeicher: ein Herzschlag, der mit dem Redis-Neustart verschwindet, +// meldete einen Ausfall, den es nicht gab. +Schedule::call(fn () => Settings::set('heartbeat.scheduler', now()->toIso8601String())) + ->everyMinute() + ->name('heartbeat-scheduler'); + +Schedule::job(new RecordProvisioningHeartbeat) + ->everyMinute() + ->name('heartbeat-provisioning'); +``` + +`OperationChecks::all()` liest beide Zeitstempel und vergleicht gegen +`now()->subMinutes(5)`; ein fehlender Wert ist „nicht erfüllt". + +Und `Readiness::all()` bekommt die fünfte Gruppe — sonst laufen die Prüfungen +ins Leere: + +```php +public static function all(): array +{ + return [ + ...BillingChecks::all(), + ...OnboardingChecks::all(), + ...ProvisioningChecks::all(), + ...DeliveryChecks::all(), + ...OperationChecks::all(), + ]; +} +``` + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=HeartbeatTest` +Expected: PASS, 5 Tests + +- [ ] **Step 5: Am laufenden Server bestätigen** + +Zwei Minuten warten, dann: + +```bash +docker compose exec -T app php artisan tinker --execute=' +echo "Zeitplaner: ".\App\Support\Settings::get("heartbeat.scheduler", "—")."\n"; +echo "Worker: ".\App\Support\Settings::get("heartbeat.queue_provisioning", "—")."\n";' +``` + +Expected: beide mit einem Zeitstempel der letzten Minuten. Bleibt der zweite +leer, während der erste läuft, ist das kein Testfehler — dann steht der +`queue-provisioning`-Worker, und die Prüfung hat beim ersten Versuch getan, +wofür sie gebaut wurde. + +- [ ] **Step 6: Committen** + +```bash +git add -- app/Provisioning/Jobs/RecordProvisioningHeartbeat.php app/Support/Readiness/OperationChecks.php app/Support/Readiness.php routes/console.php lang/de/readiness.php tests/Feature/Readiness/HeartbeatTest.php +git commit -F - -- app/Provisioning/Jobs/RecordProvisioningHeartbeat.php app/Support/Readiness/OperationChecks.php app/Support/Readiness.php routes/console.php lang/de/readiness.php tests/Feature/Readiness/HeartbeatTest.php +``` + +Nachricht: + +``` +Notice when nobody is picking up the queue + +Co-Authored-By: Claude Opus 5 +``` + +--- + +### Task 11: Der Umschalter in der Konsole + +**Files:** +- Create: `app/Livewire/Admin/ConfirmSwitchMode.php` +- Create: `resources/views/livewire/admin/confirm-switch-mode.blade.php` +- Modify: `app/Livewire/Admin/Integrations.php` +- Modify: `resources/views/livewire/admin/integrations.blade.php` +- Modify: `resources/views/layouts/admin.blade.php` (Plakette) +- Test: `tests/Feature/SwitchOperatingModeTest.php` + +**Interfaces:** +- Consumes: `OperatingMode::set()`, `ConfirmsPassword` (bestehendes Trait), das + bestehende `openModal`-Muster. +- Produces: Livewire-Ereignis `mode-switch-confirmed` mit Argument `mode`. + +Muster **wortgleich** zu `ConfirmSaveSecret`: das Modal mutiert nichts selbst, +sondern löst ein Ereignis aus, das die Seitenkomponente per `#[On(...)]` +auffängt — so bleibt die Berechtigungsprüfung an der einen Stelle, an der sie +schon steht. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +role(), nicht über eine ->owner()-Abkürzung. Das Testpasswort ist +// `passwort-fuer-tests` — OperatorFactory::definition(). +it('refuses to switch without the secrets capability', function () { + $admin = Operator::factory()->role('Admin')->create(); // hosts.manage, nicht secrets.manage + + Livewire::actingAs($admin) + ->test(Integrations::class) + ->call('switchMode', 'test') + ->assertForbidden(); + + expect(OperatingMode::current())->toBe(OperatingMode::Live); +}); + +it('switches when the owner has confirmed their password', function () { + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner) + ->test(Integrations::class) + ->call('confirmPassword', 'passwort-fuer-tests') + ->call('switchMode', 'test'); + + expect(OperatingMode::current())->toBe(OperatingMode::Test); +}); + +it('refuses an unknown mode', function () { + // Ein String aus dem Browser. `staging` gibt es nicht, und from() würde + // hier eine Ausnahme werfen statt still nichts zu tun. + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner) + ->test(Integrations::class) + ->call('confirmPassword', 'passwort-fuer-tests') + ->call('switchMode', 'staging'); + + expect(OperatingMode::current())->toBe(OperatingMode::Live); +}); +``` + +Dazu ein Blade-Wächtertest, der R23 für die neue Datei mitnimmt — er existiert +bereits als `tests/Feature/ConfirmInModalTest.php` und läuft über das ganze +Repo, also **muss** hier nichts ergänzt werden. Er wird in Step 4 mitgeprüft. + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=SwitchOperatingModeTest` +Expected: FAIL — `switchMode` existiert nicht + +- [ ] **Step 3: Umschalter und Modal schreiben** + +In `Integrations`: + +```php +/** + * Den Betriebsmodus umlegen. + * + * Hinter derselben Sperre wie der Tresor — `secrets.manage` plus bestätigtes + * Passwort — weil der Wechsel auf Live der Moment ist, ab dem echtes Geld + * fließt. Das ist keine Umschaltfläche zum Danebenklicken. + */ +#[On('mode-switch-confirmed')] +public function switchMode(string $mode): void +{ + $this->guardSecrets(); + + // Ein String aus dem Browser. tryFrom, nicht from. + $target = OperatingMode::tryFrom($mode); + + if ($target === null) { + return; + } + + OperatingMode::set($target); + + $this->dispatch('notify', message: __('integrations.mode_switched', [ + 'mode' => __('readiness.mode.'.$target->value), + ])); +} +``` + +Das Modal `ConfirmSwitchMode` bekommt den Zielmodus als Argument, zeigt einen +Satz, der beim Wechsel auf Live ausdrücklich sagt, dass ab dann echtes Geld +bewegt wird, und löst beim Bestätigen `mode-switch-confirmed` aus. + +In `layouts/admin.blade.php` die Plakette, solange der Modus `test` ist — +Icon nach R18 `size-4`, einzeilig: + +```blade +@if (\App\Support\OperatingMode::current()->isTest()) + + + {{ __('readiness.mode.test') }} + +@endif +``` + +- [ ] **Step 4: Tests laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=SwitchOperatingModeTest` +Expected: PASS, 3 Tests + +Run: `docker compose exec -T app php artisan test --filter="ConfirmInModal|IconLayout|ModalHeight"` +Expected: PASS — die Regelwächter R23, R18 und R24 nehmen die neuen Dateien mit + +- [ ] **Step 5: Committen** + +```bash +git add -- app/Livewire/Admin/ConfirmSwitchMode.php app/Livewire/Admin/Integrations.php resources/views/livewire/admin/confirm-switch-mode.blade.php resources/views/livewire/admin/integrations.blade.php resources/views/layouts/admin.blade.php lang/de/integrations.php tests/Feature/SwitchOperatingModeTest.php +git commit -F - -- app/Livewire/Admin/ConfirmSwitchMode.php app/Livewire/Admin/Integrations.php resources/views/livewire/admin/confirm-switch-mode.blade.php resources/views/livewire/admin/integrations.blade.php resources/views/layouts/admin.blade.php lang/de/integrations.php tests/Feature/SwitchOperatingModeTest.php +``` + +Nachricht: + +``` +Put the switch where it governs, and say when it is thrown + +Co-Authored-By: Claude Opus 5 +``` + +--- + +### Task 12: Die Bereitschaftsseite — und der Wächter, der sie vollständig hält + +**Files:** +- Create: `app/Livewire/Admin/Readiness.php` +- Create: `resources/views/livewire/admin/readiness.blade.php` +- Modify: `routes/web.php` (Route `/admin/readiness`) +- Modify: `resources/views/layouts/admin.blade.php` (Navigationseintrag) +- Modify: `app/Livewire/Admin/Overview.php` und dessen Blade (Hinweis) +- Test: `tests/Feature/ReadinessPageTest.php` + +**Interfaces:** +- Consumes: `Readiness::byGroup()`, `Readiness::blocking()`, `Readiness::isReady()`. +- Produces: benannte Route `admin.readiness`. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +actingAs(Operator::factory()->role('Read-only')->create(), 'operator') + ->get(route('admin.readiness')) + ->assertForbidden(); +}); + +it('lists every check, satisfied or not', function () { + Livewire::actingAs(Operator::factory()->role('Owner')->create()) + ->test(ReadinessPage::class) + ->assertSee(__('readiness.billing.company_details')) + ->assertSee(__('readiness.provisioning.usable_host')); +}); + +it('says what breaks, not only that something is missing', function () { + Livewire::actingAs(Operator::factory()->role('Owner')->create()) + ->test(ReadinessPage::class) + ->assertSee(__('readiness.delivery.mailer_not_log_breaks')); +}); + +it('runs an active check on demand and shows its answer', function () { + // Die drei Prüfungen aus Task 9 sind nur dann etwas wert, wenn jemand sie + // auslösen kann. Sie laufen NICHT bei jedem Seitenaufruf mit: eine davon + // schreibt einen TXT-Eintrag in die echte Zone. + Livewire::actingAs(Operator::factory()->role('Owner')->create()) + ->test(ReadinessPage::class) + ->call('runCheck', 'provisioning.dns_token') + ->assertSet('results.provisioning.dns_token.ok', false); +}); + +it('refuses to run a check that is not on the list', function () { + // Ein Schlüssel aus dem Browser. Ohne diese Prüfung wäre das ein Weg, + // beliebige Klassen aufzurufen. + Livewire::actingAs(Operator::factory()->role('Owner')->create()) + ->test(ReadinessPage::class) + ->call('runCheck', 'etwas.erfundenes') + ->assertSet('results', []); +}); + +/** + * Der Wächter dieser Spec. + * + * Ein künftig hinzugefügtes Zugangsdatum darf nicht still an der Übersicht + * vorbeigehen. Genau das war der Befund vom 2026-07-30: eine grüne Testsuite + * bei einer Installation, die keinen Host durchinstallieren konnte, weil + * niemand die Liste geführt hat. + */ +it('covers every entry in the secret registry', function () { + $covered = collect(Readiness::all())->pluck('key')->implode(' '); + + foreach (array_keys(SecretVault::REGISTRY) as $secret) { + // `stripe.secret` → irgendein Prüfschlüssel, der `stripe_secret` enthält. + $needle = str_replace('.', '_', $secret); + + expect($covered)->toContain( + $needle, + "Der Tresor-Eintrag [{$secret}] taucht auf der Bereitschaftsseite nicht auf." + ); + } +}); +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=ReadinessPageTest` +Expected: FAIL — Route und Komponente existieren nicht + +- [ ] **Step 3: Seite, Route, Navigation und Hinweis schreiben** + +Komponente `App\Livewire\Admin\Readiness` mit `#[Layout('layouts.admin')]`, +`mount()` prüft `Gate::any(['hosts.manage', 'secrets.manage'])` — dieselbe +Sperre wie die Integrationsseite, weil beide Hälften hier zusammenkommen. + +Das Blade zeigt oben die eine Zeile, auf die es ankommt — *Bereit für +Testbetrieb* bzw. *Bereit für Livebetrieb* — und darunter die Gruppen. Jeder +Eintrag: Zustand, Beschriftung, der `breaks`-Satz, und ein Link auf +`route('admin.integrations', ['tab' => $check->tab])`. + +Icons nach R18 (`size-4` in der Liste). Zeiten der Herzschläge nach R19 durch +`->local()`. + +Die drei Prüfungen aus Task 9 bekommen einen Knopf — und laufen **nur** auf +Knopfdruck. `DnsTokenCheck` schreibt einen TXT-Eintrag in die echte Zone; das +bei jedem Seitenaufruf zu tun, wäre eine Prüfung, die selbst ein Risiko ist: + +```php +/** Prüfschlüssel → Klasse. Die Liste IST die Erlaubnis. */ +private const RUNNABLE = [ + 'provisioning.dns_token' => DnsTokenCheck::class, + 'onboarding.wg_hub' => WireguardEndpointCheck::class, + 'provisioning.vm_template' => VmTemplateCheck::class, + 'billing.stripe_secret' => StripeCheck::class, +]; + +/** @var array> */ +public array $results = []; + +public function runCheck(string $key): void +{ + $this->authorize('secrets.manage'); + + // Der Schlüssel kommt aus dem Browser. Ohne diese Zeile wäre das ein Weg, + // beliebige Klassen aufzurufen. + $class = self::RUNNABLE[$key] ?? null; + + if ($class === null) { + return; + } + + $this->results[$key] = app($class)->run(); +} +``` + +In `Overview` ein Hinweis, sobald `Readiness::blocking()` nicht leer ist, mit +der Anzahl und einem Link auf die Seite. + +- [ ] **Step 4: Tests laufen lassen, Erfolg bestätigen** + +Run: `docker compose exec -T app php artisan test --filter=ReadinessPageTest` +Expected: PASS, 6 Tests + +- [ ] **Step 5: Die ganze Suite laufen lassen** + +Run: `docker compose exec -T app php artisan test` +Expected: PASS. Bricht alles auf einmal zusammen, ist das fast immer eine Datei +der anderen Session mitten im Schreiben — einmal neu laufen lassen, bevor man +sucht. + +- [ ] **Step 6: `pint` nur auf die eigenen Pfade** + +```bash +docker compose exec -T app ./vendor/bin/pint app/Support/Readiness.php app/Support/Readiness/ app/Livewire/Admin/Readiness.php +``` + +**Nie `--dirty`** — das Repo ist unter der Standardvorgabe nicht durchgängig +formatiert und würde fremde Dateien umschreiben. + +- [ ] **Step 7: Committen** + +```bash +git add -- app/Livewire/Admin/Readiness.php resources/views/livewire/admin/readiness.blade.php routes/web.php resources/views/layouts/admin.blade.php app/Livewire/Admin/Overview.php resources/views/livewire/admin/overview.blade.php lang/de/readiness.php tests/Feature/ReadinessPageTest.php +git commit -F - -- app/Livewire/Admin/Readiness.php resources/views/livewire/admin/readiness.blade.php routes/web.php resources/views/layouts/admin.blade.php app/Livewire/Admin/Overview.php resources/views/livewire/admin/overview.blade.php lang/de/readiness.php tests/Feature/ReadinessPageTest.php +``` + +Nachricht: + +``` +Put every missing field on one page before a customer pays + +Co-Authored-By: Claude Opus 5 +``` + +--- + +## Abschluss + +- [ ] **`VERSION` anheben und taggen.** Ein Update auf dem Liveserver greift nur bei einem `v*`-Tag. + +```bash +git add -- VERSION +git commit -F - -- VERSION <<'EOF' +Release 1.4.0 + +Co-Authored-By: Claude Opus 5 +EOF +git tag v1.4.0 +``` + +- [ ] **Push mit Token** (`GIT_ACCESS_TOKEN` aus der `.env`; ein einfaches `git push` scheitert): + +```bash +git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" main --tags +``` + +- [ ] **Auf dem Liveserver die Bereitschaftsseite durchgehen**, bis oben *Bereit für Livebetrieb* steht. Das ist der eigentliche Abnahmetest dieses Vorhabens — nicht die grüne Suite. + +--- + +## Was danach offen bleibt + +- **Teil C** — Testdaten markieren, eigene Belegserie, Ausschluss aus dem Umsatz, Aufräumen der ganzen Kette inkl. VM und DNS. Beschlossen, eigener Entwurf. `EndInstanceService` nimmt Route und DNS-Eintrag weg, rührt die VM aber ausdrücklich nicht an; `ProxmoxClient::deleteVm()` existiert, der Aufrufer fehlt. +- **Die zwei Phantom-Hosts** (`pve-fsn-1`, `pve-hel-1`) aus dem Pool nehmen. Die Bereitschaftsseite macht sie sichtbar, entfernt sie aber nicht. +- **Blöcke A–D** aus dem Handoff: goldene Vorlage, `vmbr0`, Traefik als Systemdienst.