From 37684337923690c80925642d4ca28a446e1f0d2d Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 10:11:26 +0200 Subject: [PATCH 1/6] Design a test/live switch and a page that says what is missing Co-Authored-By: Claude Opus 5 --- ...bsmodus-und-betriebsbereitschaft-design.md | 382 ++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md diff --git a/docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md b/docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md new file mode 100644 index 0000000..5e82941 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md @@ -0,0 +1,382 @@ +# Spec — Betriebsmodus (Test/Live) und Betriebsbereitschaft + +**Datum:** 2026-07-30 +**Status:** entworfen, noch nicht umgesetzt +**Vorgänger-Kontext:** `docs/handoffs/2026-07-30-real-run-handoff.md` §4 + +--- + +## 1. Ziel + +Zwei Dinge, die zusammengehören: + +**A — Betriebsmodus.** Ein Schalter in der Konsole, `test` oder `live`. Jedes +Zugangsdatum im Tresor bekommt zwei Plätze; der aktive Modus entscheidet, +welcher gilt. Ist der Testplatz leer, gilt der Live-Platz — mit **einer** +Ausnahme, siehe §4. + +**B — Betriebsbereitschaft.** Eine Seite, die jedes Pflichtfeld dieser +Installation aufführt, seinen Zustand zeigt und sagt, **was kaputtgeht, wenn +es fehlt**. Der Zweck ist genau einer: den Liveserver vollständig befüllen zu +können, bevor der erste Host onboardet wird — statt es nach der Zahlung eines +Kunden zu erfahren. + +### Nicht Teil dieses Vorhabens + +- **Testdaten-Trennung** (eigene Belegserie, Markierung, Ausschluss aus dem + Umsatz, Aufräumen inkl. VM und DNS). Eigener Entwurf, hängt an diesem hier. + Beschlossen, aber getrennt — dort wird auf einem Host eine VM zerstört, und + das braucht eigene Sorgfalt. Siehe §9. +- Zwei Plätze für die **einfachen Einstellungen** (DNS-Zone, WireGuard-Endpunkt, + Traefik-Pfad). Das sind keine Zugangsdaten, und eine getrennte Testzone wurde + bewusst nicht gewählt. +- Blöcke A–D aus dem Handoff (goldene Vorlage, `vmbr0`, Traefik als Systemdienst). + +--- + +## 2. Ausgangsbefund — gemessen, nicht gelesen + +Alles hier am 2026-07-30 auf dem Entwicklungsserver nachgemessen. + +| Befund | Beleg | +|---|---| +| Der Tresor hat **einen** Platz je Zugangsdatum | `SecretVault::REGISTRY`, fünf Einträge, je ein `config`-Schlüssel | +| Der hinterlegte Stripe-Schlüssel ist ein **Testschlüssel** | Präfix `sk_test_` | +| `STRIPE_WEBHOOK_SECRET` liegt bewusst **nicht** im Tresor | `SecretVault` Kopfkommentar; `StripeWebhookController:23` liest `config()` | +| Es gibt **keine** Bereitschaftsprüfung | Einziges Vorbild: `CompanyProfile::missingForInvoicing()`, benutzt in `Finance` und `NewInvoice` | +| `mail.default` steht auf `log` | Zugangsdaten-Mail landet in der Datei, nicht beim Kunden | +| Zwei Hosts stehen `active` **ohne** `api_token_ref` | `public_ip` `203.0.113.11`/`.21` — RFC 5737, Dokumentationsbereich | +| `CLUPILOT_WG_ENDPOINT` war eine private Adresse | `10.10.90.185:51820` | +| Kein Worker meldet, dass er lebt | Weder Zeitplaner noch `queue-provisioning` hinterlassen eine Spur | + +Die letzten vier sind der Grund für diese Spec. Jeder davon ist in der Konsole +heute **unsichtbar**, und jeder bricht die Kette an einer anderen Stelle — +zwei davon erst nach der Zahlung. + +--- + +## 3. Der Schalter + +**Speicherort:** `App\Support\Settings`, Schlüssel `platform.mode`, Werte `test` +oder `live`. + +Nicht die `.env`. `Settings` ist zwischengespeichert und verwirft den +Zwischenspeicher beim Schreiben — ein Umlegen wirkt sofort, auch in den +Warteschlangen-Prozessen, die seit Stunden laufen. Eine `.env`-Variable bräuchte +einen Containerneustart; genau diese Falle hat am 2026-07-30 die korrigierte +DNS-Zone acht Stunden lang wirkungslos gelassen. + +**Gelesen über** `App\Support\OperatingMode::current(): Mode`, damit nirgends +sonst im Code ein String verglichen wird. `Mode` ist ein Enum mit `Test` und +`Live`. + +**Vorgabe:** `live`. Eine Installation, die nichts gespeichert hat, soll nicht +in einem Modus stehen, der stillschweigend andere Zugangsdaten benutzt. + +**Wer darf umlegen:** `secrets.manage` (Inhaber) **und** bestätigtes Passwort — +dieselbe Sperre wie der Tresor, weil der Wechsel auf Live der Moment ist, ab dem +echtes Geld fließt. Bestätigung im Modal nach **R23**, nie `wire:confirm`. + +**Wo:** Integrationsseite, Reiter `services`, **über** allen Zugangsdaten — er +regiert sie. Zusätzlich eine Plakette im Kopf der Konsole (`layouts.admin`), +solange der Modus `test` ist. „Bin ich im Test?" darf keine Frage sein, die man +durch Navigieren beantwortet. + +--- + +## 4. Zwei Plätze je Zugangsdatum + +Die kuratierte Liste bleibt, wie sie ist — der Tresor bleibt ein Tresor und kein +Schlüssel/Wert-Speicher. Nur die **Zeile** wird zweigeteilt: + +``` +stripe.secret:test stripe.secret:live +dns.token:test dns.token:live +monitoring.token:test monitoring.token:live +inbound_mail.password:test inbound_mail.password:live +ssh.private_key:test ssh.private_key:live +``` + +### Auflösungsregel + +`SecretVault::get($key)` löst in dieser Reihenfolge auf: + +1. Zeile des **aktiven Modus** +2. leer **und** der Eintrag erlaubt Rückfall → Zeile `live` +3. immer noch leer → `config()`, also `.env` — wie heute + +Schritt 2 ist die Regel des Inhabers: für DNS, Monitoring, Postfach und SSH gibt +es nur ein Konto, und zwei Felder mit zwingend gleichem Inhalt wären eine +Attrappe. + +Schritt 3 bleibt **einwertig und modusfrei**. Die `.env` ist der Weg für eine +Installation, in der noch gar nichts gespeichert ist; ein zweiter Satz +Variablen dort würde den Anfangszustand verdoppeln, den es nur einmal gibt. + +### Stripe ist ausgenommen — in beide Richtungen + +Ein Merkmal `'strict' => true` in `REGISTRY`, das **nur** `stripe.secret` trägt. + +Bedeutung: kein Rückfall, weder von Test auf Live noch umgekehrt. Fehlt der +Schlüssel des aktiven Modus, liefert `get()` `null`. + +Ohne diese Ausnahme führte die Rückfallregel dazu, dass ein Testkauf bei +fehlendem Testschlüssel still den **Live-Schlüssel** benutzt und **echtes Geld** +abbucht, während die Konsole „Testbetrieb aktiv" anzeigt. Das ist genau die +Klasse von stillem Fehler, gegen die der ganze erste Durchlauf gebaut ist. + +**Folge für die Aufrufer:** `HttpStripeClient::secret()` wirft eine sprechende +Ausnahme statt einen Aufruf ohne Token abzusetzen. Die Kasse fragt vorher die +Bereitschaftsprüfung und weist die Bestellung mit lesbarem Text ab (§6). + +### Der Webhook-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. + +Zwei Variablen, ausgewählt nach Modus: + +| Modus | Variable | +|---|---| +| `live` | `STRIPE_WEBHOOK_SECRET` | +| `test` | `STRIPE_WEBHOOK_SECRET_TEST` | + +**Der Sonderfall ist bedacht.** Die Auswahl liest den Modus aus der Datenbank — +also aus genau der Quelle, die hier eigentlich vermieden werden sollte. Ist sie +nicht erreichbar, liefert `Settings::get()` seinen Vorgabewert `live` und +schreibt eine Warnung. Die Auswahl fällt damit auf den **Live**-Schlüssel, und +das ist die sichere Richtung: ein echtes Zahlungsereignis wird weiter korrekt +geprüft, ein Testereignis scheitert laut. Der umgekehrte Vorgabewert wäre der +gefährliche. + +### Migration — sie rät nicht + +- Jede bestehende Tresor-Zeile wandert auf `:live`. +- **Ausnahme `stripe.secret`:** einsortiert am Präfix. `sk_test_` oder + `rk_test_` → `:test`, alles andere → `:live`. +- **Der Anfangsmodus folgt daraus**, statt aus der Vorgabe: landet der + vorhandene Stripe-Schlüssel im Testplatz, war die Installation erkennbar im + Testbetrieb, und `platform.mode` wird auf `test` gesetzt. Ist gar kein + Stripe-Schlüssel da, bleibt es bei `live`. + +Auf dem Entwicklungsserver heißt das: der laufende Aufbau bleibt heil, ohne dass +jemand von Hand nachsortiert. + +--- + +## 5. Betriebsbereitschaft + +`App\Support\Readiness` — nach dem Muster von `CompanyProfile::missingForInvoicing()`, +nur über alle Bereiche und mit mehr als einem Feldnamen je Eintrag. + +Jede Prüfung liefert: Schlüssel, Beschriftung, **Gruppe** (was sie blockiert), +Schweregrad (`blocking` oder `warning`), einen Satz dazu, was kaputtgeht, und +den Reiter, der sie behebt. + +Gruppiert nach dem, was sie blockiert — **nicht** danach, wo der Wert +gespeichert ist. Ein Betreiber, der eine Installation befüllt, denkt in „was +kann ich noch nicht", nicht in „liegt das im Tresor oder in den Einstellungen". + +### 5.1 Abrechnung + +| Prüfung | Grad | Bricht | +|---|---|---| +| Stripe-Schlüssel des aktiven Modus | blocking | Kein Auftrag entsteht | +| Webhook-Schlüssel des aktiven Modus | blocking | Zahlung wird nie verbucht | +| Firmendaten (`missingForInvoicing()` aufrufen, nicht verdoppeln) | blocking | `IssueInvoice` verweigert, Kunde bekommt keinen Beleg | +| Rechnungsserie je Belegart vorhanden | blocking | Beleg kann keine Nummer ziehen | +| Jede veröffentlichte Paketversion hat beide Stripe-Preise | blocking | Geprüfter EU-Firmenkunde kann nicht bestellen | + +Der **Steuersatz** ist bewusst keine Prüfung: `CompanyProfile::taxRate()` hat +einen `.env`-Rückfall und ist nie leer. Die **Einrichtungsgebühr** ebenso nicht: +`0` ist ein gültiger Zustand, bei dem der Satz von der Preisseite verschwindet. + +### 5.2 Host-Onboarding + +| Prüfung | Grad | Bricht | +|---|---|---| +| `ssh.private_key` | blocking | `EstablishSshTrust`, phpseclib-Parserfehler | +| `SECRETS_KEY` | blocking | Jeder Proxmox-Token unlesbar, keine Bereitstellung | +| `VPN_CONFIG_KEY` | blocking | WireGuard-Konfiguration nicht lesbar | +| `CLUPILOT_WG_HUB_PUBKEY`, `_ENDPOINT`, `_SUBNET` | blocking | Onboarding scheitert am Handshake | +| Mindestens ein Rechenzentrum | blocking | `ValidateHostInput` kommt nicht durch | + +### 5.3 Bereitstellung + +| Prüfung | Grad | Bricht | +|---|---|---| +| `dns.token` | blocking | A-Record entsteht nicht, Kunde hat keine Adresse | +| DNS-Zone gesetzt | blocking | Name wird in der falschen Zone gesucht | +| Traefik-Pfad, dnsmasq-Verzeichnis | warning | Vorgabe greift, aber ungeprüft | +| **Mindestens ein aktiver Host mit lesbarem `api_token_ref`** | blocking | Auftrag reserviert einen Host, den es nicht gibt — **nach** der Zahlung | +| Jede veröffentlichte Version zeigt auf eine Vorlage, die auf mindestens einem aktiven Host existiert | blocking | `CloneVirtualMachine` stirbt nach der Zahlung | +| `monitoring.token` | warning | Überwachung fehlt, Bereitstellung läuft | + +Die vierte Zeile fängt genau die zwei Phantom-Hosts aus §2: `active`, aber ohne +Token. Sie sind heute in der Konsole von einem echten Host nicht zu +unterscheiden. + +### 5.4 Zustellung + +| Prüfung | Grad | Bricht | +|---|---|---| +| Mailversand geht **nicht** an `log` | blocking | VM läuft, Kunde erfährt nie davon | +| Mindestens ein Postfach | blocking | Kein Absender | +| Mail-Vorlagen vorhanden | warning | Fallback-Text statt gestalteter Mail | +| `inbound_mail.password` | warning | Nur eingehende Post betroffen | + +### 5.5 Betrieb — Herzschläge + +Zwei Prüfungen, die es heute nirgends gibt und die neu entstehen müssen: + +- **Zeitplaner:** eine Aufgabe je Minute schreibt `Settings`-Schlüssel + `heartbeat.scheduler` mit Zeitstempel. +- **Bereitstellungs-Worker:** der Zeitplaner stellt einen winzigen Auftrag in + die `provisioning`-Warteschlange, der `Settings`-Schlüssel + `heartbeat.queue_provisioning` schreibt. Das beweist den **Worker**, nicht nur + den Zeitplaner — die beiden fallen getrennt aus. + +Beide in `Settings`, nicht im Zwischenspeicher: ein Herzschlag, der mit dem +Redis-Neustart verschwindet, meldet einen Ausfall, den es nicht gab. + +Die Seite meldet einen Herzschlag als veraltet, wenn er älter als fünf Minuten +ist. Damit wird „ohne Worker passiert schlicht nichts, ohne Fehlermeldung" +sichtbar. + +### 5.6 Prüfungen mit Knopf + +Drei Einträge sind keine Feldabfragen, sondern echte Prüfungen — nach dem +Vorbild von `StripeCheck`, das schon existiert und in `REGISTRY` als `check` +eingetragen ist: + +- **`DnsTokenCheck`** — fragt die Hetzner-API, ob der Token die konfigurierte + Zone sieht **und beschreiben darf**. Ein Leserecht-Token sieht in der Konsole + identisch aus wie ein Schreibrecht-Token; das ist der Unterschied zwischen + „gesetzt" und „funktioniert". +- **`WireguardEndpointCheck`** — ein UDP-Port lässt sich von außen nicht sauber + anklopfen, aber ob dort eine private Adresse (RFC 1918) oder eine + Dokumentationsadresse (RFC 5737) steht, ist entscheidbar. Fängt + `10.10.90.185:51820`. +- **`VmTemplateCheck`** — dieselbe Frage, die `VerifyVmTemplate` stellt, nur vor + dem Kauf statt danach. + +### 5.7 Darstellung + +Eigene Seite `App\Livewire\Admin\Readiness`, Route `/admin/readiness`, in der +Navigation. Auf der Übersicht ein Hinweis, sobald etwas Blockierendes offen ist. + +Oben die eine Zeile, auf die es ankommt: **Bereit für Testbetrieb** bzw. +**Bereit für Livebetrieb** — welche Schlüssel Pflicht sind, hängt am Modus. + +Jeder Eintrag verlinkt auf den Reiter, der ihn behebt. Icons nach **R18** +(`size-4` in Tabellen, einzeilig). + +### 5.8 Die Seite ist kein Tor + +Sie berichtet. Die harten Sperren bleiben dort, wo sie hingehören und heute +schon stehen: + +- `IssueInvoice` verweigert bei unvollständigen Firmendaten +- `VerifyVmTemplate` verweigert einen Host ohne Vorlage + +Dazu kommt **genau eine neue**: die Kasse nimmt keine Bestellung an, wenn dem +aktiven Modus der Stripe-Schlüssel fehlt. + +Eine Bereitschaftsseite, die selbst sperrt, wäre eine zweite Wahrheit neben den +Prüfungen im Ablauf — und zwei Quellen für eine Frage sind der Weg, auf dem sie +auseinanderlaufen. + +--- + +## 6. Betroffene Stellen + +| Datei | Änderung | +|---|---| +| `app/Support/OperatingMode.php` | **neu** — Enum + `current()` | +| `app/Support/Readiness.php` | **neu** — die Prüfungsliste | +| `app/Services/Secrets/SecretVault.php` | Zeilenschlüssel je Modus, `strict`-Merkmal, Auflösungsregel | +| `app/Services/Stripe/HttpStripeClient.php` | `secret()` wirft bei fehlendem Schlüssel des aktiven Modus | +| `app/Http/Controllers/StripeWebhookController.php` | Webhook-Schlüssel nach Modus | +| `app/Livewire/Admin/Integrations.php` | Umschalter, zwei Felder je Eintrag | +| `app/Livewire/Admin/Readiness.php` | **neu** | +| `app/Livewire/Admin/ConfirmSwitchMode.php` | **neu** — Bestätigung nach R23 | +| `app/Livewire/Admin/Overview.php` | Hinweis bei blockierenden Lücken | +| `app/Services/Dns/DnsTokenCheck.php` | **neu** | +| `app/Services/Vpn/WireguardEndpointCheck.php` | **neu** | +| `app/Services/Proxmox/VmTemplateCheck.php` | **neu** | +| `routes/`, `layouts.admin` | Route, Navigationseintrag, Modus-Plakette | +| Migration | Zeilenschlüssel umstellen, Stripe am Präfix einsortieren, Anfangsmodus setzen | +| Zeitplaner | zwei Herzschläge | + +--- + +## 7. Tests + +Nach dem Muster, das dieses Repo für seine Regeln benutzt (`IconLayoutTest`, +`ConfirmInModalTest`, `DisplayTimezoneTest`): die Regel wird **per Test +erzwungen**, nicht per Merksatz. + +1. **Je Prüfung ein Test in beide Richtungen** — meldet fehlend, wenn es fehlt; + meldet erfüllt, wenn es da ist. +2. **Jeder `REGISTRY`-Eintrag muss auf der Bereitschaftsseite auftauchen.** + Damit kann ein künftig hinzugefügtes Zugangsdatum nicht still an der + Übersicht vorbeigehen. +3. **Rückfall Test → Live** greift für die nicht-strikten Einträge. +4. **Stripe fällt nicht zurück**, in beide Richtungen geprüft — der wichtigste + Test dieser Spec, weil sein Ausfall echtes Geld kostet. +5. **Die Kasse weist ab**, wenn dem aktiven Modus der Stripe-Schlüssel fehlt. +6. **Migration:** ein `sk_test_` landet im Testplatz und setzt den Modus auf + `test`; ein `sk_live_` landet im Live-Platz und lässt `live` stehen. +7. **Webhook-Auswahl:** bei nicht erreichbaren Einstellungen wird der + Live-Schlüssel benutzt, nicht der Test-Schlüssel. +8. **Kein `wire:confirm`** im neuen Modal (R23 gilt schon repoweit). + +--- + +## 8. Reihenfolge + +1. `OperatingMode` + Migration + `SecretVault`-Auflösung (Kern, ohne Oberfläche) +2. Stripe-Ausnahme + Kassensperre + Webhook-Auswahl +3. `Readiness` mit den reinen Feldprüfungen +4. Die drei Prüfungen mit Knopf +5. Herzschläge +6. Oberfläche: Umschalter, Bereitschaftsseite, Plakette, Übersichts-Hinweis + +Schritt 1–2 ist der Teil, der falsch sein kann und Geld kostet. Schritt 3–6 ist +Fleißarbeit. + +--- + +## 9. Risiken + +- **Der Rückfall ist bequem und gefährlich.** Für vier von fünf Einträgen + richtig, für Stripe tödlich. Die `strict`-Ausnahme ist die einzige Zeile + dieser Spec, deren Ausfall echtes Geld kostet — sie gehört in den Test, nicht + in einen Kommentar. +- **Der Modus in der Datenbank** ist eine neue Abhängigkeit für die + Webhook-Prüfung, die bisher bewusst keine hatte. Entschärft durch den + Vorgabewert `live` bei Nichterreichbarkeit (§4), aber es bleibt eine + Abhängigkeit, die vorher nicht da war. +- **Die Bereitschaftsseite kann Vollständigkeit vortäuschen.** Sie prüft, was + jemand aufgeschrieben hat. Was niemand aufschreibt, bleibt unsichtbar — und + genau das war der Befund vom 2026-07-30: eine grüne Testsuite bei einer + Installation, die keinen Host durchinstallieren konnte. Deshalb Test 2, der + wenigstens für Zugangsdaten erzwingt, dass nichts vergessen wird. + +--- + +## 10. Folgepunkte + +- **Testdaten-Trennung** (Teil C, beschlossen): Markierung an Auftrag, Vertrag, + Beleg, Instanz und Kunde; eigene Rechnungsserie je Belegart für den + Testbetrieb, damit die lückenlose `RE`-Serie sauber bleibt und Testbelege + löschbar sind; Ausschluss aus Umsatz, Dashboard und Finanzansicht; ein + Aufräumen, das die ganze Kette entfernt — **einschließlich der VMs auf dem + Host und ihrer DNS-Einträge**. `EndInstanceService` nimmt Route und 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. +- Idempotenz-Schlüssel im Stripe-Abgleich deckt die Metadaten nicht ab — + eigener Folgepunkt, am 2026-07-30 als Aufgabe abgelegt. From fe4a74773e7cc5e0425ab2d38652ef305fd18045 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 10:23:11 +0200 Subject: [PATCH 2/6] 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. From a84251260cb23198ecd2ae93af55f6e4b91f48b0 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 13:12:29 +0200 Subject: [PATCH 3/6] Read the mailbox password where it actually is, and let the console test it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inbox kept saying no mailbox was set up while the password sat right there on the settings page. isConfigured() read config('services.inbound_mail.password'), which is only ever the .env value: this project deliberately does not overlay stored secrets onto config at boot — SecretVault's own docblock says so, and HttpMonitoringClient reads its token at the point of use for exactly that reason. My docblock even asserted the opposite mechanism, which I never verified. It reads the vault now, and falls back to .env because SecretVault::get() already does. And a way to find out. "Verbindung prüfen" saves the form first — testing what is on screen while the server still holds the previous values would report on a mailbox nobody configured — then performs the same four verbs the real fetch performs: LOGIN, SELECT, SEARCH UNSEEN. A check that proves something easier than the actual job is worse than none. What it reports back is one of five fixed words, never the server's own prose: an IMAP error carries the command it failed on, and for LOGIN that command contains the password. Each word maps to a sentence an operator can act on — address and port, mailbox and password, the folder. The line saying when the mailbox was last reached is on both pages, and it is written by the SCHEDULED run as well as by the button. Otherwise "last checked" would mean "last time somebody pressed a button", which is the less interesting of the two: a mailbox that stopped answering at three in the morning is the one worth seeing, and nobody presses a button at three in the morning. It is also shown when the answer is "never" — an empty inbox and a mailbox that stopped answering look identical, and this line is the whole difference. A fetch against a mailbox that just refused the login now takes nothing in and records the refusal, rather than reporting "nothing new" — which was true and useless. Also: HostStepsTest asserted the literal clupilot.com for a host name. That was true until CLUPILOT_DNS_ZONE was set to clupilot.cloud on this installation, and then a correct configuration failed a test. The zone is pinned in the test now; what it is about is the shape of the name and which address it carries. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Livewire/Admin/Inbox.php | 6 + app/Livewire/Admin/Integrations.php | 31 +++++ app/Services/Mail/FakeMailbox.php | 16 +++ app/Services/Mail/ImapMailbox.php | 78 ++++++++++++- app/Services/Mail/InboundMailStatus.php | 59 ++++++++++ app/Services/Mail/InboundMailbox.php | 14 +++ app/Services/Mail/IngestInboundMail.php | 17 ++- lang/de/inbox.php | 3 + lang/de/integrations.php | 11 ++ lang/en/inbox.php | 3 + lang/en/integrations.php | 11 ++ .../views/livewire/admin/inbox.blade.php | 25 +++++ .../livewire/admin/integrations.blade.php | 29 +++++ tests/Feature/Admin/InboundMailTest.php | 106 ++++++++++++++++++ tests/Feature/Provisioning/HostStepsTest.php | 8 ++ 16 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 app/Services/Mail/InboundMailStatus.php diff --git a/VERSION b/VERSION index 155d478..38670bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.44 +1.3.45 diff --git a/app/Livewire/Admin/Inbox.php b/app/Livewire/Admin/Inbox.php index 0204def..f6443f4 100644 --- a/app/Livewire/Admin/Inbox.php +++ b/app/Livewire/Admin/Inbox.php @@ -6,6 +6,7 @@ use App\Models\Customer; use App\Models\InboundMail; use App\Services\Mail\IngestInboundMail; use App\Services\Mail\InboundMailbox; +use App\Services\Mail\InboundMailStatus; use Livewire\Attributes\Layout; use Livewire\Attributes\Url; use Livewire\Component; @@ -127,6 +128,11 @@ class Inbox extends Component // explain: a mailbox that was never configured looks exactly like a // mailbox with no mail in it. 'configured' => app(InboundMailbox::class)->isConfigured(), + // The same line the settings page shows. This is where somebody + // notices nothing is arriving, so this is where "last reached" has + // to be readable — otherwise an empty inbox and a broken mailbox + // look identical. + 'status' => app(InboundMailStatus::class)->last(), ]); } } diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index bd0f8e7..8b17cbc 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -6,6 +6,8 @@ use App\Livewire\Concerns\ConfirmsPassword; use App\Services\Deployment\UpdateChannel; use App\Services\Env\EnvFileEditor; use App\Services\Env\InvalidEnvContentException; +use App\Services\Mail\InboundMailbox; +use App\Services\Mail\InboundMailStatus; use App\Services\Secrets\SecretVault; use App\Support\ProvisioningSettings; use App\Support\Settings; @@ -400,6 +402,32 @@ class Integrations extends Component abort_unless($this->passwordRecentlyConfirmed(), 403); } + /** + * Reach the support mailbox now and say what came back. + * + * The one thing an operator wants after typing a host, a user and a + * password: did any of it work. Waiting for the next scheduled run to find + * out is not an answer, and "der Posteingang ist leer" is not one either. + * + * Saved first, deliberately: testing what is on screen while the server + * still holds the previous values would report on a mailbox nobody + * configured. The unsaved form is the question being asked. + */ + public function testInbound(): void + { + $this->guardInfra(); + $this->saveInfra(); + + $check = app(InboundMailbox::class)->check(); + app(InboundMailStatus::class)->record($check['ok'], $check['message'], $check['unseen']); + + $this->dispatch('notify', message: $check['ok'] + ? ($check['unseen'] === null + ? __('integrations.inbound_ok') + : trans_choice('integrations.inbound_ok_waiting', $check['unseen'], ['count' => $check['unseen']])) + : __('integrations.inbound_failed_'.$check['message'])); + } + public function render() { $vault = app(SecretVault::class); @@ -458,6 +486,9 @@ class Integrations extends Component self::TABS, fn (string $tab) => $tab !== 'env' || $canSecrets, )), + // When the mailbox was last reached, and what came back. Null until + // somebody — or the scheduler — has asked once. + 'inboundStatus' => app(InboundMailStatus::class)->last(), 'canSecrets' => $canSecrets, 'canInfra' => $canInfra, 'unlocked' => $unlocked, diff --git a/app/Services/Mail/FakeMailbox.php b/app/Services/Mail/FakeMailbox.php index 05b1500..fd7afd6 100644 --- a/app/Services/Mail/FakeMailbox.php +++ b/app/Services/Mail/FakeMailbox.php @@ -25,6 +25,22 @@ class FakeMailbox implements InboundMailbox return $this->configured; } + /** + * What a test wants the check to answer, if not the obvious. + * + * @var array{ok: bool, message: string, unseen: int|null}|null + */ + public array|null $checkAnswer = null; + + public function check(): array + { + return $this->checkAnswer ?? [ + 'ok' => $this->configured, + 'message' => $this->configured ? '' : 'not configured', + 'unseen' => $this->configured ? count($this->mails) : null, + ]; + } + public function fetch(int $limit = 50): array { return array_slice($this->mails, 0, $limit); diff --git a/app/Services/Mail/ImapMailbox.php b/app/Services/Mail/ImapMailbox.php index c7f1cea..ab3b384 100644 --- a/app/Services/Mail/ImapMailbox.php +++ b/app/Services/Mail/ImapMailbox.php @@ -2,6 +2,7 @@ namespace App\Services\Mail; +use App\Services\Secrets\SecretVault; use App\Support\ProvisioningSettings; use Illuminate\Support\Facades\Log; use RuntimeException; @@ -44,9 +45,20 @@ class ImapMailbox implements InboundMailbox * Configured means: somebody entered a host, a user and a password. * * The first two come from the console (ProvisioningSettings), the password - * from the vault — read through config, which SecretVault overrides at - * boot. No separate "enabled" switch to forget: a mailbox with credentials - * IS the switch, and one without them cannot be polled anyway. + * from the VAULT — read here, at the point of use. It used to read + * config('services.inbound_mail.password'), which is only ever the .env + * value: this project deliberately does not overlay stored secrets onto + * config at boot (SecretVault's docblock, rule 3). So a password saved in + * the console counted for nothing, isConfigured() stayed false, and the + * inbox went on saying no mailbox was set up while the password sat right + * there on the settings page. Reported exactly that way. + * + * SecretVault::get() falls back to the configured value when nothing is + * stored, so an installation carrying INBOUND_MAIL_PASSWORD in .env keeps + * working unchanged. + * + * No separate "enabled" switch to forget: a mailbox with credentials IS the + * switch, and one without them cannot be polled anyway. */ public function isConfigured(): bool { @@ -54,7 +66,63 @@ class ImapMailbox implements InboundMailbox return filled($mailbox['host']) && filled($mailbox['username']) - && filled(config('services.inbound_mail.password')); + && filled($this->password()); + } + + /** The mailbox password, from the vault, falling back to .env. */ + private function password(): string + { + return (string) app(SecretVault::class)->get('inbound_mail.password'); + } + + /** + * Reach the mailbox and count what is waiting. + * + * Deliberately the same four verbs the fetch uses — LOGIN, SELECT, SEARCH — + * so a green check means the thing that actually runs would work. A check + * that proves something easier than the real job is worse than none: it was + * exactly that mistake (a test computing its expectation with the code under + * test) the timezone rule was written about. + * + * @return array{ok: bool, message: string, unseen: int|null} + */ + public function check(): array + { + if (! $this->isConfigured()) { + return ['ok' => false, 'message' => 'incomplete', 'unseen' => null]; + } + + try { + $this->connect(); + $unseen = count($this->search()); + + return ['ok' => true, 'message' => '', 'unseen' => $unseen]; + } catch (Throwable $e) { + // The message, not the trace, and never the command: LOGIN carries + // the password and this string is shown on a page. + return ['ok' => false, 'message' => $this->reason($e), 'unseen' => null]; + } finally { + $this->disconnect(); + } + } + + /** + * One line an operator can act on, with nothing secret in it. + * + * IMAP servers answer a failed LOGIN with their own prose, and this class + * puts the command in the exception message — which for LOGIN would be the + * password. So the text is matched, never passed through. + */ + private function reason(Throwable $e): string + { + $text = strtolower($e->getMessage()); + + return match (true) { + str_contains($text, 'could not reach') => 'unreachable', + str_contains($text, 'authenticationfailed') || str_contains($text, 'login') => 'credentials', + str_contains($text, 'nonexistent') || str_contains($text, 'select') => 'folder', + default => 'failed', + }; } /** @return array */ @@ -147,7 +215,7 @@ class ImapMailbox implements InboundMailbox $this->command(sprintf( 'LOGIN %s %s', $this->quote($mailbox['username']), - $this->quote((string) config('services.inbound_mail.password')), + $this->quote($this->password()), )); $this->command('SELECT '.$this->quote($mailbox['folder'])); diff --git a/app/Services/Mail/InboundMailStatus.php b/app/Services/Mail/InboundMailStatus.php new file mode 100644 index 0000000..c55a458 --- /dev/null +++ b/app/Services/Mail/InboundMailStatus.php @@ -0,0 +1,59 @@ + Carbon::now()->toIso8601String(), + 'ok' => $ok, + // Never the password, never the whole exception: this ends up on a + // page, and an IMAP error can quote the command it failed on. + 'message' => mb_substr($message, 0, 300), + 'unseen' => $unseen, + ]); + } + + /** + * The last answer, or null if nobody has ever asked. + * + * @return array{at: Carbon, ok: bool, message: string, unseen: int|null}|null + */ + public function last(): ?array + { + $row = Settings::get(self::KEY); + + if (! is_array($row) || ! isset($row['at'])) { + return null; + } + + return [ + // Parsed back into a Carbon so the view can put it on the wall clock + // (R19) rather than printing an ISO string at somebody. + 'at' => Carbon::parse((string) $row['at']), + 'ok' => (bool) ($row['ok'] ?? false), + 'message' => (string) ($row['message'] ?? ''), + 'unseen' => isset($row['unseen']) ? (int) $row['unseen'] : null, + ]; + } +} diff --git a/app/Services/Mail/InboundMailbox.php b/app/Services/Mail/InboundMailbox.php index 3e240fb..ef63fa1 100644 --- a/app/Services/Mail/InboundMailbox.php +++ b/app/Services/Mail/InboundMailbox.php @@ -15,6 +15,20 @@ interface InboundMailbox /** Is a mailbox configured at all? */ public function isConfigured(): bool; + /** + * Reach the mailbox now and say what happened. + * + * For the button in the console: an operator who has just typed a host, a + * user and a password should not have to wait for the next scheduled run to + * find out whether any of it was right — and "the inbox is empty" is not an + * answer to that question. + * + * Never throws. A failed check IS the answer. + * + * @return array{ok: bool, message: string, unseen: int|null} + */ + public function check(): array; + /** * Messages that have arrived, oldest first. * diff --git a/app/Services/Mail/IngestInboundMail.php b/app/Services/Mail/IngestInboundMail.php index 15b5fc1..22e54f6 100644 --- a/app/Services/Mail/IngestInboundMail.php +++ b/app/Services/Mail/IngestInboundMail.php @@ -22,7 +22,10 @@ use Illuminate\Support\Facades\Log; */ class IngestInboundMail { - public function __construct(private readonly InboundMailbox $mailbox) {} + public function __construct( + private readonly InboundMailbox $mailbox, + private readonly InboundMailStatus $status, + ) {} /** * Fetch, store, and flag what was stored. @@ -35,6 +38,18 @@ class IngestInboundMail return 0; } + // The scheduled run records its own outcome, so the "last checked" line + // in the console means the last time the mailbox was actually reached — + // not the last time somebody pressed a button. A mailbox that stopped + // answering at three in the morning is the one worth seeing, and nobody + // presses a button at three in the morning. + $check = $this->mailbox->check(); + $this->status->record($check['ok'], $check['message'], $check['unseen']); + + if (! $check['ok']) { + return 0; + } + $taken = 0; foreach ($this->mailbox->fetch($limit) as $mail) { diff --git a/lang/de/inbox.php b/lang/de/inbox.php index 94f2e58..36ad498 100644 --- a/lang/de/inbox.php +++ b/lang/de/inbox.php @@ -8,6 +8,9 @@ return [ 'fetch_now' => 'Jetzt abrufen', 'fetched' => '{1} :count neue Nachricht|[2,*] :count neue Nachrichten', 'nothing_new' => 'Nichts Neues im Postfach.', + 'never_checked' => 'Das Postfach wurde noch nie abgerufen — der nächste Lauf ist in wenigen Minuten.', + 'last_ok' => 'Postfach zuletzt erreicht: :when (:ago)', + 'last_failed' => 'Postfach zuletzt nicht erreichbar: :when (:ago).', 'show_archived' => 'Erledigte zeigen', 'show_open' => 'Offene zeigen', diff --git a/lang/de/integrations.php b/lang/de/integrations.php index 4551151..2d582ba 100644 --- a/lang/de/integrations.php +++ b/lang/de/integrations.php @@ -88,6 +88,17 @@ return [ 'inbound_port_hint' => '993 ist IMAP über TLS. Alles andere läuft unverschlüsselt — dieser Zugang trägt ein Passwort und die Worte Ihrer Kunden.', 'inbound_username' => 'Postfach', 'inbound_folder' => 'Ordner', + 'inbound_test' => 'Verbindung prüfen', + 'inbound_never' => 'Noch nie geprüft.', + 'inbound_last_ok' => 'Zuletzt erreichbar: :when (:ago)', + 'inbound_last_failed' => 'Zuletzt fehlgeschlagen: :when (:ago)', + 'inbound_ok' => 'Postfach erreichbar.', + 'inbound_ok_waiting' => '{0} Postfach erreichbar, nichts Ungelesenes.|{1} Postfach erreichbar, :count ungelesene Nachricht.|[2,*] Postfach erreichbar, :count ungelesene Nachrichten.', + 'inbound_failed_incomplete' => 'Es fehlen noch Angaben: Server, Postfach oder Passwort.', + 'inbound_failed_unreachable' => 'Der Server ist nicht erreichbar. Stimmen Adresse und Port?', + 'inbound_failed_credentials' => 'Der Server hat die Anmeldung abgelehnt. Stimmen Postfach und Passwort?', + 'inbound_failed_folder' => 'Anmeldung hat funktioniert, den Ordner gibt es aber nicht.', + 'inbound_failed_failed' => 'Die Verbindung ist fehlgeschlagen. Näheres steht im Log.', // Die Reiter. Getrennt nach dem, wofür ein Abschnitt da ist — nicht danach, // welcher Speichermechanismus einen Wert zufällig hält. diff --git a/lang/en/inbox.php b/lang/en/inbox.php index 01f742a..54fbb16 100644 --- a/lang/en/inbox.php +++ b/lang/en/inbox.php @@ -8,6 +8,9 @@ return [ 'fetch_now' => 'Fetch now', 'fetched' => '{1} :count new message|[2,*] :count new messages', 'nothing_new' => 'Nothing new in the mailbox.', + 'never_checked' => 'The mailbox has never been fetched yet — the next run is a few minutes away.', + 'last_ok' => 'Mailbox last reached: :when (:ago)', + 'last_failed' => 'Mailbox last unreachable: :when (:ago).', 'show_archived' => 'Show filed', 'show_open' => 'Show open', diff --git a/lang/en/integrations.php b/lang/en/integrations.php index 24ea100..cbf1014 100644 --- a/lang/en/integrations.php +++ b/lang/en/integrations.php @@ -88,6 +88,17 @@ return [ 'inbound_port_hint' => '993 is IMAP over TLS. Anything else is unencrypted — and this connection carries a password and your customers own words.', 'inbound_username' => 'Mailbox', 'inbound_folder' => 'Folder', + 'inbound_test' => 'Test the connection', + 'inbound_never' => 'Never checked.', + 'inbound_last_ok' => 'Last reached: :when (:ago)', + 'inbound_last_failed' => 'Last failed: :when (:ago)', + 'inbound_ok' => 'Mailbox reachable.', + 'inbound_ok_waiting' => '{0} Mailbox reachable, nothing unread.|{1} Mailbox reachable, :count unread message.|[2,*] Mailbox reachable, :count unread messages.', + 'inbound_failed_incomplete' => 'Something is still missing: server, mailbox or password.', + 'inbound_failed_unreachable' => 'The server cannot be reached. Are the address and port right?', + 'inbound_failed_credentials' => 'The server refused the sign-in. Are the mailbox and password right?', + 'inbound_failed_folder' => 'The sign-in worked, but that folder does not exist.', + 'inbound_failed_failed' => 'The connection failed. There is more in the log.', // The tabs. Split by what a section is for — not by which storage // mechanism happens to hold a given value. diff --git a/resources/views/livewire/admin/inbox.blade.php b/resources/views/livewire/admin/inbox.blade.php index ea57daf..b5ca7fd 100644 --- a/resources/views/livewire/admin/inbox.blade.php +++ b/resources/views/livewire/admin/inbox.blade.php @@ -24,6 +24,31 @@ @endif + {{-- When the mailbox was last reached. Shown whether or not anything came of + it: an empty inbox and a mailbox that stopped answering look exactly the + same, and the difference is this line. --}} + @if ($configured) +

$status === null || $status['ok'], + 'text-danger' => $status !== null && ! $status['ok'], + ])> + @if ($status === null) + {{ __('inbox.never_checked') }} + @else + + {{-- R19: stored in UTC, read on the wall clock. --}} + {{ __($status['ok'] ? 'inbox.last_ok' : 'inbox.last_failed', [ + 'when' => $status['at']->local()->isoFormat('D. MMM YYYY, HH:mm'), + 'ago' => $status['at']->diffForHumans(), + ]) }} + @if (! $status['ok']) + {{ __('inbox.to_integrations') }} + @endif + @endif +

+ @endif + @if ($mails->isEmpty())

{{ $archived ? __('inbox.empty_archived') : __('inbox.empty') }}

diff --git a/resources/views/livewire/admin/integrations.blade.php b/resources/views/livewire/admin/integrations.blade.php index 2a16057..34ec41d 100644 --- a/resources/views/livewire/admin/integrations.blade.php +++ b/resources/views/livewire/admin/integrations.blade.php @@ -125,6 +125,35 @@ @if ($canInfra)
@endif @endif + + {{-- The one thing an operator wants after typing all of it: did any + of it work. The button saves first, because testing what is on + screen while the server still holds the old values would report + on a mailbox nobody configured. --}} + @if ($canInfra) +
+ + {{ __('integrations.inbound_test') }} + + + {{-- Always said, including "never": a line that appears only + after a success leaves an operator unable to tell a + mailbox that has not been checked from one that is fine. + R19: stored in UTC, read on the wall clock. --}} + @if ($inboundStatus === null) +

{{ __('integrations.inbound_never') }}

+ @else +

$inboundStatus['ok'], 'text-danger' => ! $inboundStatus['ok']])> + + {{ __($inboundStatus['ok'] ? 'integrations.inbound_last_ok' : 'integrations.inbound_last_failed', [ + 'when' => $inboundStatus['at']->local()->isoFormat('D. MMM YYYY, HH:mm'), + 'ago' => $inboundStatus['at']->diffForHumans(), + ]) }} +

+ @endif +
+ @endif
@endif diff --git a/tests/Feature/Admin/InboundMailTest.php b/tests/Feature/Admin/InboundMailTest.php index c25eb86..3a1fc00 100644 --- a/tests/Feature/Admin/InboundMailTest.php +++ b/tests/Feature/Admin/InboundMailTest.php @@ -9,6 +9,7 @@ use App\Services\Mail\FetchedMail; use App\Services\Mail\IngestInboundMail; use App\Services\Mail\InboundMailbox; use App\Services\Mail\MailParser; +use Illuminate\Support\Facades\File; use Livewire\Livewire; /** @@ -332,3 +333,108 @@ it('keeps the inbox to operators who may see customers', function () { ->test(Inbox::class) ->assertForbidden(); }); + +// ---- Configured means configured, and the check that says so ---- + +it('counts a password from the vault as configured', function () { + // The bug this closes: isConfigured() read config('services.inbound_mail + // .password'), which is only ever the .env value — this project deliberately + // does not overlay stored secrets onto config at boot. So a password saved in + // the console counted for nothing, and the inbox went on saying no mailbox + // was set up while the password sat right there on the settings page. + App\Support\Settings::set('inbound_mail.host', 'mail.example.test'); + App\Support\Settings::set('inbound_mail.username', 'support@example.test'); + config()->set('services.inbound_mail.password', null); + + $mailbox = new App\Services\Mail\ImapMailbox; + + expect($mailbox->isConfigured())->toBeFalse(); + + app(App\Services\Secrets\SecretVault::class)->put('inbound_mail.password', 'geheim', operator('Owner')); + + expect($mailbox->isConfigured())->toBeTrue(); +}); + +it('still takes the password out of the environment where that is where it is', function () { + // An installation carrying INBOUND_MAIL_PASSWORD keeps working: the vault + // falls back to the configured value when nothing is stored. + App\Support\Settings::set('inbound_mail.host', 'mail.example.test'); + App\Support\Settings::set('inbound_mail.username', 'support@example.test'); + config()->set('services.inbound_mail.password', 'aus-der-env'); + + expect((new App\Services\Mail\ImapMailbox)->isConfigured())->toBeTrue(); +}); + +it('records when the mailbox was last reached, and what came back', function () { + $status = app(App\Services\Mail\InboundMailStatus::class); + + expect($status->last())->toBeNull(); + + $status->record(true, '', 3); + $last = $status->last(); + + expect($last['ok'])->toBeTrue() + ->and($last['unseen'])->toBe(3) + // A Carbon, so the view can put it on the wall clock rather than + // printing an ISO string at somebody (R19). + ->and($last['at'])->toBeInstanceOf(Illuminate\Support\Carbon::class); +}); + +it('records the scheduled run too, not only the button', function () { + // "Last checked" has to mean the last time the mailbox was actually reached. + // A mailbox that stopped answering at three in the morning is the one worth + // seeing, and nobody presses a button at three in the morning. + $this->mailbox->mails = [fetched()]; + + app(IngestInboundMail::class)(); + + expect(app(App\Services\Mail\InboundMailStatus::class)->last())->not->toBeNull(); +}); + +it('takes nothing in when the mailbox cannot be reached, and says so', function () { + // Fetching against a mailbox that just refused the login would report + // "nothing new", which is true and useless. + $this->mailbox->mails = [fetched()]; + $this->mailbox->checkAnswer = ['ok' => false, 'message' => 'credentials', 'unseen' => null]; + + expect(app(IngestInboundMail::class)())->toBe(0) + ->and(InboundMail::query()->count())->toBe(0); + + $last = app(App\Services\Mail\InboundMailStatus::class)->last(); + + expect($last['ok'])->toBeFalse() + ->and($last['message'])->toBe('credentials'); +}); + +it('tests the connection from the settings page and shows when it last answered', function () { + $page = Livewire::actingAs(operator('Owner'), 'operator') + ->test(App\Livewire\Admin\Integrations::class) + ->set('inboundHost', 'mail.example.test') + ->set('inboundUsername', 'support@example.test') + ->call('testInbound'); + + $page->assertDispatched('notify'); + + // And the page now carries the line, whichever way it went. + Livewire::actingAs(operator('Owner'), 'operator') + ->test(App\Livewire\Admin\Integrations::class) + ->assertViewHas('inboundStatus', fn ($status) => $status !== null); +}); + +it('never puts the password or the command into the message it shows', function () { + // IMAP servers answer a failed LOGIN with their own prose, and this class + // puts the failing command in the exception — which for LOGIN is the + // password. The reasons are matched, never passed through. + $reasons = ['incomplete', 'unreachable', 'credentials', 'folder', 'failed']; + + foreach ($reasons as $reason) { + expect(__('integrations.inbound_failed_'.$reason))->not->toBe('integrations.inbound_failed_'.$reason); + } + + $code = File::get(app_path('Services/Mail/ImapMailbox.php')); + + // The reason() method returns one of the fixed words above, and nothing + // derived from the exception text. + expect($code)->toContain("return match (true) {") + ->and($code)->not->toContain('$e->getMessage()]'); +}); diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index 7207806..812fdbc 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -1044,6 +1044,14 @@ it('marks the host active on completion', function () { }); it('gives the host a name that points at the tunnel, not at its public address', function () { + // The zone is pinned here rather than read from the machine's own .env. It + // used to assert the literal clupilot.com, which was true until somebody + // set CLUPILOT_DNS_ZONE to clupilot.cloud on their installation — and then + // this test failed on a correct configuration. What it is about is the + // SHAPE of the name and which address it carries, not which domain this + // particular box is configured for. + config()->set('provisioning.dns.zone', 'clupilot.com'); + $s = fakeServices(); $host = Host::factory()->active()->create([ 'datacenter' => 'fsn', From f9993a1f486333fbc3b251f8390bfca8f06f90ce Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 13:20:29 +0200 Subject: [PATCH 4/6] Name the server variable under a credential, not the internal key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under each credential stood `stripe.secret`, `dns.token`, `inbound_mail.password`, `monitoring.token` — identifiers out of SecretVault::REGISTRY. They mean nothing on a settings page and there is nowhere an operator could type them; asked about directly, and the answer is no, that was not deliberate. The env variable name is the useful one: it is what stands in the server file, it is what the .env tab of this same page lists, and when the badge beside it says "Aus der Serverdatei" it answers the obvious next question — which line? The registry key stays where it belongs: in wire:key and in the modal arguments, as an identifier nobody reads. The test asserts on the text node rather than on the key's absence from the HTML, because absence would have demanded breaking the page to pass. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- .../components/admin/secret-field.blade.php | 10 ++++++- tests/Feature/Admin/IntegrationsPageTest.php | 28 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 38670bb..6232e7e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.45 +1.3.46 diff --git a/resources/views/components/admin/secret-field.blade.php b/resources/views/components/admin/secret-field.blade.php index 9e3ec5d..febad18 100644 --- a/resources/views/components/admin/secret-field.blade.php +++ b/resources/views/components/admin/secret-field.blade.php @@ -15,7 +15,15 @@

{{ $entry['label'] }}

-

{{ $entry['key'] }}

+ {{-- The NAME OF THE SERVER VARIABLE, not the internal registry key. + + It used to print `stripe.secret` — an identifier from + SecretVault::REGISTRY that means nothing on this page and is not + something an operator can type anywhere. The env name is: it is + what stands in the server file, it is what the .env tab of this + same page lists, and when the badge beside this says "Aus der + Serverdatei" it is the answer to "which line?". --}} +

{{ $entry['envKey'] }}

test(Integrations::class); + + $page->assertSee('STRIPE_SECRET') + ->assertSee('INBOUND_MAIL_PASSWORD') + ->assertSee('HETZNER_DNS_TOKEN') + ->assertSee('MONITORING_API_TOKEN'); + + // Not asserted by absence from the HTML: the key legitimately stays in + // wire:key and in the modal arguments, where it is an identifier nobody + // reads. What must not happen is printing it AS TEXT under the label. + $component = Illuminate\Support\Facades\File::get( + resource_path('views/components/admin/secret-field.blade.php') + ); + $withoutComments = (string) preg_replace('/\{\{--.*?--\}\}/s', '', $component); + + // Between tags, i.e. as a text node — the key stays inside attributes + // (wire:key, wire:click, the modal arguments), where it is an identifier + // nobody reads and removing it would break the page. + expect($withoutComments)->toMatch('/>\s*\{\{ \$entry\[.envKey.\] \}\}\s*and($withoutComments)->not->toMatch('/>\s*\{\{ \$entry\[.key.\] \}\}\s* Date: Thu, 30 Jul 2026 13:36:42 +0200 Subject: [PATCH 5/6] Say on the front page that the site is switched off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Ich komme auf dem lokalen Dev-Server nicht auf www.dev." Diagnosed rather than guessed: Laravel answers 200 on that host, and the request never reaches it — site.public is false, so PublicSiteGate returns the placeholder with a 503 to every visitor who is not on the management VPN and not signed in as an operator. Which is precisely what a broken deployment looks like from outside, and the only place that said so was the switch itself, on another page. So the console says it now, on the page an operator opens when something looks wrong, and the notice links to the switch. The second half of the same report — being sent to app.… — is the missing SITE_HOST. Without it the marketing site is registered without a hostname: it answers on the PORTAL's host as well, and every route() it generates points at APP_URL, so a link on the site lands on the portal's domain. Also said, also with the host named. Only where the installation is host-separated at all, though. A fresh checkout binds nothing to a hostname by design — RestrictAdminHost: "upgrading must not lock anyone out of a system that was working" — and a warning there is furniture. My first version fired on every clean installation and two existing tests said so immediately; they were right. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Livewire/Admin/Overview.php | 36 +++++++++++++++++++ lang/de/admin.php | 2 ++ lang/en/admin.php | 2 ++ tests/Feature/Admin/MailRegisterTest.php | 44 ++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 6232e7e..f5301e2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.46 +1.3.47 diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 7987e00..87435ca 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -6,6 +6,8 @@ use App\Livewire\Concerns\BuildsRunSteps; use App\Models\Customer; use App\Models\Host; use App\Services\Provisioning\HostCapacity; +use App\Support\AdminArea; +use App\Support\Settings; use App\Models\Instance; use App\Models\MonitoringTarget; use App\Models\ProvisioningRun; @@ -367,6 +369,40 @@ class Overview extends Component ])]; } + // The site being switched off. It is a deliberate state, not a fault — + // but it answers 503 to every visitor who is not on the VPN, and the + // only place that says so is the switch itself, three clicks away on + // another page. Asked as "ich komme nicht auf www.dev", which is exactly + // what it looks like from outside. + if (! Settings::bool('site.public', true)) { + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.site_hidden'), + 'route' => 'admin.settings', + ]; + } + + // No SITE_HOST. The marketing site is then registered without a + // hostname, so it answers on the PORTAL's host as well — and every + // route() it generates points at APP_URL, which is why a link on the + // site lands on the portal's domain. + // + // Only where the installation is host-separated at all. On a fresh + // checkout nothing is bound to a hostname by design (see + // RestrictAdminHost: "upgrading must not lock anyone out of a system + // that was working"), and a warning there would be furniture — the same + // mistake the capacity notice made before it learned to ask whether + // there is a host at all. + if (AdminArea::isHostBound() && config('admin_access.site_hosts') === []) { + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.no_site_host', [ + 'app' => (string) parse_url((string) config('app.url'), PHP_URL_HOST), + ]), + 'route' => 'admin.integrations', + ]; + } + return $notices; } diff --git a/lang/de/admin.php b/lang/de/admin.php index 78b708f..74e1715 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -88,6 +88,8 @@ return [ ], 'notice' => [ + 'site_hidden' => 'Website und Kundenbereich sind ausgeblendet — Besucher außerhalb des VPN bekommen die Platzhalterseite (503). Umschalten unter Einstellungen → Installation.', + 'no_site_host' => 'SITE_HOST ist nicht gesetzt. Die Website antwortet dadurch auch auf :app, und Links auf ihr zeigen dorthin statt auf den eigenen Namen.', 'no_capacity' => 'Kein Host hat noch Platz für „:plan“ — das Paket braucht :needs GB, der freieste Host hat :largest GB. Eine bezahlte Bestellung dafür würde bei der Reservierung scheitern.', 'failed_runs' => ':n fehlgeschlagene Bereitstellung(en).', 'host_error' => 'Host :host meldet einen Fehler.', diff --git a/lang/en/admin.php b/lang/en/admin.php index 5841b5c..a2098cd 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -88,6 +88,8 @@ return [ ], 'notice' => [ + 'site_hidden' => 'The website and the customer portal are hidden — a visitor outside the VPN gets the placeholder page (503). The switch is under Settings → Installation.', + 'no_site_host' => 'SITE_HOST is not set. The website therefore also answers on :app, and links on it point there instead of at its own name.', 'no_capacity' => 'No host has room for “:plan” any more — the package needs :needs GB and the roomiest host has :largest GB. A paid order for it would fail at reservation.', 'failed_runs' => ':n failed provisioning run(s).', 'host_error' => 'Host :host reports an error.', diff --git a/tests/Feature/Admin/MailRegisterTest.php b/tests/Feature/Admin/MailRegisterTest.php index 49662b1..25b4572 100644 --- a/tests/Feature/Admin/MailRegisterTest.php +++ b/tests/Feature/Admin/MailRegisterTest.php @@ -256,3 +256,47 @@ it('names the status in words rather than printing the key at it', function () { ->assertDontSee('customers.status.') ->assertDontSee('support.status.'); }); + +// ---- Two things the console owed the operator ---- + +it('says on the front page that the site is switched off', function () { + // Asked as "ich komme nicht auf www.dev". The site answers 503 to every + // visitor who is not on the VPN, which is exactly what a broken deployment + // looks like from outside — and the only place that said so was the switch + // itself, on another page. + App\Support\Settings::set('site.public', false); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Overview::class) + ->assertSee(__('admin.notice.site_hidden')) + // And it leads to the switch. + ->assertSee(route('admin.settings'), false); + + App\Support\Settings::set('site.public', true); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Overview::class) + ->assertDontSee(__('admin.notice.site_hidden')); +}); + +it('says when the website has no hostname of its own', function () { + // Without SITE_HOST the marketing site is registered without a hostname, so + // it answers on the PORTAL's host too — and every route() it generates + // points at APP_URL. That is why a link on the site lands on app.…, which + // was the other half of the same report. + // Only fires where the installation is host-separated at all: on a fresh + // checkout nothing is bound, and a warning there would be furniture. + config()->set('admin_access.hosts', ['admin.dev.example.test']); + config()->set('admin_access.site_hosts', []); + config()->set('app.url', 'https://app.dev.example.test'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Overview::class) + ->assertSee('app.dev.example.test'); + + config()->set('admin_access.site_hosts', ['www.dev.example.test']); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Overview::class) + ->assertDontSee(__('admin.notice.no_site_host', ['app' => 'app.dev.example.test'])); +}); From f49f1dca09d2a36969ab2efa0920c7337833b54d Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 13:55:55 +0200 Subject: [PATCH 6/6] Say when no mail is being delivered at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Ich versuche mich zu registrieren, erhalte aber keine Verify-Mail, auch nicht beim erneuten Senden." Traced rather than guessed, and the evidence is in storage/logs: the address appears three times, so the mails were built and handed to a transport that writes them to a file. MAIL_MAILER=log. MailboxTransport::resolution() short-circuits on it — NON_DELIVERING is ['log', 'array', null] — so every purpose mailer becomes the log transport however well the five mailboxes are configured, which is correct for a development machine and exactly what was happening here. The reason it deserves a notice is that the failure is silent in every direction an operator would look. Mail::queue() succeeds. No job fails. The queue empties. And the console's own mailbox test reports SUCCESS, because MailboxTester builds its own transport on purpose — a check that honoured MAIL_MAILER=log would report success while writing to a file. So every instrument on the page said fine. Now the front page says it, linked to the mail page, and the mail page says it above the mailboxes it makes decorative. All three swallowing values are named, not only 'log': array and an unset default do the same thing, and a notice that only knew about one would be silent in the two cases nobody thinks to check. Two existing tests asserted that a clean installation raises no notice, and went red — correctly, because phpunit.xml forces MAIL_MAILER=array so a suite cannot send mail. They pin a delivering mailer now: the warning is about the environment, those tests are about the estate. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Livewire/Admin/Overview.php | 17 +++++++ lang/de/admin.php | 1 + lang/en/admin.php | 1 + resources/views/livewire/admin/mail.blade.php | 13 +++++ .../Admin/ConsoleReportsRealDataTest.php | 10 ++++ tests/Feature/Admin/MailRegisterTest.php | 47 +++++++++++++++++++ 7 files changed, 90 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index f5301e2..b500665 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.47 +1.3.48 diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 87435ca..bc5e15a 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -382,6 +382,23 @@ class Overview extends Component ]; } + // MAIL_MAILER=log. Every purpose mailer short-circuits to the log + // transport then — see MailboxTransport::NON_DELIVERING — however well + // the mailboxes are configured, and nothing is delivered to anybody. + // + // Worth its own notice because the failure is SILENT in every direction: + // the queue reports success, no job fails, the console's own mailbox test + // reports success (MailboxTester builds its own transport on purpose), and + // the mail sits in storage/logs. Somebody registering and waiting for a + // verification mail has no way to find that out. Asked exactly that way. + if (in_array(config('mail.default'), ['log', 'array', null], true)) { + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]), + 'route' => 'admin.mail', + ]; + } + // No SITE_HOST. The marketing site is then registered without a // hostname, so it answers on the PORTAL's host as well — and every // route() it generates points at APP_URL, which is why a link on the diff --git a/lang/de/admin.php b/lang/de/admin.php index 74e1715..4fb00d9 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -88,6 +88,7 @@ return [ ], 'notice' => [ + 'mail_not_delivering' => 'MAIL_MAILER steht auf „:mailer“ — es wird keine E-Mail zugestellt, alles landet in storage/logs. Auch der Postfach-Test meldet trotzdem Erfolg, weil er seinen eigenen Weg nimmt.', 'site_hidden' => 'Website und Kundenbereich sind ausgeblendet — Besucher außerhalb des VPN bekommen die Platzhalterseite (503). Umschalten unter Einstellungen → Installation.', 'no_site_host' => 'SITE_HOST ist nicht gesetzt. Die Website antwortet dadurch auch auf :app, und Links auf ihr zeigen dorthin statt auf den eigenen Namen.', 'no_capacity' => 'Kein Host hat noch Platz für „:plan“ — das Paket braucht :needs GB, der freieste Host hat :largest GB. Eine bezahlte Bestellung dafür würde bei der Reservierung scheitern.', diff --git a/lang/en/admin.php b/lang/en/admin.php index a2098cd..b6fbe70 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -88,6 +88,7 @@ return [ ], 'notice' => [ + 'mail_not_delivering' => 'MAIL_MAILER is set to ":mailer" — no mail is delivered to anybody, it all goes to storage/logs. The mailbox test still reports success, because it builds its own transport.', 'site_hidden' => 'The website and the customer portal are hidden — a visitor outside the VPN gets the placeholder page (503). The switch is under Settings → Installation.', 'no_site_host' => 'SITE_HOST is not set. The website therefore also answers on :app, and links on it point there instead of at its own name.', 'no_capacity' => 'No host has room for “:plan” any more — the package needs :needs GB and the roomiest host has :largest GB. A paid order for it would fail at reservation.', diff --git a/resources/views/livewire/admin/mail.blade.php b/resources/views/livewire/admin/mail.blade.php index 3b00c8e..e491f38 100644 --- a/resources/views/livewire/admin/mail.blade.php +++ b/resources/views/livewire/admin/mail.blade.php @@ -4,6 +4,19 @@

{{ __('mail_settings.subtitle') }}

+ {{-- The one thing that makes every mailbox on this page decorative. Said + here as well as on the front page, because this is where somebody comes + to find out why no mail arrived — and the test button below reports + success regardless, since MailboxTester builds its own transport on + purpose (a check that honoured MAIL_MAILER=log would report success + while writing to a file). --}} + @if (in_array(config('mail.default'), ['log', 'array', null], true)) + + {{ __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]) }} + + @endif + + @if (! $usable) {{ __('mail_settings.no_key') }} @endif diff --git a/tests/Feature/Admin/ConsoleReportsRealDataTest.php b/tests/Feature/Admin/ConsoleReportsRealDataTest.php index 1ae3419..8452747 100644 --- a/tests/Feature/Admin/ConsoleReportsRealDataTest.php +++ b/tests/Feature/Admin/ConsoleReportsRealDataTest.php @@ -25,6 +25,12 @@ it('reports an empty estate as empty, not as a going concern', function () { // console still claimed 42 customers, 39 instances, 4 hosts and €7,842 a // month. The figures are asserted directly rather than searched for in the // markup — "42" also occurs inside Livewire's own component ids. + // + // The mailer is pinned to a delivering one: phpunit.xml forces + // MAIL_MAILER=array so a test suite cannot send mail, and the console + // rightly warns that nothing is being delivered. That warning is about the + // ENVIRONMENT, not about the estate, and this test is about an empty estate. + config()->set('mail.default', 'smtp'); Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) ->assertViewHas('kpis', function (array $kpis) { @@ -107,6 +113,10 @@ it('states monthly revenue off the contracts, with a yearly term divided', funct }); it('raises a notice only when something is actually wrong', function () { + // See above: the test suite deliberately delivers no mail, and the console + // says so. Pinned here so "clean" means an estate with nothing wrong in it. + config()->set('mail.default', 'smtp'); + $clean = Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class); $clean->assertSee(__('admin.systems_ok')); diff --git a/tests/Feature/Admin/MailRegisterTest.php b/tests/Feature/Admin/MailRegisterTest.php index 25b4572..1093418 100644 --- a/tests/Feature/Admin/MailRegisterTest.php +++ b/tests/Feature/Admin/MailRegisterTest.php @@ -300,3 +300,50 @@ it('says when the website has no hostname of its own', function () { ->test(Overview::class) ->assertDontSee(__('admin.notice.no_site_host', ['app' => 'app.dev.example.test'])); }); + +it('says when no mail is being delivered at all', function () { + // The report: "ich versuche mich zu registrieren, erhalte aber keine Verify- + // Mail, auch nicht beim erneuten Senden". MAIL_MAILER=log short-circuits + // every purpose mailer to the log transport (MailboxTransport:: + // NON_DELIVERING) however well the mailboxes are configured — and the + // failure is silent in every direction: the queue reports success, no job + // fails, and the console's own mailbox test reports success too because it + // builds its own transport on purpose. Nothing on any page said so. + config()->set('mail.default', 'log'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Overview::class) + ->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => 'log'])) + ->assertSee(route('admin.mail'), false); + + // And on the page somebody actually opens to find out why. + Livewire::actingAs(operator('Owner'), 'operator') + ->test(App\Livewire\Admin\Mail::class) + ->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => 'log'])); +}); + +it('stays quiet once mail really goes out', function () { + config()->set('mail.default', 'smtp'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Overview::class) + ->assertDontSee(__('admin.notice.mail_not_delivering', ['mailer' => 'log'])); +}); + +it('names every mailer that swallows mail, not only the log one', function () { + // array and an unset default do the same thing, and the transport already + // treats all three alike. A notice that only knew about 'log' would be + // silent in the two cases nobody thinks to check. + $swallowing = (new ReflectionClass(App\Mail\Transport\MailboxTransport::class)) + ->getConstant('NON_DELIVERING'); + + expect($swallowing)->toBe(['log', 'array', null]); + + foreach (['array', null] as $mailer) { + config()->set('mail.default', $mailer); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Overview::class) + ->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => $mailer ?? 'null'])); + } +});