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 new file mode 100644 index 0000000..3a92db9 --- /dev/null +++ b/database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php @@ -0,0 +1,87 @@ +get() as $row) { + // Schon einsortiert (etwa bei einem zweiten Lauf): unangetastet. + if (Str::contains($row->key, ':')) { + continue; + } + + $mode = OperatingMode::Live; + + if ($row->key === 'stripe.secret') { + $mode = $this->stripeModeOf($row->value); + $stripeMode = $mode; + } + + DB::table('app_secrets') + ->where('id', $row->id) + ->update(['key' => $row->key.':'.$mode->value]); + } + + // Nur wenn ein Stripe-Schlüssel da war. Ohne ihn bleibt die Vorgabe + // stehen, und die ist live. + if ($stripeMode !== null) { + Settings::set(OperatingMode::SETTING, $stripeMode->value); + } + } + + private function stripeModeOf(string $stored): OperatingMode + { + try { + $secret = app(SecretCipher::class)->decrypt($stored); + } catch (Throwable) { + // Unlesbar heißt: wir wissen es nicht. `:live` ist der Zustand vor + // dieser Migration, also die Fortsetzung des Status quo statt einer + // neuen Behauptung. Die Bereitschaftsseite meldet ihn ohnehin. + return OperatingMode::Live; + } + + return Str::contains($secret, '_test_') ? OperatingMode::Test : OperatingMode::Live; + } + + public function down(): void + { + foreach (DB::table('app_secrets')->get() as $row) { + if (! Str::endsWith($row->key, [':live', ':test'])) { + continue; + } + + $bare = Str::beforeLast($row->key, ':'); + + // Eine Zeile je Eintrag kann zurück; die zweite hat im alten Schema + // keinen Platz und würde am Eindeutigkeitsindex scheitern. + if (DB::table('app_secrets')->where('key', $bare)->exists()) { + DB::table('app_secrets')->where('id', $row->id)->delete(); + + continue; + } + + DB::table('app_secrets')->where('id', $row->id)->update(['key' => $bare]); + } + + Settings::forget(OperatingMode::SETTING); + } +}; diff --git a/tests/Feature/SecretSlotMigrationTest.php b/tests/Feature/SecretSlotMigrationTest.php new file mode 100644 index 0000000..837c3f0 --- /dev/null +++ b/tests/Feature/SecretSlotMigrationTest.php @@ -0,0 +1,69 @@ +up(); +} + +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); +}); + +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); +});