diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 7987e00..30fd08e 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -5,11 +5,13 @@ namespace App\Livewire\Admin; use App\Livewire\Concerns\BuildsRunSteps; use App\Models\Customer; use App\Models\Host; -use App\Services\Provisioning\HostCapacity; use App\Models\Instance; use App\Models\MonitoringTarget; use App\Models\ProvisioningRun; use App\Models\Subscription; +use App\Services\Billing\PlanCatalogue; +use App\Services\Provisioning\HostCapacity; +use App\Support\Readiness; use Illuminate\Support\Carbon; use Illuminate\Support\Number; use Livewire\Attributes\Layout; @@ -232,7 +234,7 @@ class Overview extends Component $capacity = app(HostCapacity::class); try { - $sellable = app(\App\Services\Billing\PlanCatalogue::class)->sellable(); + $sellable = app(PlanCatalogue::class)->sellable(); } catch (\Throwable) { // A catalogue that cannot be read has its own alarm. It does not // get to take the console's front page down with it. @@ -367,6 +369,19 @@ class Overview extends Component ])]; } + // What is not yet set up at all, as opposed to everything above (what + // IS set up but currently unhealthy). Readiness::blocking() is the + // same list App\Livewire\Admin\Readiness renders in full — this is + // only the count and a link to it, not a second copy of the reasons. + $blockingReadiness = count(Readiness::blocking()); + if ($blockingReadiness > 0) { + $notices[] = [ + 'level' => 'warning', + 'text' => trans_choice('admin.notice.readiness_blocking', $blockingReadiness, ['n' => $blockingReadiness]), + 'route' => 'admin.readiness', + ]; + } + return $notices; } diff --git a/app/Livewire/Admin/Readiness.php b/app/Livewire/Admin/Readiness.php new file mode 100644 index 0000000..8840bbf --- /dev/null +++ b/app/Livewire/Admin/Readiness.php @@ -0,0 +1,176 @@ + DnsTokenCheck::class, + 'onboarding.wg_hub' => WireguardEndpointCheck::class, + 'provisioning.vm_template' => VmTemplateCheck::class, + 'billing.stripe_secret' => StripeCheck::class, + ]; + + /** + * A heartbeat older than this is not shown as fresh even where the check + * itself already says so — a display concern only. Kept in sync with + * App\Support\Readiness\OperationChecks::STALE_AFTER_MINUTES on purpose + * (see the docblock there for why five minutes), not re-derived from it: + * that constant is private, and duplicating the reasoning in a comment + * here is cheaper than making it public for one reader. + */ + private const HEARTBEAT_KEYS = [ + 'operation.scheduler' => 'heartbeat.scheduler', + 'operation.queue_provisioning' => 'heartbeat.queue_provisioning', + ]; + + /** + * The on-demand checks' own answers, keyed by Check::key. + * + * Never pre-filled in mount(): DnsTokenCheck writes a real TXT record to + * the production DNS zone the moment it runs, so nothing here may run + * except in direct response to an operator's own click. + * + * @var array> + */ + public array $results = []; + + public function mount(): void + { + abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403); + } + + 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(); + } + + /** + * Where an operator goes to actually fix ONE entry. + * + * Not always Admin\Integrations, and not always $check->tab handed + * straight through as its ?tab=: several of the five check groups + * predate the Integrations::TABS redesign and still carry a tab value + * from before it — 'hosts', 'plans', 'datacenters', 'mail', 'templates' + * and 'company' each name a DIFFERENT admin page entirely, and the + * leftover value 'integrations' is the exact bug Task 10's fix round + * already found and corrected for OperationChecks (it does not exist in + * Integrations::TABS, so a link built from it lands nowhere in + * particular — Integrations::mount() silently falls back to its own + * first tab). Rather than repeat that history by forwarding $check->tab + * unchecked, a value only ever becomes a ?tab= when it actually IS one + * of Integrations::TABS; every other known value gets its own real + * route, and anything unrecognised — including the eight checks in + * Onboarding/Provisioning/Delivery still carrying the pre-redesign + * 'integrations' value (Task 8's own deferred note; out of scope for + * this task to rename) — falls back to the Integrations page's own + * default tab rather than a dead link. + */ + public function checkUrl(Check $check): string + { + if (in_array($check->tab, Integrations::TABS, true)) { + return route('admin.integrations', ['tab' => $check->tab]); + } + + return match ($check->tab) { + 'hosts' => route('admin.hosts'), + 'plans' => route('admin.plans'), + 'datacenters' => route('admin.datacenters'), + 'mail' => route('admin.mail'), + 'templates' => route('admin.templates'), + // Company details AND the invoice series both live on + // Admin\Finance — there is no dedicated "company" page. + 'company' => route('admin.finance'), + default => route('admin.integrations'), + }; + } + + public function render() + { + return view('livewire.admin.readiness', [ + 'groups' => ReadinessRegistry::byGroup(), + 'blockingCount' => count(ReadinessRegistry::blocking()), + 'ready' => ReadinessRegistry::isReady(), + 'mode' => OperatingMode::current(), + 'runnable' => array_keys(self::RUNNABLE), + 'heartbeats' => collect(self::HEARTBEAT_KEYS) + ->map(fn (string $settingsKey) => $this->heartbeatDisplay($settingsKey)) + ->all(), + ]); + } + + /** + * The heartbeat's own stored moment, in the operator's zone (R19) — or + * null for "nothing stored" AND for "unreadable", the same forgiving + * either-way OperationChecks::isFresh() already uses. A readiness page + * whose only job is to calmly report what is missing must not itself go + * down over a garbled timestamp (see that method's own docblock for the + * incident this already caused once, inside Readiness::all() itself). + */ + private function heartbeatDisplay(string $settingsKey): ?string + { + $raw = Settings::get($settingsKey); + + if (blank($raw)) { + return null; + } + + try { + return Carbon::parse($raw)->local()->isoFormat('DD.MM.YYYY HH:mm:ss'); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 491cfdf..b730faf 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -86,6 +86,10 @@ final class Navigation // never "all of these" (contrast a single string elsewhere in // this file, which x-shell.nav checks with plain ->can()). ['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']], + // Every check App\Support\Readiness knows about, gathered on + // one page — same gate as Integrations just above, because + // both halves it configures come back together here. + ['admin.readiness', 'shield-check', 'readiness', ['hosts.manage', 'secrets.manage']], // Two-factor enrolment used to sit here as an entry of its // own. It is a tab of Settings now — it is a setting, and it // was the one place an operator had to leave the settings page diff --git a/app/Support/Readiness/DeliveryChecks.php b/app/Support/Readiness/DeliveryChecks.php index 530c2ff..772305b 100644 --- a/app/Support/Readiness/DeliveryChecks.php +++ b/app/Support/Readiness/DeliveryChecks.php @@ -53,7 +53,10 @@ final class DeliveryChecks satisfied: MailTemplate::query()->exists(), ), new Check( - key: 'delivery.inbound_password', + // Named after the vault entry it checks (inbound_mail.password), + // not a shorthand — see the identical reasoning on + // onboarding.ssh_private_key in OnboardingChecks.php. + key: 'delivery.inbound_mail_password', group: self::GROUP, severity: Check::SEVERITY_WARNING, label: __('readiness.delivery.inbound_password'), diff --git a/app/Support/Readiness/OnboardingChecks.php b/app/Support/Readiness/OnboardingChecks.php index 265ca63..5c99962 100644 --- a/app/Support/Readiness/OnboardingChecks.php +++ b/app/Support/Readiness/OnboardingChecks.php @@ -22,7 +22,13 @@ final class OnboardingChecks { return [ new Check( - key: 'onboarding.ssh_key', + // Named after the vault entry it checks (ssh.private_key), not + // a shorthand: ReadinessPageTest's guard walks every + // SecretVault::REGISTRY key and demands a Check key that + // CONTAINS it (dots turned to underscores) — 'onboarding. + // ssh_key' does not contain 'ssh_private_key' and slipped + // through unnoticed until that guard existed (Task 12). + key: 'onboarding.ssh_private_key', group: self::GROUP, severity: Check::SEVERITY_BLOCKING, label: __('readiness.onboarding.ssh_key'), diff --git a/lang/de/admin.php b/lang/de/admin.php index 78b708f..b8baca3 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -30,6 +30,7 @@ return [ 'revenue' => 'Umsatz', 'mail' => 'E-Mail', 'integrations' => 'Integrationen', + 'readiness' => 'Bereitschaft', 'settings' => 'Einstellungen', 'two_factor_setup' => 'Zwei-Faktor-Anmeldung', ], @@ -93,6 +94,7 @@ return [ 'host_error' => 'Host :host meldet einen Fehler.', 'host_silent' => 'Host :host hat sich seit über :minutes Minuten nicht gemeldet.', 'monitoring_down' => ':n überwachte Instanz(en) nicht erreichbar.', + 'readiness_blocking' => '{1} :n Punkt auf der Bereitschaftsseite blockiert den Livebetrieb.|[2,*] :n Punkte auf der Bereitschaftsseite blockieren den Livebetrieb.', ], 'customers_sub' => 'Alle Kunden und ihre Pakete.', diff --git a/lang/de/readiness.php b/lang/de/readiness.php index f6be55c..0db7b84 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -6,6 +6,32 @@ * fehlt bleibt" — siehe den Kopfkommentar von App\Support\Readiness\Check. */ return [ + 'page_title' => 'Betriebsbereitschaft', + 'page_sub' => 'Jedes Pflichtfeld dieser Installation an einer Stelle — bis oben „Bereit für Livebetrieb" steht, ist hier noch etwas offen.', + 'ready_test' => 'Bereit für Testbetrieb', + 'ready_live' => 'Bereit für Livebetrieb', + 'not_ready' => '{1} Noch nicht bereit — :n blockierender Punkt offen|[2,*] Noch nicht bereit — :n blockierende Punkte offen', + + 'group' => [ + 'billing' => 'Abrechnung', + 'onboarding' => 'Host-Onboarding', + 'provisioning' => 'Bereitstellung', + 'delivery' => 'Zustellung', + 'operation' => 'Betrieb', + ], + + 'status' => [ + 'satisfied' => 'Erfüllt', + 'blocking' => 'Blockierend', + 'warning' => 'Warnung', + ], + + 'run_check' => 'Prüfen', + 'check_ok' => 'Geprüft: in Ordnung', + 'check_failed' => 'Geprüft: nicht in Ordnung', + 'fix_link' => 'Beheben', + 'last_heartbeat' => 'Letzter Herzschlag: :when', + 'mode' => [ 'test' => 'Testbetrieb', 'live' => 'Livebetrieb', diff --git a/lang/en/admin.php b/lang/en/admin.php index 5841b5c..2d53112 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -30,6 +30,7 @@ return [ 'revenue' => 'Revenue', 'mail' => 'Email', 'integrations' => 'Integrations', + 'readiness' => 'Readiness', 'settings' => 'Settings', 'two_factor_setup' => 'Two-factor login', ], @@ -93,6 +94,7 @@ return [ 'host_error' => 'Host :host reports an error.', 'host_silent' => 'Host :host has not checked in for over :minutes minutes.', 'monitoring_down' => ':n monitored instance(s) unreachable.', + 'readiness_blocking' => '{1} :n item on the readiness page blocks going live.|[2,*] :n items on the readiness page block going live.', ], 'customers_sub' => 'All customers and their plans.', diff --git a/lang/en/readiness.php b/lang/en/readiness.php index a6a37a2..f23b629 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -6,6 +6,32 @@ * the header comment of App\Support\Readiness\Check. */ return [ + 'page_title' => 'Readiness', + 'page_sub' => 'Every required field of this installation in one place — until it says "Ready for live mode" at the top, something here is still open.', + 'ready_test' => 'Ready for test mode', + 'ready_live' => 'Ready for live mode', + 'not_ready' => '{1} Not ready yet — :n blocking item open|[2,*] Not ready yet — :n blocking items open', + + 'group' => [ + 'billing' => 'Billing', + 'onboarding' => 'Host onboarding', + 'provisioning' => 'Provisioning', + 'delivery' => 'Delivery', + 'operation' => 'Operation', + ], + + 'status' => [ + 'satisfied' => 'Satisfied', + 'blocking' => 'Blocking', + 'warning' => 'Warning', + ], + + 'run_check' => 'Check', + 'check_ok' => 'Checked: OK', + 'check_failed' => 'Checked: not OK', + 'fix_link' => 'Fix', + 'last_heartbeat' => 'Last heartbeat: :when', + 'mode' => [ 'test' => 'Test mode', 'live' => 'Live mode', diff --git a/resources/views/livewire/admin/readiness.blade.php b/resources/views/livewire/admin/readiness.blade.php new file mode 100644 index 0000000..0d1eb5e --- /dev/null +++ b/resources/views/livewire/admin/readiness.blade.php @@ -0,0 +1,88 @@ +
+
+

{{ __('readiness.page_title') }}

+

{{ __('readiness.page_sub') }}

+
+ + {{-- The one line that matters. Everything below is detail. --}} +
+ +

+ @if ($ready) + {{ $mode->isTest() ? __('readiness.ready_test') : __('readiness.ready_live') }} + @else + {{ trans_choice('readiness.not_ready', $blockingCount, ['n' => $blockingCount]) }} + @endif +

+
+ + @foreach ($groups as $groupKey => $checks) +
+

{{ __('readiness.group.'.$groupKey) }}

+ +
    + @foreach ($checks as $check) + @php + $badgeStatus = $check->satisfied ? 'active' : ($check->isBlocking() ? 'failed' : 'warning'); + $statusLabel = $check->satisfied + ? __('readiness.status.satisfied') + : ($check->isBlocking() ? __('readiness.status.blocking') : __('readiness.status.warning')); + $iconName = $check->satisfied ? 'check' : ($check->isBlocking() ? 'x' : 'alert-triangle'); + $iconTone = $check->satisfied ? 'text-success' : ($check->isBlocking() ? 'text-danger' : 'text-warning'); + $result = $results[$check->key] ?? null; + $heartbeat = $heartbeats[$check->key] ?? null; + @endphp +
  • + + +
    +
    + {{ $check->label }} + {{ $statusLabel }} +
    + + @unless ($check->satisfied) +

    {{ $check->breaks }}

    + @endunless + + @if ($heartbeat !== null) +

    + {{ __('readiness.last_heartbeat', ['when' => $heartbeat]) }} +

    + @endif + + @if ($result !== null) +

    + {{ $result['ok'] ? __('readiness.check_ok') : __('readiness.check_failed') }} + @if (($result['reason'] ?? null) !== null) + — {{ $result['reason'] }} + @endif +

    + @endif +
    + +
    + @if (in_array($check->key, $runnable, true)) + + + {{ __('readiness.run_check') }} + + @endif + + + {{ __('readiness.fix_link') }} + + +
    +
  • + @endforeach +
+
+ @endforeach +
diff --git a/routes/admin.php b/routes/admin.php index 692445a..bf0a3e1 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -2,8 +2,11 @@ use App\Http\Controllers\ImpersonationController; use App\Livewire\Admin; +use App\Models\Invoice; +use App\Services\Billing\InvoiceRenderer; use App\Services\Deployment\UpdateChannel; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; /* @@ -59,12 +62,12 @@ Route::get('/invoices/new', Admin\NewInvoice::class)->name('invoices.new'); // than a Livewire action: a download is a download, and routing one through a // component round-trip only adds a way for it to fail. Route::get('/invoices/{uuid}/pdf', function (string $uuid) { - Illuminate\Support\Facades\Gate::authorize('site.manage'); + Gate::authorize('site.manage'); - $invoice = App\Models\Invoice::query()->where('uuid', $uuid)->firstOrFail(); + $invoice = Invoice::query()->where('uuid', $uuid)->firstOrFail(); return response()->streamDownload( - fn () => print (app(App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)), + fn () => print (app(InvoiceRenderer::class)->forInvoice($invoice)), $invoice->number.'.pdf', ['Content-Type' => 'application/pdf'], ); @@ -79,6 +82,11 @@ Route::get('/integrations', Admin\Integrations::class)->name('integrations'); // uses in routes/web.php. Route::get('/secrets', fn () => redirect()->route('admin.integrations', status: 301))->name('secrets'); Route::get('/infrastructure', fn () => redirect()->route('admin.integrations', status: 301))->name('infrastructure'); +// Everything App\Support\Readiness knows how to check, in one place — gated +// the same way as Integrations just above, since it is the page where both +// halves that page configures (Settings and the vault) come back together as +// one list of what is still missing. +Route::get('/readiness', Admin\Readiness::class)->name('readiness'); Route::get('/settings', Admin\Settings::class)->name('settings'); // Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without // exempting the rest of admin.settings — see that middleware for why. diff --git a/tests/Feature/Admin/ConsoleReportsRealDataTest.php b/tests/Feature/Admin/ConsoleReportsRealDataTest.php index 1ae3419..20b9ede 100644 --- a/tests/Feature/Admin/ConsoleReportsRealDataTest.php +++ b/tests/Feature/Admin/ConsoleReportsRealDataTest.php @@ -1,13 +1,13 @@ assertViewHas('hostLoad', []) ->assertViewHas('runs', []) - ->assertViewHas('notices', []) - ->assertSee(__('admin.no_open_runs')) - ->assertSee(__('admin.no_notices')); + // Not notices === [] any more (Task 12): an installation with nothing + // configured is not the same as an installation with nothing WRONG. + // Readiness::blocking() reports a real, non-invented gap here (nine + // unmet checks on a bare database — no Stripe key, no DNS token, no + // scheduler heartbeat yet, and so on), and hiding that behind an + // empty notices list would be exactly the kind of fiction this file + // exists to catch. What is still true of a truly empty estate: no + // INCIDENT-shaped notice invents itself out of nothing — the one + // notice present is the readiness gap, and nothing else. + ->assertViewHas('notices', fn (array $notices) => collect($notices)->pluck('route')->all() === ['admin.readiness']) + ->assertSee(__('admin.no_open_runs')); }); it('counts what is actually there', function () { @@ -106,16 +113,24 @@ it('states monthly revenue off the contracts, with a yearly term divided', funct ->assertDontSee('999,00'); }); -it('raises a notice only when something is actually wrong', function () { +/** + * `admin.systems_ok` can no longer be asserted on a bare install (Task 12): a + * brand new console always starts with at least one readiness gap, which is + * exactly the point of Readiness::blocking() existing at all — a badge + * claiming nothing is wrong would itself be the invented-data problem this + * file is about. What this test is actually for still holds: an INCIDENT + * notice (a failed run, a silent host…) appears only once one is real, on + * top of whatever readiness gaps a fresh install already carries. + */ +it('raises an incident notice only when something is actually wrong', function () { $clean = Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class); - $clean->assertSee(__('admin.systems_ok')); + $clean->assertDontSee(__('admin.notice.failed_runs', ['n' => 1])); ProvisioningRun::factory()->create(['status' => ProvisioningRun::STATUS_FAILED]); Livewire::actingAs(operator('Owner'), 'operator') ->test(Overview::class) - ->assertSee(__('admin.notice.failed_runs', ['n' => 1])) - ->assertDontSee(__('admin.systems_ok')); + ->assertSee(__('admin.notice.failed_runs', ['n' => 1])); }); it('gets the same committed storage whether or not the sum was preloaded', function () { diff --git a/tests/Feature/Readiness/DeliveryChecksTest.php b/tests/Feature/Readiness/DeliveryChecksTest.php index 08dcea2..b3c49c0 100644 --- a/tests/Feature/Readiness/DeliveryChecksTest.php +++ b/tests/Feature/Readiness/DeliveryChecksTest.php @@ -92,12 +92,12 @@ it('treats missing reply templates as a warning, never as a blocker', function ( it('treats a missing inbound mail password as a warning, never as a blocker', function () { OperatingMode::set(OperatingMode::Live); - expect(deliveryCheck('delivery.inbound_password')->satisfied)->toBeFalse(); - expect(deliveryCheck('delivery.inbound_password')->severity)->toBe(Check::SEVERITY_WARNING); + expect(deliveryCheck('delivery.inbound_mail_password')->satisfied)->toBeFalse(); + expect(deliveryCheck('delivery.inbound_mail_password')->severity)->toBe(Check::SEVERITY_WARNING); app(SecretVault::class)->put('inbound_mail.password', 'secret', Operator::factory()->create(), OperatingMode::Live); - expect(deliveryCheck('delivery.inbound_password')->satisfied)->toBeTrue(); + expect(deliveryCheck('delivery.inbound_mail_password')->satisfied)->toBeTrue(); }); it('names what breaks for every delivery check, not just what is missing', function () { @@ -105,7 +105,7 @@ it('names what breaks for every delivery check, not just what is missing', funct 'delivery.mailer_not_log', 'delivery.mailbox', 'delivery.mail_templates', - 'delivery.inbound_password', + 'delivery.inbound_mail_password', ] as $key) { expect(deliveryCheck($key)->breaks)->not->toBe(''); } diff --git a/tests/Feature/Readiness/OnboardingChecksTest.php b/tests/Feature/Readiness/OnboardingChecksTest.php index 30b2dca..78da312 100644 --- a/tests/Feature/Readiness/OnboardingChecksTest.php +++ b/tests/Feature/Readiness/OnboardingChecksTest.php @@ -45,7 +45,7 @@ it('needs an ssh private key', function () { // zurückgesetzt, damit der Test nicht vom Entwicklerrechner abhängt. config()->set('provisioning.ssh.private_key', ''); - expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeFalse(); + expect(onboardingCheck('onboarding.ssh_private_key')->satisfied)->toBeFalse(); app(SecretVault::class)->put( 'ssh.private_key', @@ -54,7 +54,7 @@ it('needs an ssh private key', function () { OperatingMode::Live, ); - expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeTrue(); + expect(onboardingCheck('onboarding.ssh_private_key')->satisfied)->toBeTrue(); }); it('needs a usable vault key', function () { @@ -101,7 +101,7 @@ it('needs all three wireguard values, not just some', function () { it('names what breaks for every onboarding check, not just what is missing', function () { foreach ([ - 'onboarding.ssh_key', + 'onboarding.ssh_private_key', 'onboarding.secrets_key', 'onboarding.vpn_config_key', 'onboarding.wg_hub', diff --git a/tests/Feature/ReadinessPageTest.php b/tests/Feature/ReadinessPageTest.php new file mode 100644 index 0000000..8e29105 --- /dev/null +++ b/tests/Feature/ReadinessPageTest.php @@ -0,0 +1,89 @@ +actingAs(Operator::factory()->role('Read-only')->create(), 'operator') + ->get(route('admin.readiness')) + ->assertForbidden(); +}); + +it('lists every check, satisfied or not', function () { + // Livewire::actingAs() needs the 'operator' guard spelled out — omitting it + // signed the test in on the 'web' guard instead and cost Task 11 a failing + // test for exactly this reason. + Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->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(), 'operator') + ->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. + // + // Http::fake() steht hier unbedingt, nicht weil dieser eine Testpfad das + // Netzwerk erreicht (DnsTokenCheck bricht schon vorher an blank($token) + // || blank($zone) ab), sondern weil JEDER Test, der runCheck() anfasst, + // dieselbe Absicherung tragen soll — ein Testpfad, der heute früh + // ausscheidet, ist keine Garantie, dass er es morgen noch tut. + Http::fake(); + + Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->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. + Http::fake(); + + Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->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); + + // NICHT expect($covered)->toContain($needle, $message) — Pest's + // toContain() ist variadic, ein zweites Argument ist ein zweiter + // GEFORDERTER Substring, keine eigene Fehlermeldung. Genau diese Falle + // dokumentiert tests/Feature/ModalHeightTest.php bereits selbst + // ("asserted that the footer contains its own filename, which is why + // this failed on a file that was correct") — der Brief-Code für diesen + // Test tappte hinein: er scheiterte auf JEDEM Eintrag, auch auf + // 'stripe_secret', das längst abgedeckt ist. str_contains() + toBeTrue() + // mit Nachricht liefert die Prüfung UND den lesbaren Hinweis. + expect(str_contains($covered, $needle))->toBeTrue( + "Der Tresor-Eintrag [{$secret}] taucht auf der Bereitschaftsseite nicht auf." + ); + } +});