Put every missing field on one page before a customer pays
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 <noreply@anthropic.com>feature/betriebsmodus
parent
fe1110de71
commit
e83b17ec37
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\Dns\DnsTokenCheck;
|
||||
use App\Services\Proxmox\VmTemplateCheck;
|
||||
use App\Services\Stripe\StripeCheck;
|
||||
use App\Services\Vpn\WireguardEndpointCheck;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Readiness as ReadinessRegistry;
|
||||
use App\Support\Readiness\Check;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* The one page an operator setting up a live server goes through top to
|
||||
* bottom, until nothing blocking is left — App\Support\Readiness rendered,
|
||||
* with a button on the handful of checks that have to ask a real service
|
||||
* instead of just reading a field.
|
||||
*
|
||||
* Aliased on import (`ReadinessRegistry`) rather than `use App\Support\
|
||||
* Readiness;` bare: this class is ALSO named Readiness, in the same PHP
|
||||
* process, and an unaliased reference here would resolve to itself — which
|
||||
* has no byGroup()/blocking()/isReady() — rather than to the registry.
|
||||
*
|
||||
* mount() checks the same gate as Admin\Integrations
|
||||
* (Gate::any(['hosts.manage', 'secrets.manage'])), because this page is where
|
||||
* both halves it configures — App\Support\Settings and SecretVault — come
|
||||
* back together as one list of what is missing.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Readiness extends Component
|
||||
{
|
||||
/**
|
||||
* Prüfschlüssel → Klasse. Die Liste IST die Erlaubnis.
|
||||
*
|
||||
* runCheck() receives its argument from wire:click in the browser — a
|
||||
* string a stranger could type into devtools just as easily as an
|
||||
* operator clicking the real button. Without this fixed map, that
|
||||
* argument would be a way to instantiate and run ANY class through app().
|
||||
* Four entries: the three on-demand checks Task 9 built (each asks a real
|
||||
* service instead of only reading a field) plus StripeCheck, which
|
||||
* already had this exact run() shape from Admin\Integrations' own
|
||||
* "Testen" button.
|
||||
*/
|
||||
private const RUNNABLE = [
|
||||
'provisioning.dns_token' => 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<string, array<string, mixed>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('readiness.page_title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('readiness.page_sub') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- The one line that matters. Everything below is detail. --}}
|
||||
<div class="flex items-center gap-3 rounded-lg border p-5 shadow-xs animate-rise
|
||||
{{ $ready ? 'border-success-border bg-success-bg' : 'border-warning-border bg-warning-bg' }}">
|
||||
<x-ui.icon :name="$ready ? 'shield-check' : 'alert-triangle'"
|
||||
class="{{ $ready ? 'text-success' : 'text-warning' }}" />
|
||||
<p class="text-lg font-semibold {{ $ready ? 'text-success' : 'text-warning' }}">
|
||||
@if ($ready)
|
||||
{{ $mode->isTest() ? __('readiness.ready_test') : __('readiness.ready_live') }}
|
||||
@else
|
||||
{{ trans_choice('readiness.not_ready', $blockingCount, ['n' => $blockingCount]) }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@foreach ($groups as $groupKey => $checks)
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise">
|
||||
<h2 class="font-semibold text-ink">{{ __('readiness.group.'.$groupKey) }}</h2>
|
||||
|
||||
<ul class="mt-3 divide-y divide-line">
|
||||
@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
|
||||
<li class="flex flex-wrap items-start gap-3 py-3">
|
||||
<x-ui.icon :name="$iconName" class="mt-0.5 size-4 shrink-0 {{ $iconTone }}" />
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-ink">{{ $check->label }}</span>
|
||||
<x-ui.badge :status="$badgeStatus">{{ $statusLabel }}</x-ui.badge>
|
||||
</div>
|
||||
|
||||
@unless ($check->satisfied)
|
||||
<p class="mt-1 text-sm text-muted">{{ $check->breaks }}</p>
|
||||
@endunless
|
||||
|
||||
@if ($heartbeat !== null)
|
||||
<p class="mt-1 font-mono text-xs text-faint">
|
||||
{{ __('readiness.last_heartbeat', ['when' => $heartbeat]) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if ($result !== null)
|
||||
<p class="mt-1 text-xs {{ $result['ok'] ? 'text-success' : 'text-danger' }}">
|
||||
{{ $result['ok'] ? __('readiness.check_ok') : __('readiness.check_failed') }}
|
||||
@if (($result['reason'] ?? null) !== null)
|
||||
— <span class="font-mono">{{ $result['reason'] }}</span>
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
@if (in_array($check->key, $runnable, true))
|
||||
<x-ui.button variant="secondary" size="sm"
|
||||
wire:click="runCheck('{{ $check->key }}')"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="runCheck('{{ $check->key }}')">
|
||||
<x-ui.icon name="refresh" class="size-4" />
|
||||
{{ __('readiness.run_check') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
|
||||
<a href="{{ $this->checkUrl($check) }}" wire:navigate
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold text-accent-text hover:underline">
|
||||
{{ __('readiness.fix_link') }}
|
||||
<x-ui.icon name="external-link" class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Instances as AdminInstances;
|
||||
use App\Livewire\Admin\Overview;
|
||||
use App\Livewire\Admin\Revenue;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\Subscription;
|
||||
use App\Livewire\Admin\Instances as AdminInstances;
|
||||
use App\Livewire\Admin\Overview;
|
||||
use App\Livewire\Admin\Revenue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
|
@ -19,7 +19,6 @@ use Livewire\Livewire;
|
|||
* twelve-month growth curve. It read like a running business and measured
|
||||
* nothing. These tests exist so that cannot come back quietly.
|
||||
*/
|
||||
|
||||
it('reports an empty estate as empty, not as a going concern', function () {
|
||||
// The strongest statement of the old bug: with nothing in the database the
|
||||
// console still claimed 42 customers, 39 instances, 4 hosts and €7,842 a
|
||||
|
|
@ -37,9 +36,17 @@ it('reports an empty estate as empty, not as a going concern', function () {
|
|||
})
|
||||
->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 () {
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Readiness as ReadinessPage;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\Readiness;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('is closed to operators without either capability', function () {
|
||||
$this->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."
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue