Compare commits

...

10 Commits

Author SHA1 Message Date
nexxo 2d915be003 Put back the docblock space pint took on the way past
One character in a @return line of webhooks(), untouched by anything this
branch does. It was only reformatted because the file happened to be on
the pint path of the prefix-rule commit. A style fix to an unrelated line
is noise in a diff a reviewer reads line by line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:31:04 +02:00
nexxo 786318b6d4 Never tell anyone to delete a catalogue their contracts bill on
The refusal added in the previous commit had a state it read wrongly, and
the wrong reading was destructive.

record() sat at the END of handle() and in no try/finally, while
createProduct() and ensure() throw uncaught. A run that died after the
first object left hasStoredObjects() true and recorded() null. The same
state arises with no failure at all: CheckoutController → PlanPrices::
ensure() and BookAddon → SyncStripeAddonItems → AddonPrices::ensure()
mint missing prices and never call record().

In that state the next run — in the SAME mode — refused with the foreign
account message and its "Clear plan_families.stripe_product_id,
plan_prices.stripe_price_id and the … registers first", and
billing.catalogue_synced blocked with the same text. The objects were
from the account in force. The right move was to resume, which is what
the idempotency keys exist for; instead an operator was handed a delete
instruction for a catalogue live contracts are billed on.

Two changes:

  1. record() moves ahead of the creation loop, right behind the refusal.
     There it is already proved that either nothing is stored or what is
     stored belongs to the active account, so the moment carries the
     claim just as well — and a run that dies part-way can no longer
     leave a state that contradicts itself.
  2. "Origin never recorded" gets its own sentence and its own cure,
     separate from "established: other account".
     StripeCatalogueMode::matchesActiveMode() becomes
     belongsToAnotherMode(), which is only true where the other account
     is fact. The check still blocks — the origin cannot be proved — but
     it says "run the sync again", and it names no register to empty.

Red first:

  ⨯ it takes up a catalogue whose origin was never written down
      Failed asserting that 1 matches expected 0.
  ⨯ it leaves no half-built catalogue that contradicts itself when a run dies
      Failed asserting that null is identical to an object of class "App\Support\OperatingMode".
  ⨯ it tells an unrecorded origin apart from a foreign account

The two states are held apart by assertion, not by wording: only the
foreign-account sentence may name stripe_plan_prices, and the unrecorded
one must name stripe:sync-catalogue instead. The existing foreign-account
test keeps its teeth.

Full suite: 1817 passed, 6366 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:56 +02:00
nexxo 26cf1a72a0 Remember which Stripe account the catalogue was built in
The mode switches the credentials. It does not switch what was made from
them: a Price id and a Product id belong to the account that issued them,
and test and live are two accounts. Every credential got two slots on this
branch; the ids derived from them sit in single-valued columns.

So the planned sequence of this installation ended in the exact false
green this page exists to rule out — sync in test, store the live key,
switch, and billing.catalogue_synced went on reporting satisfied because
it only asked whether the column was filled. "Bereit für Livebetrieb",
and the first real order got "No such price".

Detection, not repair. Stripe does not put the account in the id —
prod_… and price_… look the same in both, only KEYS carry _test_/_live_
— so the origin cannot be read back out of a stored id, and asking
Stripe is out: this page reaches nothing over the network on a page load.
What is left is to write it down at sync time, which is what
App\Support\StripeCatalogueMode does.

One setting for the whole catalogue is only honest because
stripe:sync-catalogue now REFUSES a run into a catalogue that belongs to
the other account. Without that, the run would skip every row that already
carries an id, answer "already in step", and record an account it never
touched — the same lie one level down. The registers count as stored
objects too: inStep() takes a registered row as proof on its own for the
reverse-charge half.

The `breaks` sentence says what happens (checkout fails, no order) and
what actually helps, including the awkward half: re-running the sync is
not enough, the pointers and both registers have to be cleared first.

The slot migration backfills the one case it can prove: whatever is at
Stripe was made with the one key this installation has ever stored, so it
belongs to the account that key opens. Otherwise a long-synced catalogue
would read as "origin unknown" and the page would demand a re-sync nobody
needs.

Red first:

  ⨯ it does not call the catalogue synced when its ids belong to the other account
  ⨯ it says the sale is refused and a fresh sync is needed, not that a column is empty
  ⨯ it records the mode its objects were created in
  ⨯ it refuses to work into a catalogue that belongs to the other account
  ⨯ it syncs into the new account once the stale ids are cleared

Full suite: 1814 passed, 6357 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:12:56 +02:00
nexxo 823eeaf413 Refuse a key that does not belong to the slot it sits in
put('stripe.secret', 'sk_live_REALMONEY') in test mode was accepted,
get() handed it out, billing.stripe_secret reported satisfied, and the
badge above it said "Testbetrieb". The strict rule closed the automatic
route into that state (no fallback); the typed one stayed open.

The prefix decides it without touching the network, and that rule now
lives in ONE place — OperatingMode::ofStripeKey() — called by the three
that were answering it separately: the slot migration (unchanged verdict,
`?? Live` for a value it cannot place), StripeCheck's `live` flag
(unchanged verdict, null stays false), and the readiness check, which
never asked at all. A key it cannot place is not reported as a
contradiction: this check only says what it can prove.

The two directions get their own `breaks` sentence, because the
consequences are opposite — real money moving while the console says
test, versus no money moving while the order looks paid.

The "Prüfen" button no longer contradicts the check either: the page
rendered only ok/reason, so a live key in the test slot answered
"Geprüft: in Ordnung". It now names the account the key belongs to.

Red first:

  ⨯ it refuses a live key sitting in the test slot
  ⨯ it refuses a test key sitting in the live slot
  ⨯ it says what the wrong key does, not that a field is empty

Guard tests (ConfirmInModal, ModalHeight, IconLayout, DisplayTimezone)
run with the blade change: green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:05:58 +02:00
nexxo 5094f70c19 Say which slot the value in force is really coming from
Mode test, dns.token only in the :live slot, HETZNER_DNS_TOKEN also in the
.env — the state this installation is in the moment the migration runs:

  get()      -> "live-token-value"   (the stored live row is in force)
  source()   -> "environment"        -> card reads "Aus der Serverdatei"
  outline()  -> null                 -> no "Hinterlegt" block at all

So the console sent an operator to the .env to rotate a token that the
database supplies, where the edit would have had no effect. R19's dummy,
literally: a display that reads like an answer, is wrong, and stops the
next person looking.

source(), outline() and updatedAt() now ask the same one place get() does
(inForce()/fallback()), and the answer gets a THIRD value, 'stored_live'.
Not 'stored': that shows the Vergessen button, which would then have
pointed at the empty test slot. Strict entries (Stripe) still have no
fallback in either direction — pinned by its own test.

Red first (before the vault changed):

  ⨯ it names the live slot as the source when it is the one in force
    -'stored_live'
    +'environment'
  ⨯ it shows the outline and the date of the value that is actually in force

Two existing expectations moved from 'none' to 'stored_live' with the
reason written beside them: 'none' meant "nichts hinterlegt" about a
credential that was in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:01:33 +02:00
nexxo 507c976035 Pin the one question that must never throw
isConfigured() had no coverage at all: replacing its body with main's
`filled($this->secret())` left all 1795 tests green, while at runtime the
same mutation throws StripeNotConfigured. Six callers ask that question
precisely to avoid an exception — CheckoutController:87 would answer a
customer with a 500 instead of a sentence.

The merge makes it urgent rather than merely untidy: git auto-merges
HttpStripeClient.php without a conflict marker, so nothing forces anyone
to look at the one line where the two branches disagree.

Proved red by that exact mutation before it went in, then reverted:

  ⨯ it answers the configured question instead of throwing it
    StripeNotConfigured: No Stripe secret is stored for the [test] operating mode.
    at app/Services/Stripe/HttpStripeClient.php:376

Http::fake() + assertNothingSent(): the question is asked on every
checkout and may only read the vault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:58:22 +02:00
nexxo 49d528dbea Fix round: point at the real tab, hide the button nobody can press, prove the quiet page load
Fixes four Important findings from the review of Task 12.

Corrected nine check `tab` values at the source (BillingChecks was already
correct; OnboardingChecks/ProvisioningChecks/DeliveryChecks all carried the
pre-redesign 'integrations' value, which is not a member of
Integrations::TABS): four onboarding checks now point at 'platform' or 'env'
depending on where their field actually is, five provisioning/delivery
checks point at 'services'. checkUrl()'s match-block resolver — praised as
correct for the six checks that point at a genuinely different admin page —
is unchanged; its `default` arm is now a pure safety net, not a route any
check actually relies on.

Wrapped the "Prüfen" button in @can('secrets.manage'): mount() admits
hosts.manage OR secrets.manage, but runCheck() requires secrets.manage alone,
so an Admin-role operator could see a button that 403s on press.

Fixed a docblock on HEARTBEAT_KEYS that asserted a staleness threshold was
kept in sync with OperationChecks::STALE_AFTER_MINUTES — that constant is a
key/settings-name map with no threshold of its own, and no such
synchronisation exists.

Three new tests, each confirmed red against a deliberately reintroduced
version of the bug it covers before being confirmed green: every check's tab
value against where its field actually lives, the run-check button hidden
from an operator who cannot press it, and Http::assertNothingSent() after a
plain page load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:26:10 +02:00
nexxo e83b17ec37 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>
2026-07-30 15:59:34 +02:00
nexxo fe1110de71 Fix round: name the slot on the card, and cover the modal that already worked
Names which of the two slots a card actually reads and writes right now, at
the card an operator types into (not only the page-top badge) — save() and
render() resolve without a mode argument, so the card looked identical in
both modes while quietly acting on whichever slot was active. Also adds the
ConfirmSwitchMode coverage IntegrationsPageTest already carries for
ConfirmSaveSecret/ConfirmForgetSecret: a modal is reachable without the
page's route middleware, and reading the code is not the same as testing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:32:16 +02:00
nexxo 96f171b3b3 Put the switch where it governs, and say when it is thrown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:09:38 +02:00
40 changed files with 1784 additions and 84 deletions

View File

@ -11,6 +11,8 @@ use App\Services\Billing\AddonPrices;
use App\Services\Billing\PlanPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use App\Support\OperatingMode;
use App\Support\StripeCatalogueMode;
use Illuminate\Console\Command;
/**
@ -59,6 +61,14 @@ use Illuminate\Console\Command;
* Product for it would be a price list entry for something that may never
* exist.
*
* **A run belongs to ONE Stripe account, and it says which.** Products and
* Prices are account-bound, and the operating mode picks the key so the mode
* a run worked in is recorded (App\Support\StripeCatalogueMode) and a run into
* a catalogue that belongs to the other account is refused rather than allowed
* to skip its way to "already in step". Without that, switching to live left
* every stored id pointing at test objects while the readiness page went on
* reporting a synced catalogue, and the first real order met "No such price".
*
* **Modules were missing from this entirely**, which is why a booked module was
* charged in the month it was booked and never again: nothing in Stripe existed
* to put on the subscription. They are pushed at today's catalogue price on both
@ -83,6 +93,34 @@ class SyncStripeCatalogue extends Command
return self::FAILURE;
}
if (StripeCatalogueMode::hasStoredObjects() && StripeCatalogueMode::belongsToAnotherMode()) {
$this->error($this->staleCatalogueMessage());
// Auch der Trockenlauf endet hier, und mit FAILURE: er zählt, was
// FEHLT, und in diesem Zustand fehlt nichts — jede Zeile trägt
// schon eine ID, nur eine aus dem falschen Konto. „0 object(s)
// would be created" wäre die beruhigende Antwort auf die
// gefährliche Lage.
return self::FAILURE;
}
// VOR der Anlegeschleife, nicht danach. Hier ist bereits bewiesen, dass
// entweder nichts liegt oder das Vorhandene dem aktiven Konto gehört —
// der Zeitpunkt trägt die Aussage also genauso gut. Am Ende von
// handle() trug er sie NICHT: createProduct() und ensure() werfen
// ungefangen, und ein Lauf, der nach dem ersten angelegten Objekt
// stirbt, hinterließ IDs ohne Herkunft. Der nächste Lauf hielt das für
// ein fremdes Konto und trug dem Betreiber auf, einen Katalog zu leeren,
// an dem laufende Verträge abgerechnet werden — statt schlicht
// wiederaufzusetzen, wofür die Idempotenzschlüssel unten gebaut sind.
if (! $dryRun) {
if (StripeCatalogueMode::recorded() === null && StripeCatalogueMode::hasStoredObjects()) {
$this->line(' note taking up a catalogue whose account was never recorded; nothing is replaced.');
}
StripeCatalogueMode::record();
}
$created = 0;
foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) {
@ -137,6 +175,40 @@ class SyncStripeCatalogue extends Command
return self::SUCCESS;
}
/**
* Why a run is refused outright rather than left to skip its way through.
*
* A Stripe Product and a Stripe Price belong to the ACCOUNT that issued
* them, and a test account and a live account are two accounts. This
* command skips every row that already carries an id (PlanPrices::inStep()
* asks the register, not Stripe), so a run after the switch would report
* "already in step" and change nothing while recording that the
* catalogue now belongs to the account it never touched.
*
* The registers have to go too, not only the two columns: inStep() takes
* a registered row as proof on its own for the reverse-charge half, so a
* cleared pointer with the register left behind would leave exactly that
* half pointing at the old account, and nothing would say so.
*
* Only reached where the OTHER account is established fact. A catalogue
* whose account was never recorded is a different state with a different
* cure see StripeCatalogueMode::belongsToAnotherMode(). Telling that
* operator to clear anything would be telling them to delete a catalogue
* their live contracts bill on.
*/
private function staleCatalogueMessage(): string
{
return sprintf(
'The stored catalogue was created in the [%s] account; this installation is now in [%s] mode. '
.'Stripe objects belong to the account that issued them, and this command skips every row that '
.'already carries an id — so it cannot repair this by running again. Clear '
.'plan_families.stripe_product_id, plan_prices.stripe_price_id and the stripe_plan_prices / '
.'stripe_addon_prices registers first, then run it. Nothing was created.',
StripeCatalogueMode::recorded()?->value,
OperatingMode::current()->value,
);
}
/**
* The Stripe Prices one priced catalogue row is sold on, brought into step.
*

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Support\OperatingMode;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before the operating mode changes (R23).
*
* Wortgleiches Muster zu ConfirmSaveSecret: dieses Modal mutiert nichts selbst
* es ruft `OperatingMode::set()` nirgends auf. Bestätigen löst nur ein
* Ereignis aus, das Integrations::switchMode() auffängt; deren eigene Sperre
* (secrets.manage + kürzlich bestätigtes Passwort) bleibt die einzige Stelle,
* die tatsächlich etwas schreibt. Ein Modus, der hier gefälscht hereinkäme,
* muss trotzdem switchMode()s eigenes tryFrom() und guardSecrets() passieren.
*/
class ConfirmSwitchMode extends ModalComponent
{
public string $mode;
public function mount(string $mode): void
{
$this->authorize('secrets.manage');
// Ein Wert aus dem Aufrufort dieses Modals — dieselbe Prüfung, die
// switchMode() gleich noch einmal macht: ein unbekannter Modus 404t
// hier, statt eine Bestätigung für etwas zu zeigen, das nachher
// ohnehin still nichts tut.
abort_if(OperatingMode::tryFrom($mode) === null, 404);
$this->mode = $mode;
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('mode-switch-confirmed', mode: $this->mode);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-switch-mode');
}
}

View File

@ -7,6 +7,7 @@ use App\Services\Deployment\UpdateChannel;
use App\Services\Env\EnvFileEditor;
use App\Services\Env\InvalidEnvContentException;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Support\Facades\Artisan;
@ -188,6 +189,34 @@ class Integrations extends Component
}
}
// ---- The operating mode — governs every vault entry below it. ----
/**
* 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),
]));
}
// ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----
public function saveInfra(): void
@ -462,6 +491,7 @@ class Integrations extends Component
'canInfra' => $canInfra,
'unlocked' => $unlocked,
'usable' => $vault->isUsable(),
'mode' => OperatingMode::current(),
'restart' => $restart,
'entries' => collect(SecretVault::REGISTRY)
->map(fn (array $meta, string $key) => [

View File

@ -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;
}

View File

@ -0,0 +1,181 @@
<?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,
];
/**
* Check::key the App\Support\Settings row it is written under.
*
* Display only, and carries no threshold of its own: whether a heartbeat
* counts as fresh is decided exactly once, by
* App\Support\Readiness\OperationChecks::isFresh(), and shows up here
* only as the check's own `satisfied`/severity this map exists solely
* so heartbeatDisplay() knows which stored value to format for which
* check. (A previous version of this comment claimed a staleness
* threshold was kept in sync with OperationChecks::STALE_AFTER_MINUTES
* there is no such value here to keep in sync. CLAUDE.md's R19 names this
* exact shape of mistake a call that reads as an assurance and is not
* one as worse than no comment at all, because it stops the next
* reader from checking for themselves.)
*/
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: six of the nine `tab` values used across
* the five check groups name a DIFFERENT admin page entirely 'hosts',
* 'plans', 'datacenters', 'mail', 'templates' and 'company' are pages of
* their own, not Integrations tabs, and forwarding them as `?tab=` would
* either 404 or (since Integrations::mount() falls back silently to its
* own first tab for an unrecognised value) land quietly on the wrong
* section. Every OTHER `tab` value that exists in the five check group
* files today (checked at the source, Fix-Runde) really IS one of
* Integrations::TABS nine of them were not, until that same Fix-Runde
* corrected them there rather than papering over it here (see
* BillingChecks/OnboardingChecks/ProvisioningChecks/DeliveryChecks). The
* `default` arm below is therefore a pure safety net for a value nobody
* currently produces, not a route any check relies on kept so a future
* typo degrades to the Integrations landing tab instead of an
* UnhandledMatchError taking the whole page down.
*/
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;
}
}
}

View File

@ -131,16 +131,7 @@ final class SecretVault
{
$this->assertKnown($key);
$mode = OperatingMode::current();
$row = $this->row($key, $mode);
// Fall back to the live slot when the active mode's slot is empty.
// ONLY from test to live, never the other way: using a test credential
// in live operation because the real one is missing is the dangerous
// direction, and must stay an explicit, visible gap instead.
if ($row === null && $mode->isTest() && ! $this->isStrict($key)) {
$row = $this->row($key, OperatingMode::Live);
}
$row = $this->inForce($key, OperatingMode::current());
if ($row === null) {
// Strict entries never see the environment: see isStrict() below.
@ -162,13 +153,34 @@ final class SecretVault
}
}
/** Where the value in force comes from: stored, environment, or nowhere. */
/**
* Where the value in force under this mode comes from four answers now.
*
* 'stored_live' is the third, and it exists because the second-best answers
* were both lies. The measured case: mode test, dns.token only in the :live
* slot, HETZNER_DNS_TOKEN also in the .env. get() hands out the stored live
* value (the fallback it has always had), while this method reported
* 'environment' the console then read "Aus der Serverdatei" over a
* credential that is in force from the database, and an operator changing
* the .env line would have seen no effect at all. That is R19's dummy in
* miniature: a display that reads like an answer, is wrong, and stops the
* next person looking.
*
* Returning 'stored' instead would trade one lie for another: the console
* shows the "Vergessen" button only for 'stored', and it would then have
* pointed at the EMPTY test slot, which forget() would happily delete
* nothing from.
*
* The $mode argument names which mode is IN FORCE, not which row to look
* at same question get() answers, so that the two cannot disagree.
*/
public function source(string $key, ?OperatingMode $mode = null): string
{
$this->assertKnown($key);
return match (true) {
$this->row($key, $mode) !== null => 'stored',
$this->fallback($key, $mode) !== null => 'stored_live',
$this->isStrict($key) => 'none',
filled(config(self::REGISTRY[$key]['config'])) => 'environment',
default => 'none',
@ -177,12 +189,12 @@ final class SecretVault
public function outline(string $key, ?OperatingMode $mode = null): ?string
{
return $this->row($key, $mode)?->outline;
return $this->inForce($key, $mode)?->outline;
}
public function updatedAt(string $key, ?OperatingMode $mode = null): ?string
{
return $this->row($key, $mode)?->updated_at;
return $this->inForce($key, $mode)?->updated_at;
}
public function put(string $key, string $value, Operator $by, ?OperatingMode $mode = null): void
@ -234,6 +246,39 @@ final class SecretVault
return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first();
}
/**
* The stored row that actually applies under this mode: its own slot, or
* the live slot standing in for it.
*
* ONE place, called by get(), outline() and updatedAt() and asked by
* source() through fallback() because these four answering the same
* question separately is precisely how the console came to say "nothing
* stored" about a credential the provisioning run was using.
*/
private function inForce(string $key, ?OperatingMode $mode = null): ?object
{
return $this->row($key, $mode) ?? $this->fallback($key, $mode);
}
/**
* The live slot filling in for an empty test slot.
*
* ONLY from test to live, never the other way: using a test credential in
* live operation because the real one is missing is the dangerous
* direction, and must stay an explicit, visible gap instead. Strict entries
* (Stripe) have no fallback at all, in either direction.
*/
private function fallback(string $key, ?OperatingMode $mode = null): ?object
{
$mode ??= OperatingMode::current();
if (! $mode->isTest() || $this->isStrict($key)) {
return null;
}
return $this->row($key, OperatingMode::Live);
}
/** Does this entry carry the strict trait? Populated in task 3. */
private function isStrict(string $key): bool
{

View File

@ -3,6 +3,7 @@
namespace App\Services\Stripe;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use Illuminate\Support\Facades\Http;
use Throwable;
@ -50,7 +51,11 @@ final class StripeCheck
'business' => $account->json('settings.dashboard.display_name') ?: $account->json('business_profile.name'),
// Stripe does not label the key; the mode is inferred from the
// prefix, which is the only thing that is true of both key kinds.
'live' => str_starts_with($secret, 'sk_live_') || str_starts_with($secret, 'rk_live_'),
// Asked of OperatingMode, not spelled out again here: the same
// question decides which slot the migration files a key into and
// whether the readiness page reports a key sitting in the wrong
// one. Unrecognisable (null) stays false, exactly as before.
'live' => OperatingMode::ofStripeKey($secret) === OperatingMode::Live,
'restricted' => str_starts_with($secret, 'rk_'),
'webhooks' => $this->webhooks($secret),
];

View File

@ -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

View File

@ -34,6 +34,37 @@ enum OperatingMode: string
Settings::set(self::SETTING, $mode->value);
}
/**
* Zu welchem Modus ein Stripe-Schlüssel gehört am Präfix, ohne Netzverkehr.
*
* Stripe beschriftet den Schlüssel nicht; das Präfix ist das Einzige, was
* bei beiden Schlüsselarten (`sk_`, und der eingeschränkte `rk_`) wahr ist.
* Das reicht, und es muss reichen: die Bereitschaftsseite darf beim
* Seitenaufruf nichts erreichen, und die Migration läuft, bevor irgendein
* Dienst antwortet.
*
* `null` heißt „nicht entscheidbar", nicht „live". Wer daraus eine
* Vorgabe braucht, sagt sie an seiner Stelle selbst die Migration setzt
* `?? Live` (der Zustand vor ihr, also Fortsetzung statt neuer Behauptung),
* die Bereitschaftsprüfung meldet dagegen NUR einen Widerspruch, den sie
* beweisen kann.
*
* Diese eine Stelle, weil dieselbe Frage vorher an drei Orten unabhängig
* beantwortet wurde: in der Migration, in StripeCheck und gar nicht auf
* der Bereitschaftsseite. Drei Formulierungen einer Frage sind der Weg, auf
* dem sie auseinanderlaufen.
*/
public static function ofStripeKey(?string $secret): ?self
{
$secret = trim((string) $secret);
return match (true) {
str_starts_with($secret, 'sk_test_'), str_starts_with($secret, 'rk_test_') => self::Test,
str_starts_with($secret, 'sk_live_'), str_starts_with($secret, 'rk_live_') => self::Live,
default => null,
};
}
public function isTest(): bool
{
return $this === self::Test;

View File

@ -7,6 +7,7 @@ use App\Models\PlanPrice;
use App\Services\Secrets\SecretVault;
use App\Support\CompanyProfile;
use App\Support\OperatingMode;
use App\Support\StripeCatalogueMode;
use App\Support\StripeWebhookSecret;
/**
@ -22,18 +23,63 @@ final class BillingChecks
{
$mode = OperatingMode::current();
// get() löst den aktiven Modus selbst auf UND ist für Stripe strikt —
// ein Live-Schlüssel im LIVE-Platz zählt im Testbetrieb nicht als
// Nachweis, sonst widerspräche diese Seite der Kassensperre.
$secret = app(SecretVault::class)->get('stripe.secret');
// Was im Platz des aktiven Modus liegt, kann trotzdem der Schlüssel des
// anderen Kontos sein — eingetippt, nicht zurückgefallen. Am Präfix
// ohne Netzverkehr entscheidbar; `null` heißt „nicht entscheidbar" und
// wird hier bewusst NICHT als Widerspruch gemeldet: diese Prüfung sagt
// nur, was sie beweisen kann.
$keyMode = OperatingMode::ofStripeKey($secret);
$wrongSlot = $keyMode !== null && $keyMode !== $mode;
// stripe:sync-catalogue (SyncStripeCatalogue::handle()) only ever
// touches PUBLISHED versions — "a draft has promised nothing, and a
// Product for it would be a price list entry for something that may
// never exist" (its own header comment). A draft's own unsynced price
// is therefore not a gap; counting it anyway turns this check into an
// alarm that never clears on any installation that has ever drafted a
// next version, which stops it being read at all.
$published = PlanPrice::query()
->whereHas('version', fn ($query) => $query->whereNotNull('published_at'));
$unsyncedPrices = (clone $published)->whereNull('stripe_price_id')->exists();
// Eine gefüllte Spalte beweist nur, dass IRGENDEIN Konto diese ID
// ausgestellt hat. Welches, steht seit dem Abgleich in einer eigenen
// Zeile (StripeCatalogueMode) — ohne sie meldete diese Prüfung nach
// dem Umschalten auf Live weiter „erfüllt", und die erste echte
// Bestellung bekam von Stripe *No such price*. Ohne Netzverkehr
// entschieden: die Bereitschaftsseite erreicht beim Aufruf nichts.
//
// ZWEI Fälle, nicht einer. „Steht fest: anderes Konto" verlangt das
// Leeren der Zeiger und Register; „Herkunft nie festgehalten" verlangt
// nur einen neuen Lauf. Der zweite entsteht ohne jeden Kontowechsel —
// ein abgebrochener Abgleich, oder eine Bestellung, die sich ihren
// fehlenden Preis selbst geholt hat — und die Leeranweisung wäre dort
// die Aufforderung, einen Katalog zu löschen, an dem laufende Verträge
// abgerechnet werden.
$somethingSynced = (clone $published)->whereNotNull('stripe_price_id')->exists();
$wrongAccount = $somethingSynced && StripeCatalogueMode::belongsToAnotherMode();
$unknownOrigin = $somethingSynced && StripeCatalogueMode::recorded() === null;
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'),
// Zwei Ausfälle, zwei Folgen — und „ohne ihn nimmt die Kasse
// keine Bestellung an" wäre über einen Schlüssel, der DA ist,
// schlicht falsch.
breaks: $wrongSlot
? __('readiness.billing.stripe_secret_'.$mode->value.'_slot_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')),
satisfied: filled($secret) && ! $wrongSlot,
),
new Check(
key: 'billing.webhook_secret',
@ -83,20 +129,21 @@ final class BillingChecks
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.catalogue_synced'),
breaks: __('readiness.billing.catalogue_synced_breaks'),
// Der bewiesene Widerspruch zuerst: „noch nicht abgeglichen"
// wäre über einen Katalog, der abgeglichen IST — nur im anderen
// Konto — die falsche Anweisung, und es ist die teure Richtung
// (der Betreiber ruft den Befehl, bekommt „already in step"
// und glaubt, es sei erledigt). Danach die unbekannte Herkunft,
// deren Kur ein Lauf ist und kein Löschen. Zuletzt die Lücke.
breaks: match (true) {
$wrongAccount => __('readiness.billing.catalogue_account_breaks', [
'mode' => __('readiness.mode.'.$mode->value),
]),
$unknownOrigin => __('readiness.billing.catalogue_origin_breaks'),
default => __('readiness.billing.catalogue_synced_breaks'),
},
tab: 'services',
// stripe:sync-catalogue (SyncStripeCatalogue::handle()) only
// ever touches PUBLISHED versions — "a draft has promised
// nothing, and a Product for it would be a price list entry
// for something that may never exist" (its own header
// comment). A draft's own unsynced price is therefore not a
// gap; counting it anyway turns this check into an alarm that
// never clears on any installation that has ever drafted a
// next version, which stops it being read at all.
satisfied: PlanPrice::query()
->whereHas('version', fn ($query) => $query->whereNotNull('published_at'))
->whereNull('stripe_price_id')
->doesntExist(),
satisfied: ! $unsyncedPrices && ! $wrongAccount && ! $unknownOrigin,
),
];
}

View File

@ -53,12 +53,18 @@ 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'),
breaks: __('readiness.delivery.inbound_password_breaks'),
tab: 'integrations',
// The inbound-mail card (host/port/user/folder + this
// password) is on the 'services' tab — 'integrations' is not
// a member of Integrations::TABS at all (Fix-Runde, Befund 1).
tab: 'services',
satisfied: filled(app(SecretVault::class)->get('inbound_mail.password')),
),
];

View File

@ -22,12 +22,24 @@ 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'),
breaks: __('readiness.onboarding.ssh_key_breaks'),
tab: 'integrations',
// The SSH identity card lives on the 'platform' tab
// (integrations.blade.php: "own machines"), beside the
// public-key setting it pairs with — not 'integrations',
// which is not a member of Integrations::TABS at all and
// sent an operator clicking this exact blocking check to the
// wrong tab (Fix-Runde, Befund 1).
tab: 'platform',
// EstablishSshTrust only deploys the PUBLIC half. Every step
// after it (HostStep's own shell helper), and later every
// Traefik write on a host already onboarded (SshTraefikWriter),
@ -40,7 +52,10 @@ final class OnboardingChecks
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.secrets_key'),
breaks: __('readiness.onboarding.secrets_key_breaks'),
tab: 'integrations',
// SECRETS_KEY has no field anywhere on Integrations' 'services'
// or 'platform' tabs — the only place an operator can actually
// set it is the raw .env editor on the 'env' tab.
tab: 'env',
// isUsable() rather than a bare filled() on the config value —
// SecretCipher's own docblock warns that a second, shorter
// rule that only checks emptiness lets a malformed-but-non-
@ -54,7 +69,10 @@ final class OnboardingChecks
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.vpn_config_key'),
breaks: __('readiness.onboarding.vpn_config_key_breaks'),
tab: 'integrations',
// Same reasoning as onboarding.secrets_key just above:
// VPN_CONFIG_KEY has no console field either, only the raw
// .env editor.
tab: 'env',
// Same reuse-not-reimplement reasoning as isUsable() above:
// ConfigVault::available() is the one place that already knows
// what counts as a usable key.
@ -66,7 +84,12 @@ final class OnboardingChecks
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.wg_hub'),
breaks: __('readiness.onboarding.wg_hub_breaks'),
tab: 'integrations',
// Two of the three values this check reads (wgHubPubkey,
// wgEndpoint) are 'platform'-tab fields; only the third
// (subnet) is .env-only and cannot be changed from the
// console at all (see the docblock on satisfied: below). The
// tab an operator can actually act on is 'platform'.
tab: 'platform',
// Two of three is no half a tunnel, it is none: ConfigureWireguard
// writes the peer config from all three at once. Subnet is read
// straight from config(), never through Settings/ProvisioningSettings

View File

@ -26,7 +26,10 @@ final class ProvisioningChecks
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.dns_token'),
breaks: __('readiness.provisioning.dns_token_breaks'),
tab: 'integrations',
// The DNS card (token + zone + Traefik path) is on the
// 'services' tab — 'integrations' is not a member of
// Integrations::TABS at all (Fix-Runde, Befund 1).
tab: 'services',
satisfied: filled(app(SecretVault::class)->get('dns.token')),
),
new Check(
@ -35,7 +38,8 @@ final class ProvisioningChecks
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.dns_zone'),
breaks: __('readiness.provisioning.dns_zone_breaks'),
tab: 'integrations',
// Same DNS card as provisioning.dns_token just above.
tab: 'services',
satisfied: filled(ProvisioningSettings::dnsZone()),
),
new Check(
@ -44,7 +48,8 @@ final class ProvisioningChecks
severity: Check::SEVERITY_WARNING,
label: __('readiness.provisioning.traefik_path'),
breaks: __('readiness.provisioning.traefik_path_breaks'),
tab: 'integrations',
// Same DNS card — traefikDynamicPath is a field on it.
tab: 'services',
satisfied: filled(ProvisioningSettings::traefikDynamicPath()),
),
new Check(
@ -93,7 +98,8 @@ final class ProvisioningChecks
: Check::SEVERITY_WARNING,
label: __('readiness.provisioning.monitoring_token'),
breaks: __('readiness.provisioning.monitoring_token_breaks'),
tab: 'integrations',
// The monitoring card is on the 'services' tab too.
tab: 'services',
satisfied: filled(app(SecretVault::class)->get('monitoring.token')),
),
];

View File

@ -0,0 +1,103 @@
<?php
namespace App\Support;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
/**
* In welchem Stripe-KONTO die gespeicherten Objekt-IDs entstanden sind.
*
* Der Betriebsmodus schaltet die Zugangsdaten um. Er schaltet nicht um, was aus
* ihnen entstanden ist: eine Stripe-Price-ID und eine Product-ID gehören dem
* Konto, das sie ausgestellt hat, und ein Testkonto und ein Livekonto sind zwei
* Konten. Jedes Zugangsdatum hat auf dieser Installation zwei Plätze; die
* daraus abgeleiteten IDs stehen in einwertigen Spalten
* (`plan_families.stripe_product_id`, `plan_prices.stripe_price_id`) und in den
* zwei Registern (`stripe_plan_prices`, `stripe_addon_prices`).
*
* Ohne diese Zeile lief der geplante Ablauf dieser Installation ins Leere:
* abgleichen im Testbetrieb, Live-Schlüssel hinterlegen, umschalten und
* `billing.catalogue_synced` meldete weiter „erfüllt", weil es nur prüfte, ob
* die Spalte gefüllt ist. Die Seite sagte „Bereit für Livebetrieb", und die
* erste echte Bestellung schickte eine Test-Preis-ID an die Live-API: *No such
* price*, kein Auftrag. Genau die falsche Grünmeldung, gegen die diese Seite
* gebaut ist, ausgelöst durch genau den Schalter, den dieses Vorhaben einführt.
*
* **Warum eine Einstellungszeile und nicht die Objekte selbst.** Stripe schreibt
* die Kontoart nicht in die ID: `prod_…` und `price_…` sehen im Test- und im
* Livekonto gleich aus (nur SCHLÜSSEL tragen `_test_`/`_live_` im Präfix das
* ist OperatingMode::ofStripeKey()). Die Herkunft ist aus einer gespeicherten ID
* also nicht ableitbar, und nachfragen dürfte die Bereitschaftsseite nicht: sie
* erreicht beim Seitenaufruf nichts über das Netz. Bleibt: beim Abgleich
* festhalten, in welchem Modus die IDs entstanden sind.
*
* **Warum EINE Zeile für den ganzen Katalog.** Ein Lauf von
* stripe:sync-catalogue benutzt genau einen Schlüssel, also genau ein Konto. Und
* der Befehl weigert sich, in einen Katalog hineinzuarbeiten, der zu einem
* anderen Konto gehört (siehe dort) ohne diese Weigerung könnte ein Lauf
* einen halb gemischten Zustand hinterlassen, über den eine einzelne Zeile dann
* lügen würde.
*/
final class StripeCatalogueMode
{
public const SETTING = 'billing.catalogue_mode';
/** Der Modus, in dem der Katalog zuletzt angelegt wurde — null: noch nie. */
public static function recorded(): ?OperatingMode
{
$stored = Settings::get(self::SETTING);
return $stored === null ? null : OperatingMode::tryFrom((string) $stored);
}
/** Festhalten, wessen Konto die gerade angelegten IDs gehören. */
public static function record(?OperatingMode $mode = null): void
{
Settings::set(self::SETTING, ($mode ?? OperatingMode::current())->value);
}
/**
* Steht fest, dass der gespeicherte Katalog einem ANDEREN Konto gehört?
*
* Nur dann nicht schon, wenn die Herkunft unbekannt ist. Das sind zwei
* Zustände mit zwei Handlungsanweisungen, und sie zusammenzuwerfen war ein
* teurer Fehler: „Herkunft nie festgehalten" entsteht ganz ohne Kontowechsel,
* nämlich wenn ein Abgleich mitten im Anlegen abbricht oder wenn eine
* Bestellung sich ihren fehlenden Preis selbst holt (CheckoutController
* PlanPrices::ensure(), BookAddon SyncStripeAddonItems
* AddonPrices::ensure() keiner der beiden ruft record()). Die Objekte
* stammen dann aus genau dem geltenden Konto, und wer in diesem Zustand die
* Leeranweisung für einen Kontowechsel liest, löscht einen Katalog, an dem
* laufende Verträge abgerechnet werden.
*
* Unbekannte Herkunft ist trotzdem kein Freibrief: die Bereitschaftsseite
* meldet sie weiter blockierend, nur mit dem Satz, der dazu passt („lauf den
* Abgleich noch einmal"). Beweisen kann sie die Herkunft nicht.
*/
public static function belongsToAnotherMode(): bool
{
$recorded = self::recorded();
return $recorded !== null && $recorded !== OperatingMode::current();
}
/**
* Liegt überhaupt eine Stripe-Objekt-ID in der Datenbank?
*
* Alle vier Orte, an denen der Abgleich etwas hinterlässt auch die zwei
* Register, weil PlanPrices::inStep() für den Netto-Preis eines
* Reverse-Charge-Kunden ALLEIN das Register fragt: eine geleerte Spalte in
* `plan_prices` ohne gelöschte Registerzeile ließe genau diese Hälfte des
* Katalogs still auf das alte Konto zeigen.
*/
public static function hasStoredObjects(): bool
{
return PlanFamily::query()->whereNotNull('stripe_product_id')->exists()
|| PlanPrice::query()->whereNotNull('stripe_price_id')->exists()
|| StripePlanPrice::query()->exists()
|| StripeAddonPrice::query()->exists();
}
}

View File

@ -3,6 +3,7 @@
use App\Services\Secrets\SecretCipher;
use App\Support\OperatingMode;
use App\Support\Settings;
use App\Support\StripeCatalogueMode;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@ -45,6 +46,20 @@ return new class extends Migration
// stehen, und die ist live.
if ($stripeMode !== null) {
Settings::set(OperatingMode::SETTING, $stripeMode->value);
// Und derselbe Schluss noch einmal, eine Ebene weiter: was bei
// Stripe an Produkten und Preisen angelegt wurde, wurde mit
// GENAU DIESEM Schlüssel angelegt — ein anderer war auf dieser
// Installation nie hinterlegt. Die IDs gehören also dem Konto, das
// dieser Schlüssel öffnet, und das ist ab jetzt festgehalten
// (StripeCatalogueMode). Ohne diesen Nachtrag stünde ein längst
// abgeglichener Katalog als „Herkunft unbekannt" da, und die
// Bereitschaftsseite verlangte einen neuen Abgleich, den niemand
// braucht. Nur wenn wirklich IDs da sind: eine Zeile über einen
// Katalog, den es nicht gibt, wäre eine Behauptung ohne Gegenstand.
if (StripeCatalogueMode::hasStoredObjects()) {
Settings::set(StripeCatalogueMode::SETTING, $stripeMode->value);
}
}
}
@ -59,7 +74,12 @@ return new class extends Migration
return OperatingMode::Live;
}
return Str::contains($secret, '_test_') ? OperatingMode::Test : OperatingMode::Live;
// Die Präfixregel steht in OperatingMode und wird hier gerufen, nicht
// wiederholt: dieselbe Frage entscheidet auf der Bereitschaftsseite, ob
// ein Schlüssel im falschen Platz liegt, und in StripeCheck, was der
// Konsole als Kontoart gemeldet wird. `?? Live` ist wie oben der
// Status quo für einen Wert, der sich nicht zuordnen lässt.
return OperatingMode::ofStripeKey($secret) ?? OperatingMode::Live;
}
public function down(): void
@ -101,5 +121,6 @@ return new class extends Migration
}
Settings::forget(OperatingMode::SETTING);
Settings::forget(StripeCatalogueMode::SETTING);
}
};

