Cover the unreadable branch and make rollback pick live deterministically
The only test that reached SecretCipher::decrypt() failing was an accident of a fixture bug fixed in the previous commit: it encrypted with the wrong key, so decryption threw and the migration's catch(Throwable) branch ran without anyone meaning to test it. Fixing that fixture silently removed the only coverage of a branch that decides a payment key's mode, so it gets its own test with a deliberately unreadable row. down() picked whichever of the :live/:test rows an unordered get() happened to return first to restore to the bare key, and deleted the other — so which credential survived a rollback depended on row order, not a decision. It now processes :live rows first unconditionally, then :test rows only where the bare key is still free, so the live credential always wins when both exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus
parent
727f2e41db
commit
ac44717d1b
|
|
@ -64,15 +64,33 @@ return new class extends Migration
|
|||
|
||||
public function down(): void
|
||||
{
|
||||
foreach (DB::table('app_secrets')->get() as $row) {
|
||||
if (! Str::endsWith($row->key, [':live', ':test'])) {
|
||||
$rows = DB::table('app_secrets')->get();
|
||||
|
||||
// Zwei Durchläufe über dieselbe Menge, nicht einer — und die
|
||||
// Reihenfolge zwischen den beiden ist die Entscheidung, nicht eine
|
||||
// Nebenwirkung. `:live` geht zuerst und beansprucht den nackten
|
||||
// Schlüssel bedingungslos; `:test` sieht ihn danach schon vergeben und
|
||||
// weicht. Ein einziger Durchlauf, der beide Endungen im selben Schritt
|
||||
// behandelt, hätte stattdessen der Zeilenreihenfolge der Datenbank
|
||||
// überlassen, welcher der beiden Schlüssel überlebt — bei einem
|
||||
// Zahlungsschlüssel die falsche Art von Zufall.
|
||||
foreach ($rows as $row) {
|
||||
if (Str::endsWith($row->key, ':live')) {
|
||||
DB::table('app_secrets')->where('id', $row->id)->update(['key' => Str::beforeLast($row->key, ':')]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (! Str::endsWith($row->key, ':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.
|
||||
// keinen Platz und würde am Eindeutigkeitsindex scheitern. Existiert
|
||||
// der nackte Schlüssel schon, hat ihn der Live-Durchlauf oben gerade
|
||||
// erst belegt — der Testwert wird verworfen, nie der Live-Wert.
|
||||
if (DB::table('app_secrets')->where('key', $bare)->exists()) {
|
||||
DB::table('app_secrets')->where('id', $row->id)->delete();
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ function runSlotMigration(): void
|
|||
(require base_path('database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php'))->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',
|
||||
|
|
@ -67,3 +72,49 @@ it('leaves an installation with no stripe key on live', function () {
|
|||
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue