up(); } function runSlotMigrationDown(): void { (require base_path('database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php'))->down(); } it('files a stripe test key in the test slot and switches the mode', function () { DB::table('app_secrets')->insert([ 'key' => 'stripe.secret', // SecretVault::put() always encrypts with SecretCipher (its own // SECRETS_KEY), never with the app's default APP_KEY-based encrypt() // helper — so a row built with the latter is not a value this // migration would ever actually meet, only one that looks unreadable. 'value' => app(SecretCipher::class)->encrypt('sk_test_abc'), 'created_at' => now(), 'updated_at' => now(), ]); runSlotMigration(); expect(DB::table('app_secrets')->where('key', 'stripe.secret:test')->exists())->toBeTrue(); expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->exists())->toBeFalse(); expect(Settings::get(OperatingMode::SETTING))->toBe('test'); }); it('files a stripe live key in the live slot and leaves the mode alone', function () { DB::table('app_secrets')->insert([ 'key' => 'stripe.secret', 'value' => app(SecretCipher::class)->encrypt('sk_live_abc'), 'created_at' => now(), 'updated_at' => now(), ]); runSlotMigration(); expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->exists())->toBeTrue(); 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', 'value' => app(SecretCipher::class)->encrypt('whatever'), 'created_at' => now(), 'updated_at' => now(), ]); runSlotMigration(); expect(DB::table('app_secrets')->where('key', 'dns.token:live')->exists())->toBeTrue(); expect(DB::table('app_secrets')->where('key', 'dns.token')->exists())->toBeFalse(); }); it('leaves an installation with no stripe key on live', function () { runSlotMigration(); expect(OperatingMode::current())->toBe(OperatingMode::Live); }); it('files an unreadable stripe value in the live slot instead of aborting', function () { // Fix round (Codex review): the earlier version of this file happened to // exercise this branch by accident — its fixture encrypted with the wrong // key, so every "test key" assertion secretly ran through this catch // instead. Fixing that fixture silently deleted the only coverage of a // branch that decides a payment key's mode. This row is deliberately // unreadable on purpose, not by mistake. DB::table('app_secrets')->insert([ 'key' => 'stripe.secret', 'value' => 'not-a-valid-payload-at-all', 'created_at' => now(), 'updated_at' => now(), ]); runSlotMigration(); expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->exists())->toBeTrue(); expect(DB::table('app_secrets')->where('key', 'stripe.secret:test')->exists())->toBeFalse(); expect(OperatingMode::current())->toBe(OperatingMode::Live); }); it('keeps the live slot on rollback when both slots are occupied, not whichever the query happens to return first', function () { // Fix round (Codex review): down() picked whichever of the two rows a // plain, unordered get() returned first to rename back to the bare key, // and deleted the other — so which credential survived a rollback // depended on row order, not on a decision. Inserting the TEST row first // (the lower id) reproduces the old bug: an unordered scan tends to // return rows in id order, so the test slot would have claimed the bare // key and the live one would have been the one discarded. DB::table('app_secrets')->insert([ 'key' => 'stripe.secret:test', 'value' => 'test-slot-value', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('app_secrets')->insert([ 'key' => 'stripe.secret:live', 'value' => 'live-slot-value', 'created_at' => now(), 'updated_at' => now(), ]); runSlotMigrationDown(); expect(DB::table('app_secrets')->where('key', 'stripe.secret')->value('value'))->toBe('live-slot-value'); expect(DB::table('app_secrets')->where('key', 'stripe.secret:test')->exists())->toBeFalse(); expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->exists())->toBeFalse(); });