View File

@ -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.',

View File

@ -4,6 +4,20 @@ return [
'title' => 'Integrationen',
'subtitle' => 'Angebundene Dienste, gruppiert nach Zweck — Zugangsdaten und Einstellungen nebeneinander, nicht auf getrennten Seiten nach Speicherort.',
// Der Umschalter — über allen Zugangsdaten, weil er regiert, welcher
// Platz je Eintrag gilt.
'mode_title' => 'Betriebsmodus',
'mode_body' => 'Entscheidet, welcher Platz je Zugangsdatum unten gilt. Ist der Testplatz leer, gilt im Testbetrieb der Live-Platz — außer beim Stripe-Schlüssel, der in keine Richtung zurückfällt.',
'mode_switch_to_test' => 'Zu Testbetrieb wechseln',
'mode_switch_to_live' => 'Zu Livebetrieb wechseln',
'mode_switched' => 'Betriebsmodus umgelegt: :mode.',
'mode_switch_title_test' => 'Wirklich zu Testbetrieb wechseln?',
'mode_switch_body_test' => 'Ab sofort gilt für jedes Zugangsdatum der Testplatz — oder, wo dieser leer ist, der Live-Platz. Zahlungen laufen weiter über den aktiven Stripe-Schlüssel des jeweiligen Modus.',
'mode_switch_confirm_test' => 'Umlegen',
'mode_switch_title_live' => 'Wirklich zu Livebetrieb wechseln?',
'mode_switch_body_live' => 'Ab sofort fließt echtes Geld: jede Bestellung wird über den Live-Stripe-Schlüssel abgerechnet, nicht mehr über den Testschlüssel. Das ist keine Umschaltfläche zum Danebenklicken.',
'mode_switch_confirm_live' => 'Umlegen',
'payments_title' => 'Zahlungen (Stripe)',
'payments_body' => 'Der Secret Key, mit dem Zahlungen entgegengenommen und Webhooks geprüft werden.',
@ -96,4 +110,4 @@ return [
'platform' => 'Plattform',
'env' => 'Umgebung (.env)',
],
];
];

View File

@ -6,6 +6,34 @@
* 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',
'check_key_mode' => 'Der Schlüssel gehört zum Konto für :mode.',
'check_key_mode_conflict' => 'Diese Installation läuft aber im :mode — der Schlüssel liegt im falschen Platz.',
'fix_link' => 'Beheben',
'last_heartbeat' => 'Letzter Herzschlag: :when',
'mode' => [
'test' => 'Testbetrieb',
'live' => 'Livebetrieb',
@ -14,6 +42,12 @@ return [
'billing' => [
'stripe_secret' => 'Stripe-Schlüssel (:mode)',
'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.',
// Zwei getrennte Sätze, weil die Folgen entgegengesetzt sind: einmal
// fließt echtes Geld, das nicht fließen sollte, einmal fließt keines,
// das fließen sollte. Ein gemeinsamer Satz müsste beides andeuten und
// sagte am Ende keins von beidem.
'stripe_secret_test_slot_breaks' => 'Im Testplatz liegt ein Live-Schlüssel. Jede Bestellung bucht damit echtes Geld ab, während diese Seite und die Konsole „Testbetrieb" anzeigen — ein Probekauf ist dann ein echter Kauf, mit echter Karte und echter Abbuchung.',
'stripe_secret_live_slot_breaks' => 'Im Live-Platz liegt ein Testschlüssel. Der Kunde durchläuft die Kasse, es wird nie Geld eingezogen, und die Bestellung sieht trotzdem bezahlt aus — die Bereitstellung läuft, der Beleg wird ausgestellt, und auf dem Konto kommt nichts an.',
'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',
@ -22,6 +56,17 @@ return [
'invoice_series_breaks' => 'IssueInvoice bricht beim Ausstellen ab, sobald eine Serie fehlt oder deaktiviert ist. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg, und der Fehler landet nur im Log.',
'catalogue_synced' => 'Stripe-Katalog abgeglichen',
'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.',
// Sagt, was passiert, und was dagegen hilft — inklusive der
// unbequemen Hälfte: `stripe:sync-catalogue` überspringt jede Zeile,
// die schon eine ID trägt, repariert das also NICHT von selbst. Ein
// Satz, der nur „bitte neu abgleichen" sagte, schickte den Betreiber
// auf einen Befehl, der ihm „already in step" antwortet.
// Der dritte Zustand, und ausdrücklich OHNE Leeranweisung: er entsteht
// ohne jeden Kontowechsel, und die Objekte gehören zum geltenden Konto.
// Wer hier zu löschen anfinge, löschte einen Katalog, an dem laufende
// Verträge abgerechnet werden.
'catalogue_origin_breaks' => 'Zu den gespeicherten Stripe-IDs ist nicht festgehalten, in welchem Konto sie entstanden sind — etwa weil ein Abgleich mitten im Anlegen abgebrochen ist oder weil eine Bestellung sich ihren fehlenden Preis selbst geholt hat. Solange das offen ist, kann diese Seite nicht sagen, ob die Kasse mit ihnen verkaufen kann. Ein Lauf von stripe:sync-catalogue setzt auf dem Vorhandenen auf, legt nur das Fehlende nach und hält die Herkunft fest — gelöscht werden muss dafür nichts.',
'catalogue_account_breaks' => 'Die gespeicherten Stripe-Produkt- und Preis-IDs wurden in einem anderen Konto angelegt als dem, das jetzt gilt (:mode) — Stripe-Objekte gehören dem Konto, das sie ausgestellt hat. Jede Bestellung scheitert damit an der Kasse mit „No such price": kein Auftrag, keine Zahlung. Ein neuer Lauf von stripe:sync-catalogue allein genügt nicht, er überspringt jede Zeile, die schon eine ID trägt; erst nach dem Leeren von plan_families.stripe_product_id und plan_prices.stripe_price_id sowie der Register stripe_plan_prices und stripe_addon_prices legt er den Katalog im aktiven Konto neu an.',
],
'onboarding' => [

View File

@ -22,7 +22,23 @@ return [
'unlocked_note' => 'Entsperrt. Änderungen wirken sofort auf den laufenden Betrieb.',
'lock_again' => 'Wieder sperren',
// Welcher der zwei Plätze dieses Eintrags jetzt gilt — Speichern UND
// Lesen, weil save()/render() ohne Modus-Argument auf
// OperatingMode::current() auflösen. Benennt den PLATZ, nicht den Modus:
// „Testbetrieb"/„Livebetrieb" steht schon in der Plakette oben auf der
// Seite, hier geht es darum, in welche der zwei Zeilen ein neuer Wert
// tatsächlich landet.
'slot' => [
'test' => 'Testplatz aktiv',
'live' => 'Live-Platz aktiv',
],
'source_stored' => 'Hier hinterlegt',
// Im Testbetrieb gilt der Live-Wert, wenn der Testplatz leer ist — genau
// das, was SecretVault::get() tut. Ohne eigene Beschriftung stand hier
// „Aus der Serverdatei" über einem Wert aus der Datenbank, und wer die
// .env daraufhin änderte, änderte nichts.
'source_stored_live' => 'Aus dem Live-Platz',
'source_environment' => 'Aus der Serverdatei',
'source_none' => 'Nicht gesetzt',
'stored_value' => 'Hinterlegt',

View File

@ -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.',

View File

@ -4,6 +4,20 @@ return [
'title' => 'Integrations',
'subtitle' => 'Connected services, grouped by purpose — credentials and settings side by side, not on separate pages by storage mechanism.',
// The switch — above every credential, because it governs which slot of
// each entry below applies.
'mode_title' => 'Operating mode',
'mode_body' => 'Decides which slot of each credential below applies. In test mode, an empty test slot falls back to the live slot — except the Stripe key, which does not fall back either way.',
'mode_switch_to_test' => 'Switch to test mode',
'mode_switch_to_live' => 'Switch to live mode',
'mode_switched' => 'Operating mode switched: :mode.',
'mode_switch_title_test' => 'Really switch to test mode?',
'mode_switch_body_test' => 'From now on, every credential uses its test slot — or, where that one is empty, the live slot. Payments continue to go through whichever Stripe key belongs to the active mode.',
'mode_switch_confirm_test' => 'Switch',
'mode_switch_title_live' => 'Really switch to live mode?',
'mode_switch_body_live' => 'From now on, real money moves: every order is charged through the live Stripe key, not the test one. This is not a switch to click by accident.',
'mode_switch_confirm_live' => 'Switch',
'payments_title' => 'Payments (Stripe)',
'payments_body' => 'The secret key used to accept payments and verify webhooks.',
@ -96,4 +110,4 @@ return [
'platform' => 'Platform',
'env' => 'Environment (.env)',
],
];
];

View File

@ -6,6 +6,34 @@
* 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',
'check_key_mode' => 'This key belongs to the :mode account.',
'check_key_mode_conflict' => 'This installation is running in :mode — the key is in the wrong slot.',
'fix_link' => 'Fix',
'last_heartbeat' => 'Last heartbeat: :when',
'mode' => [
'test' => 'Test mode',
'live' => 'Live mode',
@ -14,6 +42,10 @@ return [
'billing' => [
'stripe_secret' => 'Stripe key (:mode)',
'stripe_secret_breaks' => 'Without it the checkout accepts no order at all — no order is ever created.',
// Two separate sentences, because the consequences are opposite ones:
// real money moving that should not, and no money moving that should.
'stripe_secret_test_slot_breaks' => 'The test slot holds a live key. Every order charges real money while this page and the console say "test mode" — a trial purchase is a real purchase, on a real card.',
'stripe_secret_live_slot_breaks' => 'The live slot holds a test key. The customer completes checkout, no money is ever taken, and the order still looks paid — provisioning runs, the invoice is issued, and nothing arrives in the account.',
'webhook_secret' => 'Stripe signing secret',
'webhook_secret_breaks' => 'Without it a payment is never recorded: the customer pays, and nothing happens.',
'company_details' => 'Company details complete',
@ -22,6 +54,14 @@ return [
'invoice_series_breaks' => 'IssueInvoice aborts while issuing whenever a series is missing or switched off. Provisioning still goes through — the customer gets a running cloud with no invoice, and the failure is only written to the log.',
'catalogue_synced' => 'Stripe catalogue in sync',
'catalogue_synced_breaks' => 'A verified EU business customer cannot check out, because their net price is missing at Stripe.',
// Says what happens and what actually helps, including the awkward
// half: stripe:sync-catalogue skips every row that already carries an
// id, so it does not repair this on its own.
// The third state, deliberately WITHOUT a clear-it-out instruction: it
// arises without any account change, and the objects belong to the
// account in force.
'catalogue_origin_breaks' => 'Nothing records which account the stored Stripe ids were created in — a sync may have died part-way through, or an order may have minted its own missing price. Until that is settled this page cannot say whether the checkout can sell with them. Running stripe:sync-catalogue takes up what is already there, creates only what is missing, and records the account — nothing has to be deleted for it.',
'catalogue_account_breaks' => 'The stored Stripe product and price ids were created in a different account from the one now in force (:mode) — a Stripe object belongs to the account that issued it. Every order then fails at the checkout with "No such price": no order, no payment. Re-running stripe:sync-catalogue is not enough on its own, because it skips every row that already carries an id; only after clearing plan_families.stripe_product_id and plan_prices.stripe_price_id as well as the stripe_plan_prices and stripe_addon_prices registers does it recreate the catalogue in the active account.',
],
'onboarding' => [

View File

@ -22,7 +22,22 @@ return [
'unlocked_note' => 'Unlocked. Changes take effect on the running system immediately.',
'lock_again' => 'Lock again',
// Which of the two slots this entry resolves to right now — for both
// saving and reading, since save()/render() resolve against
// OperatingMode::current() without a mode argument. Names the SLOT, not
// the mode: "Test mode"/"Live mode" already sits in the badge at the top
// of the page; this is about which of the two rows a new value actually
// lands in.
'slot' => [
'test' => 'Test slot active',
'live' => 'Live slot active',
],
'source_stored' => 'Stored here',
// In test mode the live value applies when the test slot is empty —
// exactly what SecretVault::get() does. Without a label of its own this
// read "From the server file" over a value coming from the database.
'source_stored_live' => 'From the live slot',
'source_environment' => 'From the server file',
'source_none' => 'Not set',
'stored_value' => 'Stored',

View File

@ -1,4 +1,4 @@
@props(['entry', 'unlocked'])
@props(['entry', 'unlocked', 'mode'])
{{--
One vault entry outline, new-value field, test/save/forget reusable
across every integration section on App\Livewire\Admin\Integrations. Kept
@ -17,13 +17,33 @@
<h3 class="text-sm font-semibold text-ink">{{ $entry['label'] }}</h3>
<p class="mt-0.5 font-mono text-xs text-muted">{{ $entry['key'] }}</p>
</div>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
: ($entry['source'] === 'environment' ? 'border-info-border bg-info-bg text-info'
: 'border-warning-border bg-warning-bg text-warning') }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('secrets.source_'.$entry['source']) }}
</span>
<div class="flex flex-wrap items-center gap-2">
{{-- Which of the two slots THIS card reads and writes right now
fix round, Important: save()/render() resolve without a mode
argument, so they always act on OperatingMode::current()'s
slot, but nothing on the card said so. The badge in the
header above (Integrations::mode_title) is not enough: it is
not visible at the card an operator is about to type into,
and repeating "Testbetrieb"/"Livebetrieb" there would only
restate the MODE, not name the PLATZ a value lands in. Same
colours as that header badge, for one visual language. --}}
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}">
{{ __('secrets.slot.'.$mode->value) }}
</span>
{{-- Aufgezählt nach den zwei Enden statt nach jedem Wert einzeln:
grün heißt „liegt in DIESEM Platz", gelb heißt „nirgends", und
alles dazwischen (aus der Serverdatei, aus dem Live-Platz) ist
blau in Kraft, aber woanders her. So bekommt ein künftiger
vierter Wert nicht still die Farbe von „Nicht gesetzt". --}}
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
: ($entry['source'] === 'none' ? 'border-warning-border bg-warning-bg text-warning'
: 'border-info-border bg-info-bg text-info') }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('secrets.source_'.$entry['source']) }}
</span>
</div>
</div>
@if (! $unlocked)

View File

@ -43,6 +43,7 @@
'globe' => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
'send' => '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
'plug' => '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
'beaker' => '<path d="M4.5 3h15"/><path d="M6 3v16a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V3"/><path d="M6 14h12"/>',
];
$body = $icons[$name] ?? '';

View File

@ -42,6 +42,16 @@
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
<x-shell.topbar :crumbs="array_values(array_filter([__('admin.console'), $title ?? \App\Support\Navigation::currentLabel(console: true)]))">
{{-- "Bin ich im Test?" darf keine Frage sein, die man erst
durch Navigieren zur Integrationsseite beantwortet die
Plakette steht im Kopf jeder Konsolenseite, solange der
Modus test ist, und verschwindet von selbst im Livebetrieb. --}}
@if (\App\Support\OperatingMode::current()->isTest())
<span class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-900 dark:bg-amber-900/40 dark:text-amber-200">
<x-ui.icon name="beaker" class="size-4" />
{{ __('readiness.mode.test') }}
</span>
@endif
@auth
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
<button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">

View File

@ -0,0 +1,24 @@
{{--
Zweizeilige Rückfrage, kein Eingabefeld braucht nach R24 kein
<x-ui.modal>. Live bekommt eine eigene, wärmere Warnfarbe und einen
zweiten Satz: das ist der Moment, ab dem echtes Geld bewegt wird, und das
darf nicht dieselbe beiläufige Bestätigung sein wie der Wechsel zurück in
den Testbetrieb.
--}}
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg {{ $mode === 'live' ? 'bg-warning-bg text-warning' : 'bg-accent-subtle text-accent-text' }}">
<x-ui.icon name="{{ $mode === 'live' ? 'alert-triangle' : 'beaker' }}" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('integrations.mode_switch_title_'.$mode) }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('integrations.mode_switch_body_'.$mode) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
{{ __('integrations.mode_switch_confirm_'.$mode) }}
</x-ui.button>
</div>
</div>

View File

@ -62,6 +62,36 @@
@endif
@if ($tab === 'services')
{{-- The switch above every vault entry, because it governs them: which
slot of each credential below is in force. Same lock as the vault
itself (secrets.manage + confirmed password): the mode a stranger
could type into a form field is only ever applied through
switchMode()'s own guard, but the button that offers it in the first
place stays behind the same unlock as everything else on this page. --}}
@if ($canSecrets)
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div>
<h2 class="font-semibold text-ink">{{ __('integrations.mode_title') }}</h2>
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('integrations.mode_body') }}</p>
</div>
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('readiness.mode.'.$mode->value) }}
</span>
@if ($unlocked)
<x-ui.button variant="secondary"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-switch-mode', arguments: { mode: '{{ $mode->isTest() ? 'live' : 'test' }}' } })">
{{ $mode->isTest() ? __('integrations.mode_switch_to_live') : __('integrations.mode_switch_to_test') }}
</x-ui.button>
@endif
</div>
</div>
@endif
{{-- Two columns from lg up. Each card is a form of two or three fields
stacked, they left half the window empty and pushed monitoring below
the fold. --}}
@ -73,7 +103,7 @@
<h2 class="font-semibold text-ink">{{ __('integrations.payments_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('integrations.payments_body') }}</p>
</div>
<x-admin.secret-field :entry="$entries['stripe.secret']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['stripe.secret']" :unlocked="$unlocked" :mode="$mode" />
@if ($unlocked)
<x-ui.alert variant="info">{{ __('secrets.webhook_secret_note') }}</x-ui.alert>
@endif
@ -97,7 +127,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['dns.token']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['dns.token']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif
@ -123,7 +153,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif
@ -142,7 +172,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['monitoring.token']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['monitoring.token']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif
@ -188,7 +218,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['ssh.private_key']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['ssh.private_key']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif

View File

@ -0,0 +1,110 @@
<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>
{{-- StripeCheck rechnet die Kontoart des Schlüssels längst aus,
und diese Seite hat sie bislang weggeworfen: wer „Prüfen"
drückte, las „Geprüft: in Ordnung" über einen Live-Schlüssel
im Testplatz. Die Prüfung selbst blockiert dafür jetzt
(BillingChecks); hier steht, was der Knopf gerade gesehen
hat, damit er ihr nicht widerspricht. --}}
@if (array_key_exists('live', $result))
@php $keyMode = $result['live'] ? 'live' : 'test'; @endphp
<p class="mt-1 text-xs {{ $keyMode === $mode->value ? 'text-muted' : 'text-danger' }}">
{{ __('readiness.check_key_mode', ['mode' => __('readiness.mode.'.$keyMode)]) }}
@unless ($keyMode === $mode->value)
{{ __('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.'.$mode->value)]) }}
@endunless
</p>
@endif
@endif
</div>
<div class="flex shrink-0 items-center gap-3">
{{-- mount() only requires hosts.manage OR secrets.manage, so an
Admin (hosts.manage alone) can reach this page but runCheck()
itself demands secrets.manage and 403s otherwise. A button that
is visible and throws when pressed is worse than no button. --}}
@if (in_array($check->key, $runnable, true))
@can('secrets.manage')
<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>
@endcan
@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>

View File

@ -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.

View File

@ -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 () {

View File

@ -0,0 +1,130 @@
<?php
use App\Models\PlanPrice;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\OperatingMode;
use App\Support\Settings;
use App\Support\StripeCatalogueMode;
use Illuminate\Contracts\Console\Kernel;
/**
* Der Abgleich sagt, in wessen Konto er gearbeitet hat und weigert sich, in
* einen Katalog hineinzuarbeiten, der einem anderen gehört.
*
* Eine Stripe-Price-ID gehört dem Konto, das sie ausgestellt hat. Der Befehl
* überspringt jede Zeile, die schon eine ID trägt (PlanPrices::inStep()), also
* repariert ein zweiter Lauf nach dem Umschalten gar nichts er meldete
* „already in step" und ließe eine Zeile zurück, die behauptet, der Katalog
* gehöre jetzt zum Livekonto. Genau diese Lüge macht die Weigerung unnötig.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
});
it('records the mode its objects were created in', function () {
OperatingMode::set(OperatingMode::Test);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test)
->and(StripeCatalogueMode::hasStoredObjects())->toBeTrue();
});
it('records nothing on a dry run, which creates nothing', function () {
OperatingMode::set(OperatingMode::Test);
$this->artisan('stripe:sync-catalogue', ['--dry-run' => true])->assertSuccessful();
expect(StripeCatalogueMode::recorded())->toBeNull();
});
it('refuses to work into a catalogue that belongs to the other account', function () {
OperatingMode::set(OperatingMode::Test);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
OperatingMode::set(OperatingMode::Live);
$this->artisan('stripe:sync-catalogue')->assertFailed();
// Und die Zeile lügt danach nicht: der Katalog gehört weiterhin dem
// Testkonto, weil dieser Lauf nichts angelegt hat.
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
});
/**
* „Herkunft nie festgehalten" ist NICHT „fremdes Konto".
*
* Der Zustand entsteht ohne jeden Fehler: `CheckoutController` `PlanPrices::
* ensure()` und `BookAddon` → `SyncStripeAddonItems` → `AddonPrices::ensure()`
* legen fehlende Preise selbst an, und keiner der beiden ruft `record()`. Er
* entsteht auch nach einem Lauf, der mittendrin abgebrochen ist.
*
* Die Objekte stammen dann aus genau dem geltenden Konto. Diesen Zustand in die
* Konto-Weigerung zu schicken, hieße einem Betreiber das Leeren eines Katalogs
* aufzutragen, an dem laufende Verträge abgerechnet werden für ein
* Wiederaufsetzen, für das die Idempotenzschlüssel dieses Befehls ausdrücklich
* gebaut sind.
*/
it('takes up a catalogue whose origin was never written down', function () {
OperatingMode::set(OperatingMode::Test);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
Settings::forget(StripeCatalogueMode::SETTING);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
});
it('leaves no half-built catalogue that contradicts itself when a run dies', function () {
OperatingMode::set(OperatingMode::Test);
// Stripe geht mitten im Lauf weg, nachdem das erste Produkt schon angelegt
// und seine ID gespeichert ist. `createProduct()` und `ensure()` werfen
// ungefangen — stünde record() am ENDE von handle(), bliebe genau der
// Zustand zurück, den der Test darüber beschreibt: IDs da, Herkunft leer.
app()->instance(StripeClient::class, new class extends FakeStripeClient
{
public function createPrice(
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata = [],
?string $idempotencyKey = null,
): string {
throw new RuntimeException('Stripe went away mid-run');
}
});
try {
app(Kernel::class)->call('stripe:sync-catalogue');
} catch (RuntimeException) {
// Erwartet — der Abbruch IST der Testfall.
}
expect(StripeCatalogueMode::hasStoredObjects())->toBeTrue()
->and(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
});
it('syncs into the new account once the stale ids are cleared', function () {
OperatingMode::set(OperatingMode::Test);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
OperatingMode::set(OperatingMode::Live);
// Was der `breaks`-Satz und die Fehlermeldung dem Betreiber auftragen —
// Zeiger UND Register, weil PlanPrices::inStep() für den Netto-Preis eines
// Reverse-Charge-Kunden allein das Register fragt.
DB::table('plan_families')->update(['stripe_product_id' => null]);
DB::table('plan_prices')->update(['stripe_price_id' => null]);
DB::table('stripe_plan_prices')->delete();
DB::table('stripe_addon_prices')->delete();
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Live)
->and(PlanPrice::query()->whereNotNull('stripe_price_id')->exists())->toBeTrue();
});

View File

@ -3,10 +3,13 @@
use App\Exceptions\StripeNotConfigured;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\Order;
use App\Models\User;
use App\Services\Secrets\SecretVault;
use App\Services\Stripe\HttpStripeClient;
use App\Support\OperatingMode;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
/**
@ -23,6 +26,43 @@ it('refuses to reach stripe without a key for the active mode', function () {
->toThrow(StripeNotConfigured::class);
});
/**
* Die andere Hälfte derselben Entscheidung, und der Riegel vor dem Merge:
* `secret()` WIRFT, `isConfigured()` WIRFT NICHT.
*
* Sechs Aufrufer stellen genau diese Frage, um KEINE Ausnahme zu bekommen
* CheckoutController:87 (der Kunde liest sonst eine 500 statt eines Satzes),
* SyncStripeCatalogue, RepriceStripeSubscriptions, SyncStripeAddonItems,
* MoveStripeSubscriptionPrice. `main` trägt an dieser Stelle noch
* `return filled($this->secret());`, und der Trockenlauf des Merges führt die
* Datei OHNE Konfliktmarkierung zusammen: niemand wird gezwungen hinzusehen.
* Mit `main`s Rumpf und dem werfenden `secret()` dieses Zweigs wird aus der
* Frage eine Ausnahme und ohne diesen Test bleiben dabei 1795 Tests grün
* (nachgewiesen im Schluss-Review durch genau diese Mutation).
*/
it('answers the configured question instead of throwing it', function () {
Http::fake();
OperatingMode::set(OperatingMode::Test);
// Nicht toBeFalse() allein: eine geworfene Ausnahme wäre kein „false",
// sondern gar keine Antwort — und genau das ist der Rückschritt.
expect(app(HttpStripeClient::class)->isConfigured())->toBeFalse();
// Die Frage darf auch nichts kosten: sie wird auf jedem Kassenaufruf
// gestellt und liest nur den Tresor.
Http::assertNothingSent();
});
it('says configured once the slot of the active mode is filled', function () {
Http::fake();
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_test_x', Operator::factory()->create(), OperatingMode::Test);
expect(app(HttpStripeClient::class)->isConfigured())->toBeTrue();
Http::assertNothingSent();
});
it('does not create an order when the key is missing', function () {
OperatingMode::set(OperatingMode::Test);
$customer = Customer::factory()->create();

View File

@ -9,6 +9,7 @@ use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Readiness\Check;
use App\Support\Settings;
use App\Support\StripeCatalogueMode;
/**
* Die Bereitschaftsprüfung berichtet, was fehlt und sagt dazu, was
@ -43,6 +44,55 @@ it('does not accept a live key as proof while the test mode is active', function
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
});
/**
* Ein Schlüssel im falschen Platz ist ein blockierender Befund.
*
* Gemessen im Schluss-Review: `put('stripe.secret', 'sk_live_REALMONEY')` im
* Testbetrieb wird angenommen, `get()` liefert ihn, die Bereitschaftsseite
* meldete „erfüllt" — und oben auf derselben Seite steht die Plakette
* „Testbetrieb". Das ist derselbe Ausgang, gegen den die strikte Ausnahme im
* Tresor gebaut wurde („bucht echtes Geld ab, während die Konsole Testbetrieb
* anzeigt"), nur über den eingetippten statt den automatischen Weg.
*
* Am Präfix ohne Netzverkehr entscheidbar OperatingMode::ofStripeKey(),
* dieselbe Stelle, die auch die Migration und StripeCheck fragen.
*/
it('refuses a live key sitting in the test slot', function () {
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
});
it('refuses a test key sitting in the live slot', function () {
// Die andere Richtung kostet kein Geld, sondern nimmt keins ein: der Kunde
// zahlt in Stripes Testkonto, und die Bestellung sieht bezahlt aus.
OperatingMode::set(OperatingMode::Live);
app(SecretVault::class)->put('stripe.secret', 'sk_test_notreal', Operator::factory()->create(), OperatingMode::Live);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
});
it('accepts the restricted key of the active mode', function () {
// rk_ ist ein echter Schlüssel, kein Sonderfall — die Erkennung darf ihn
// nicht für einen Widerspruch halten, nur weil er nicht sk_ heißt.
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'rk_test_readonly', Operator::factory()->create(), OperatingMode::Test);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
});
it('says what the wrong key does, not that a field is empty', function () {
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
// Der Satz für „gar kein Schlüssel" wäre hier eine Lüge: einer liegt da,
// er ist der falsche, und die Folge ist eine ganz andere.
expect(checkFor('billing.stripe_secret')->breaks)
->not->toBe(__('readiness.billing.stripe_secret_breaks'));
});
it('reports incomplete company details without repeating the list', function () {
Settings::forget('company.name');
@ -88,5 +138,82 @@ it('does not raise a permanent alarm for a draft version stripe never touches',
]);
$draft->prices()->create(['term' => 'monthly', 'amount_cents' => 1000, 'currency' => 'EUR']);
// Der Abgleich hält seit dieser Runde fest, in welchem Konto er die IDs
// angelegt hat; ohne diese Zeile wäre der Katalog oben von unbekannter
// Herkunft und die Prüfung meldete zu Recht einen Widerspruch.
StripeCatalogueMode::record();
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue();
});
/**
* Der Modus schaltet die Zugangsdaten um nicht die Objekte, die aus ihnen
* entstanden sind.
*
* Eine Stripe-Price-ID gehört dem Konto, das sie ausgestellt hat. Der geplante
* Ablauf dieser Installation war: im Testbetrieb abgleichen, Live-Schlüssel
* hinterlegen, umschalten und diese Prüfung meldete weiter „erfüllt", weil
* sie nur nachsah, ob die Spalte gefüllt ist. Die Seite sagte „Bereit für
* Livebetrieb", und die erste echte Bestellung bekam von Stripe *No such
* price*. Kein Auftrag, und die Kernzusage dieser Seite gebrochen in genau dem
* Moment, für den sie existiert.
*/
it('does not call the catalogue synced when its ids belong to the other account', function () {
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_test_account']);
StripeCatalogueMode::record(OperatingMode::Test);
OperatingMode::set(OperatingMode::Live);
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeFalse();
expect(checkFor('billing.catalogue_synced')->severity)->toBe(Check::SEVERITY_BLOCKING);
});
it('says the sale is refused and a fresh sync is needed, not that a column is empty', function () {
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_test_account']);
StripeCatalogueMode::record(OperatingMode::Test);
OperatingMode::set(OperatingMode::Live);
// Der Satz für „nie abgeglichen" wäre hier falsch: abgeglichen wurde, nur
// in einem anderen Konto — und ein blosser neuer Lauf repariert das NICHT,
// weil stripe:sync-catalogue jede Zeile überspringt, die schon eine ID
// trägt. Das muss dastehen, sonst schickt die Seite den Betreiber auf einen
// Befehl, der ihm „already in step" antwortet.
expect(checkFor('billing.catalogue_synced')->breaks)
->not->toBe(__('readiness.billing.catalogue_synced_breaks'))
// Nur DIESER Fall trägt die Leeranweisung.
->and(checkFor('billing.catalogue_synced')->breaks)->toContain('stripe_plan_prices');
});
/**
* Der dritte Zustand, und er darf nicht mit dem zweiten zusammenfallen.
*
* IDs liegen da, aber nichts sagt, aus welchem Konto weil ein Abgleich
* mittendrin abgebrochen ist, oder weil eine Bestellung sich ihren fehlenden
* Preis selbst angelegt hat (CheckoutController PlanPrices::ensure(),
* BookAddon AddonPrices::ensure(); keiner der beiden ruft record()). Sie
* stammen aus genau dem geltenden Konto. Wer hier die Leeranweisung liest,
* löscht einen Katalog, an dem laufende Verträge abgerechnet werden.
*/
it('tells an unrecorded origin apart from a foreign account', function () {
PlanPrice::query()->update(['stripe_price_id' => 'price_minted_by_a_checkout']);
Settings::forget(StripeCatalogueMode::SETTING);
// Blockierend bleibt es: beweisen lässt sich die Herkunft nicht, und die
// Seite behauptet nichts, was sie nicht weiß.
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeFalse();
// Aber die Handlungsanweisung ist „lauf den Abgleich noch einmal", nicht
// „lösche deinen Katalog" — kein Registername in diesem Satz.
expect(checkFor('billing.catalogue_synced')->breaks)
->not->toContain('stripe_plan_prices')
->and(checkFor('billing.catalogue_synced')->breaks)->toContain('stripe:sync-catalogue');
});
it('is satisfied again once the catalogue was synced in the mode that is running', function () {
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_live_account']);
OperatingMode::set(OperatingMode::Live);
StripeCatalogueMode::record();
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue();
});

View File

@ -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('');
}

View File

@ -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',

View File

@ -0,0 +1,188 @@
<?php
use App\Livewire\Admin\Readiness as ReadinessPage;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
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);
});
/**
* Der „Prüfen"-Knopf darf der Prüfung nicht widersprechen.
*
* StripeCheck::run() rechnet die Kontoart des Schlüssels aus, die Seite hat
* sie verworfen und nur `ok`/`reason` gerendert ein Live-Schlüssel im
* Testplatz antwortet bei Stripe einwandfrei, und die Seite las darüber
* „Geprüft: in Ordnung", während die Plakette oben „Testbetrieb" sagte.
*/
it('names the account a checked key belongs to, and says when it is the wrong one', function () {
Http::fake(['*' => Http::response(['id' => 'acct_1'], 200)]);
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(ReadinessPage::class)
->call('runCheck', 'billing.stripe_secret')
->assertSee(__('readiness.check_key_mode', ['mode' => __('readiness.mode.live')]))
->assertSee(__('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.test')]));
});
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."
);
}
});
/**
* Fix-Runde, Befund 1: der Auflöser (checkUrl()) ist nur so gut wie seine
* Quelle. Neun Prüfschlüssel trugen `tab: 'integrations'` kein Mitglied von
* Integrations::TABS und landeten deshalb alle auf dem Standard-Reiter
* ('services'), unabhängig davon, wo ihr Feld tatsächlich liegt. Dieser Test
* hält JEDEN Prüfschlüssel gegen die eine Stelle, an der sein Feld wirklich
* steht nicht nur die neun, die diesmal falsch waren damit der nächste
* falsche Wert genauso durchrutscht wie diese.
*/
it('sends every check to the tab or page where its field actually lives', function () {
$expectedTab = [
'billing.stripe_secret' => 'services',
'billing.webhook_secret' => 'env',
'billing.company_details' => 'company',
'billing.invoice_series' => 'company',
'billing.catalogue_synced' => 'services',
'onboarding.ssh_private_key' => 'platform',
'onboarding.secrets_key' => 'env',
'onboarding.vpn_config_key' => 'env',
'onboarding.wg_hub' => 'platform',
'onboarding.datacenter' => 'datacenters',
'provisioning.dns_token' => 'services',
'provisioning.dns_zone' => 'services',
'provisioning.traefik_path' => 'services',
'provisioning.usable_host' => 'hosts',
'provisioning.vm_template' => 'plans',
'provisioning.monitoring_token' => 'services',
'delivery.mailer_not_log' => 'mail',
'delivery.mailbox' => 'mail',
'delivery.mail_templates' => 'templates',
'delivery.inbound_mail_password' => 'services',
'operation.scheduler' => 'env',
'operation.queue_provisioning' => 'env',
];
foreach (Readiness::all() as $check) {
// NICHT expect($expectedTab)->toHaveKey($check->key, $message) — genau
// dieselbe Falle wie bei toContain() weiter oben: toHaveKey()s zweites
// Argument ist der erwartete WERT des Schlüssels, keine Fehlermeldung.
expect(array_key_exists($check->key, $expectedTab))->toBeTrue(
"Neuer Prüfschlüssel [{$check->key}] hat keinen erwarteten tab-Wert in diesem Test — bitte nachtragen."
);
expect($check->tab)->toBe(
$expectedTab[$check->key],
"{$check->key} zeigt auf '{$check->tab}', sein Feld liegt aber unter '{$expectedTab[$check->key]}'."
);
}
});
/**
* Fix-Runde, Befund 2: mount() lässt hosts.manage ODER secrets.manage durch,
* runCheck() verlangt aber secrets.manage allein. Ein Admin (nur
* hosts.manage) erreicht die Seite und sah bislang auch den Knopf der beim
* Drücken 403 geworfen hätte. Ein sichtbarer Knopf, der beim Drücken
* scheitert, ist schlimmer als gar keiner.
*/
it('hides the check button from an operator who could not press it anyway', function () {
Livewire::actingAs(Operator::factory()->role('Admin')->create(), 'operator')
->test(ReadinessPage::class)
->assertDontSee(__('readiness.run_check'));
});
/**
* Fix-Runde, Befund 3: die eine Eigenschaft, wegen der diese ganze
* Konstruktion existiert die vier RUNNABLE-Prüfungen laufen NUR auf
* Knopfdruck, weil DnsTokenCheck einen echten TXT-Eintrag in die
* Produktivzone schreibt war bislang ungeprüft.
*/
it('sends nothing over the network on a plain page load', function () {
Http::fake();
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(ReadinessPage::class);
Http::assertNothingSent();
});

