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
parent
5094f70c19
commit
823eeaf413
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue