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>
feature/betriebsmodus
nexxo 2026-07-30 17:05:58 +02:00
parent 5094f70c19
commit 823eeaf413
9 changed files with 164 additions and 8 deletions

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),
];
@ -63,7 +68,7 @@ final class StripeCheck
* valid while the endpoint listens for the wrong five events, and nothing
* fails until a payment is not recorded.
*
* @return array<int, array<string, mixed>>|null null when the key may not read them
* @return array<int, array<string, mixed>>|null null when the key may not read them
*/
private function webhooks(string $secret): ?array
{

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

@ -22,18 +22,33 @@ 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;
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',

View File

@ -59,7 +59,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

View File

@ -29,6 +29,8 @@ return [
'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',
@ -40,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',

View File

@ -29,6 +29,8 @@ return [
'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',
@ -40,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',

View File

@ -60,6 +60,22 @@
<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>

View File

@ -43,6 +43,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');

View File

@ -3,6 +3,7 @@
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;
@ -47,6 +48,26 @@ it('runs an active check on demand and shows its answer', function () {
->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.