View File

@ -1,8 +1,10 @@
<?php
use App\Models\PlanPrice;
use App\Services\Secrets\SecretCipher;
use App\Support\OperatingMode;
use App\Support\Settings;
use App\Support\StripeCatalogueMode;
use Illuminate\Support\Facades\DB;
/**
@ -54,6 +56,41 @@ it('files a stripe live key in the live slot and leaves the mode alone', functio
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
/**
* Der Katalog, der schon bei Stripe liegt, bekommt seine Herkunft mit.
*
* Ein Stripe-Produkt und ein Stripe-Preis gehören dem Konto, das sie
* ausgestellt hat. Auf dieser Installation kann das nur das Konto sein, das
* der eine hinterlegte Schlüssel öffnet ein zweiter war nie da. Ohne diesen
* Nachtrag stünde ein längst abgeglichener Katalog nach der Migration als
* „Herkunft unbekannt" da, und die Bereitschaftsseite verlangte einen neuen
* Abgleich, den niemand braucht.
*/
it('files the already synced catalogue under the account that key opens', function () {
DB::table('app_secrets')->insert([
'key' => 'stripe.secret',
'value' => app(SecretCipher::class)->encrypt('sk_test_abc'),
'created_at' => now(), 'updated_at' => now(),
]);
PlanPrice::query()->limit(1)->update(['stripe_price_id' => 'price_minted_with_that_key']);
runSlotMigration();
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
});
it('claims no catalogue where none was ever synced', function () {
DB::table('app_secrets')->insert([
'key' => 'stripe.secret',
'value' => app(SecretCipher::class)->encrypt('sk_test_abc'),
'created_at' => now(), 'updated_at' => now(),
]);
runSlotMigration();
expect(StripeCatalogueMode::recorded())->toBeNull();
});
it('files every other credential in the live slot', function () {
DB::table('app_secrets')->insert([
'key' => 'dns.token',

View File

@ -61,7 +61,11 @@ 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');
// NICHT 'none': im Testbetrieb ist genau diese Live-Zeile in Kraft (der
// Rückfall zwei Tests weiter oben). 'none' hieße „nichts hinterlegt" über
// ein Zugangsdatum, mit dem gerade gearbeitet wird — siehe den Test
// „names the live slot as the source…" weiter unten.
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('stored_live');
});
it('forgets one slot without touching the other', function () {
@ -70,6 +74,59 @@ it('forgets one slot without touching the other', function () {
$this->vault->forget('dns.token', OperatingMode::Test);
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none');
// 'stored_live' statt 'stored' ist der Beweis, dass die Testzeile weg ist:
// was jetzt im Testplatz gilt, kommt aus dem Live-Platz.
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('stored_live');
expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored');
});
/**
* Der Widerspruch, den das Schluss-Review gemessen hat und warum er zählt.
*
* Modus test, `dns.token` nur im `:live`-Platz, `HETZNER_DNS_TOKEN` in der
* `.env`: `get()` lieferte den gespeicherten Live-Wert (der Rückfall oben),
* `source()` meldete dazu „environment" und `outline()` null. Die Karte in der
* Konsole las sich damit als „Aus der Serverdatei" über ein Zugangsdatum, das
* gerade aus der Datenbank in Kraft ist ein Betreiber, der den Token wechseln
* will, wird in die `.env` geschickt, wo eine Änderung wirkungslos bleibt.
* Genau die Falle, die im Kopfkommentar von OperatingMode als Anlass dieses
* ganzen Vorhabens steht, und R19s Attrappe wörtlich: eine Anzeige, die sich
* wie eine Auskunft liest, falsch ist, und den Nächsten vom Nachsehen abhält.
*
* Ein DRITTER Rückgabewert, nicht 'stored': 'stored' blendet den
* „Vergessen"-Knopf ein, und der zeigte dann auf den leeren Testplatz.
*/
it('names the live slot as the source when it is the one in force', function () {
config()->set('provisioning.dns.token', 'from-env');
$this->vault->put('dns.token', 'live-token-value', $this->by, OperatingMode::Live);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('dns.token'))->toBe('live-token-value')
->and($this->vault->source('dns.token'))->toBe('stored_live');
});
it('shows the outline and the date of the value that is actually in force', function () {
// Die zweite Folge desselben Befunds: ohne Umriss fehlt der Block
// „Hinterlegt / Geändert" ganz, und die Karte behauptet durch Weglassen,
// es liege nichts vor.
$this->vault->put('dns.token', 'live-token-value', $this->by, OperatingMode::Live);
OperatingMode::set(OperatingMode::Test);
$outline = $this->vault->outline('dns.token');
expect($outline)->not->toBeNull()
->and($outline)->toContain('alue')
->and($this->vault->updatedAt('dns.token'))->not->toBeNull();
});
it('keeps the strict entry out of the fallback here too', function () {
// Stripe hat keinen Rückfall, in keine Richtung — source() darf ihn
// deshalb auch nicht als 'stored_live' anzeigen, sonst widerspräche die
// Karte wieder der Kasse.
$this->vault->put('stripe.secret', 'sk_live_real', $this->by, OperatingMode::Live);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('stripe.secret'))->toBeNull()
->and($this->vault->source('stripe.secret'))->toBe('none')
->and($this->vault->outline('stripe.secret'))->toBeNull();
});

View File

@ -0,0 +1,120 @@
<?php
use App\Livewire\Admin\ConfirmSwitchMode;
use App\Livewire\Admin\Integrations;
use App\Models\Operator;
use App\Support\OperatingMode;
use Livewire\Livewire;
// Die Rollen heißen exakt so (Operator::OPERATOR_ROLES), und die Fabrik setzt
// sie über ->role(), nicht über eine ->owner()-Abkürzung. Das Testpasswort ist
// `passwort-fuer-tests` — OperatorFactory::definition().
//
// ZWEI PLAN-FEHLER (Brief/Plan wörtlich übernommen, hier korrigiert):
//
// 1. Livewire::actingAs() ohne den zweiten Parameter meldet nur den
// 'web'-Guard an — genau die Falle, die tests/Pest.php oben bereits für
// operator()/admin() dokumentiert (§"Shared account helpers").
// confirmationGuard() dieser Seite ist 'operator'.
// 2. ConfirmsPassword::confirmPassword() nimmt KEIN Argument entgegen — sie
// liest die eigene Eigenschaft $confirmablePassword (so gesetzt in jedem
// bestehenden Aufrufer, siehe tests/Feature/Admin/IntegrationsPageTest.php:
// ->set('confirmablePassword', …)->call('confirmPassword')). Ein Aufruf
// als ->call('confirmPassword', 'passwort-fuer-tests') kompiliert (PHP
// verwirft überzählige Argumente stillschweigend), setzt die Eigenschaft
// aber nie — die Prüfung vergleicht gegen einen leeren String und scheitert.
//
// Beide Fehler zusammen ließen den zweiten Test bei OperatingMode::Live
// stehen, obwohl scheinbar alles aufgerufen wurde: confirmPassword()
// scheiterte am Passwortvergleich, switchMode() brach danach in
// guardSecrets() mit 403 ab, bevor es je etwas schrieb.
it('refuses to switch without the secrets capability', function () {
$admin = Operator::factory()->role('Admin')->create(); // hosts.manage, nicht secrets.manage
Livewire::actingAs($admin, 'operator')
->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, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'passwort-fuer-tests')
->call('confirmPassword')
->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, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'passwort-fuer-tests')
->call('confirmPassword')
->call('switchMode', 'staging');
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
// ---- ConfirmSwitchMode selbst — nach dem Vorbild der ConfirmSaveSecret/
// ConfirmForgetSecret-Tests in tests/Feature/Admin/IntegrationsPageTest.php.
// Ein Modal ist OHNE die Route-Middleware der Seite erreichbar (R23-
// Kopfkommentar in CLAUDE.md) — "gelesen und für korrekt befunden" ist keine
// Prüfung, ein eigener Test schon.
it('does not open the switch confirmation without the capability', function () {
$admin = Operator::factory()->role('Admin')->create(); // hosts.manage, nicht secrets.manage
Livewire::actingAs($admin, 'operator')
->test(ConfirmSwitchMode::class, ['mode' => 'test'])
->assertForbidden();
});
it('confirms a switch through the modal without changing the mode itself', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(ConfirmSwitchMode::class, ['mode' => 'test'])
->call('confirm')
->assertDispatched('mode-switch-confirmed', mode: 'test');
// Das Modal selbst ruft OperatingMode::set() nirgends auf — nur
// Integrations::switchMode() darf das, hinter guardSecrets(). Bestätigen
// im Modal allein darf den Modus nicht bewegen.
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
// ---- Fix-Runde, Befund 1 (Important): keine Karte sagte, in welchen Platz
// sie schreibt. save()/render() lösen ohne Modus-Argument gegen
// OperatingMode::current() auf — funktional kein Problem, aber die Karte sah
// in Test und Live identisch aus. Reine Darstellung (secret-field.blade.php
// liest nur $mode, das Integrations::render() schon berechnet — kein neuer
// Aufruf von source()/outline()/updatedAt()), deshalb genügt ein assertSee je
// Modus statt eines erfundenen Verhaltens-Tests.
it('names the slot a saved value would land in, on the card itself', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'passwort-fuer-tests')
->call('confirmPassword')
->assertSee(__('secrets.slot.live'))
->assertDontSee(__('secrets.slot.test'));
OperatingMode::set(OperatingMode::Test);
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'passwort-fuer-tests')
->call('confirmPassword')
->assertSee(__('secrets.slot.test'))
->assertDontSee(__('secrets.slot.live'));
});