From e83b17ec379b7c4dc51b1908d0515eaff00e832c Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 15:59:34 +0200 Subject: [PATCH] Put every missing field on one page before a customer pays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin\Readiness renders App\Support\Readiness::byGroup() end to end: state, label, breaks sentence and a fix link for every check, a button on the four that have to ask a real service (DnsTokenCheck, WireguardEndpointCheck, VmTemplateCheck, StripeCheck) and only ever run on demand, and one headline line — ready for the operator's current mode, or how many blocking items are still open. Wired into the nav and into Overview's own notice list beside the same gate as Admin\Integrations, since both halves it configures come back together on this page. Fixed two check keys the guard test caught for real: onboarding.ssh_key and delivery.inbound_password did not textually contain the vault key they check (ssh.private_key, inbound_mail.password), which is exactly the kind of silent gap this whole task exists to catch. Renamed to onboarding.ssh_private_key and delivery.inbound_mail_password, with the two existing test files that referenced the old keys updated to match. Also fixed the guard test's own assertion: Pest's toContain() is variadic, so a second string argument is a second required substring, not a failure message (the same trap tests/Feature/ModalHeightTest.php already documents about itself) — it failed on every entry, including ones already covered. Replaced with str_contains()+toBeTrue(), which keeps the check and the message both. Two pre-existing Overview tests assumed a bare install has nothing to report; it now correctly always starts with a readiness gap, so both were narrowed to what they actually test — no invented data, no invented incident. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Overview.php | 19 +- app/Livewire/Admin/Readiness.php | 176 ++++++++++++++++++ app/Support/Navigation.php | 4 + app/Support/Readiness/DeliveryChecks.php | 5 +- app/Support/Readiness/OnboardingChecks.php | 8 +- lang/de/admin.php | 2 + lang/de/readiness.php | 26 +++ lang/en/admin.php | 2 + lang/en/readiness.php | 26 +++ .../views/livewire/admin/readiness.blade.php | 88 +++++++++ routes/admin.php | 14 +- .../Admin/ConsoleReportsRealDataTest.php | 37 ++-- .../Feature/Readiness/DeliveryChecksTest.php | 8 +- .../Readiness/OnboardingChecksTest.php | 6 +- tests/Feature/ReadinessPageTest.php | 89 +++++++++ 15 files changed, 485 insertions(+), 25 deletions(-) create mode 100644 app/Livewire/Admin/Readiness.php create mode 100644 resources/views/livewire/admin/readiness.blade.php create mode 100644 tests/Feature/ReadinessPageTest.php 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." + ); + } +});