diff --git a/app/Services/Stripe/StripeCheck.php b/app/Services/Stripe/StripeCheck.php index ac0338e..a9d953e 100644 --- a/app/Services/Stripe/StripeCheck.php +++ b/app/Services/Stripe/StripeCheck.php @@ -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>|null null when the key may not read them + * @return array>|null null when the key may not read them */ private function webhooks(string $secret): ?array { diff --git a/app/Support/OperatingMode.php b/app/Support/OperatingMode.php index 72afaca..d14d0a0 100644 --- a/app/Support/OperatingMode.php +++ b/app/Support/OperatingMode.php @@ -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; diff --git a/app/Support/Readiness/BillingChecks.php b/app/Support/Readiness/BillingChecks.php index 461ae36..fdbb9a6 100644 --- a/app/Support/Readiness/BillingChecks.php +++ b/app/Support/Readiness/BillingChecks.php @@ -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', diff --git a/database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php b/database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php index dec975a..6bb4449 100644 --- a/database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php +++ b/database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php @@ -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 diff --git a/lang/de/readiness.php b/lang/de/readiness.php index 0db7b84..4fe3bab 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -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', diff --git a/lang/en/readiness.php b/lang/en/readiness.php index f23b629..5d6e5bb 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -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', diff --git a/resources/views/livewire/admin/readiness.blade.php b/resources/views/livewire/admin/readiness.blade.php index 0797b64..07382c1 100644 --- a/resources/views/livewire/admin/readiness.blade.php +++ b/resources/views/livewire/admin/readiness.blade.php @@ -60,6 +60,22 @@ — {{ $result['reason'] }} @endif

+ + {{-- 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 +

+ {{ __('readiness.check_key_mode', ['mode' => __('readiness.mode.'.$keyMode)]) }} + @unless ($keyMode === $mode->value) + {{ __('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.'.$mode->value)]) }} + @endunless +

+ @endif @endif diff --git a/tests/Feature/Readiness/BillingChecksTest.php b/tests/Feature/Readiness/BillingChecksTest.php index 2d80adc..1fd88e7 100644 --- a/tests/Feature/Readiness/BillingChecksTest.php +++ b/tests/Feature/Readiness/BillingChecksTest.php @@ -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'); diff --git a/tests/Feature/ReadinessPageTest.php b/tests/Feature/ReadinessPageTest.php index d0a7b36..32a4be5 100644 --- a/tests/Feature/ReadinessPageTest.php +++ b/tests/Feature/ReadinessPageTest.php @@ -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.