diff --git a/VERSION b/VERSION index 155d478..b500665 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.44 +1.3.48 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/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 7987e00..bc5e15a 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,57 @@ 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', + ]; + } + + // 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 + // 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/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/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. 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. diff --git a/lang/de/admin.php b/lang/de/admin.php index 78b708f..4fb00d9 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -88,6 +88,9 @@ 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.', 'failed_runs' => ':n fehlgeschlagene Bereitstellung(en).', 'host_error' => 'Host :host meldet einen Fehler.', 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/admin.php b/lang/en/admin.php index 5841b5c..b6fbe70 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -88,6 +88,9 @@ 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.', 'failed_runs' => ':n failed provisioning run(s).', 'host_error' => 'Host :host reports an error.', 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/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'] }}

+ {{-- 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/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/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/Admin/IntegrationsPageTest.php b/tests/Feature/Admin/IntegrationsPageTest.php index e0a333f..8eda383 100644 --- a/tests/Feature/Admin/IntegrationsPageTest.php +++ b/tests/Feature/Admin/IntegrationsPageTest.php @@ -476,3 +476,31 @@ it('saves .env once the page receives the confirmed event', function () { expect(File::get($path))->toBe("CONFIRMED=value\n"); }); + +it('names the server variable under each credential, not the internal key', function () { + // It printed `stripe.secret` — an identifier out of SecretVault::REGISTRY + // that means nothing on a settings page and is not something an operator can + // type anywhere. The env name is what stands in the server file, what the + // .env tab of this same page lists, and the answer to "which line?" when the + // badge says the value comes from there. + $page = Livewire::actingAs(operator('Owner'), 'operator')->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*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'])); +}); + +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'])); + } +}); 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',