diff --git a/app/Services/Secrets/SecretVault.php b/app/Services/Secrets/SecretVault.php index 1788b52..11bbe07 100644 --- a/app/Services/Secrets/SecretVault.php +++ b/app/Services/Secrets/SecretVault.php @@ -131,16 +131,7 @@ final class SecretVault { $this->assertKnown($key); - $mode = OperatingMode::current(); - $row = $this->row($key, $mode); - - // Fall back to the live slot when the active mode's slot is empty. - // ONLY from test to live, never the other way: using a test credential - // in live operation because the real one is missing is the dangerous - // direction, and must stay an explicit, visible gap instead. - if ($row === null && $mode->isTest() && ! $this->isStrict($key)) { - $row = $this->row($key, OperatingMode::Live); - } + $row = $this->inForce($key, OperatingMode::current()); if ($row === null) { // Strict entries never see the environment: see isStrict() below. @@ -162,13 +153,34 @@ final class SecretVault } } - /** Where the value in force comes from: stored, environment, or nowhere. */ + /** + * Where the value in force under this mode comes from — four answers now. + * + * 'stored_live' is the third, and it exists because the second-best answers + * were both lies. The measured case: mode test, dns.token only in the :live + * slot, HETZNER_DNS_TOKEN also in the .env. get() hands out the stored live + * value (the fallback it has always had), while this method reported + * 'environment' — the console then read "Aus der Serverdatei" over a + * credential that is in force from the database, and an operator changing + * the .env line would have seen no effect at all. That is R19's dummy in + * miniature: a display that reads like an answer, is wrong, and stops the + * next person looking. + * + * Returning 'stored' instead would trade one lie for another: the console + * shows the "Vergessen" button only for 'stored', and it would then have + * pointed at the EMPTY test slot, which forget() would happily delete + * nothing from. + * + * The $mode argument names which mode is IN FORCE, not which row to look + * at — same question get() answers, so that the two cannot disagree. + */ public function source(string $key, ?OperatingMode $mode = null): string { $this->assertKnown($key); return match (true) { $this->row($key, $mode) !== null => 'stored', + $this->fallback($key, $mode) !== null => 'stored_live', $this->isStrict($key) => 'none', filled(config(self::REGISTRY[$key]['config'])) => 'environment', default => 'none', @@ -177,12 +189,12 @@ final class SecretVault public function outline(string $key, ?OperatingMode $mode = null): ?string { - return $this->row($key, $mode)?->outline; + return $this->inForce($key, $mode)?->outline; } public function updatedAt(string $key, ?OperatingMode $mode = null): ?string { - return $this->row($key, $mode)?->updated_at; + return $this->inForce($key, $mode)?->updated_at; } public function put(string $key, string $value, Operator $by, ?OperatingMode $mode = null): void @@ -234,6 +246,39 @@ final class SecretVault return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first(); } + /** + * The stored row that actually applies under this mode: its own slot, or + * the live slot standing in for it. + * + * ONE place, called by get(), outline() and updatedAt() — and asked by + * source() through fallback() — because these four answering the same + * question separately is precisely how the console came to say "nothing + * stored" about a credential the provisioning run was using. + */ + private function inForce(string $key, ?OperatingMode $mode = null): ?object + { + return $this->row($key, $mode) ?? $this->fallback($key, $mode); + } + + /** + * The live slot filling in for an empty test slot. + * + * ONLY from test to live, never the other way: using a test credential in + * live operation because the real one is missing is the dangerous + * direction, and must stay an explicit, visible gap instead. Strict entries + * (Stripe) have no fallback at all, in either direction. + */ + private function fallback(string $key, ?OperatingMode $mode = null): ?object + { + $mode ??= OperatingMode::current(); + + if (! $mode->isTest() || $this->isStrict($key)) { + return null; + } + + return $this->row($key, OperatingMode::Live); + } + /** Does this entry carry the strict trait? Populated in task 3. */ private function isStrict(string $key): bool { diff --git a/lang/de/secrets.php b/lang/de/secrets.php index 0cddd04..d5d0e0d 100644 --- a/lang/de/secrets.php +++ b/lang/de/secrets.php @@ -34,6 +34,11 @@ return [ ], 'source_stored' => 'Hier hinterlegt', + // Im Testbetrieb gilt der Live-Wert, wenn der Testplatz leer ist — genau + // das, was SecretVault::get() tut. Ohne eigene Beschriftung stand hier + // „Aus der Serverdatei" über einem Wert aus der Datenbank, und wer die + // .env daraufhin änderte, änderte nichts. + 'source_stored_live' => 'Aus dem Live-Platz', 'source_environment' => 'Aus der Serverdatei', 'source_none' => 'Nicht gesetzt', 'stored_value' => 'Hinterlegt', diff --git a/lang/en/secrets.php b/lang/en/secrets.php index 40fb833..164bef3 100644 --- a/lang/en/secrets.php +++ b/lang/en/secrets.php @@ -34,6 +34,10 @@ return [ ], 'source_stored' => 'Stored here', + // In test mode the live value applies when the test slot is empty — + // exactly what SecretVault::get() does. Without a label of its own this + // read "From the server file" over a value coming from the database. + 'source_stored_live' => 'From the live slot', 'source_environment' => 'From the server file', 'source_none' => 'Not set', 'stored_value' => 'Stored', diff --git a/resources/views/components/admin/secret-field.blade.php b/resources/views/components/admin/secret-field.blade.php index 7aefcb7..f7c818b 100644 --- a/resources/views/components/admin/secret-field.blade.php +++ b/resources/views/components/admin/secret-field.blade.php @@ -31,10 +31,15 @@ {{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}"> {{ __('secrets.slot.'.$mode->value) }} + {{-- Aufgezählt nach den zwei Enden statt nach jedem Wert einzeln: + grün heißt „liegt in DIESEM Platz", gelb heißt „nirgends", und + alles dazwischen (aus der Serverdatei, aus dem Live-Platz) ist + blau — in Kraft, aber woanders her. So bekommt ein künftiger + vierter Wert nicht still die Farbe von „Nicht gesetzt". --}} + : ($entry['source'] === 'none' ? 'border-warning-border bg-warning-bg text-warning' + : 'border-info-border bg-info-bg text-info') }}"> {{ __('secrets.source_'.$entry['source']) }} diff --git a/tests/Feature/SecretVaultModeTest.php b/tests/Feature/SecretVaultModeTest.php index d54a662..906ed86 100644 --- a/tests/Feature/SecretVaultModeTest.php +++ b/tests/Feature/SecretVaultModeTest.php @@ -61,7 +61,11 @@ it('reports the source per slot', function () { $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live); expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored'); - expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none'); + // NICHT 'none': im Testbetrieb ist genau diese Live-Zeile in Kraft (der + // Rückfall zwei Tests weiter oben). 'none' hieße „nichts hinterlegt" über + // ein Zugangsdatum, mit dem gerade gearbeitet wird — siehe den Test + // „names the live slot as the source…" weiter unten. + expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('stored_live'); }); it('forgets one slot without touching the other', function () { @@ -70,6 +74,59 @@ it('forgets one slot without touching the other', function () { $this->vault->forget('dns.token', OperatingMode::Test); - expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none'); + // 'stored_live' statt 'stored' ist der Beweis, dass die Testzeile weg ist: + // was jetzt im Testplatz gilt, kommt aus dem Live-Platz. + expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('stored_live'); expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored'); }); + +/** + * Der Widerspruch, den das Schluss-Review gemessen hat — und warum er zählt. + * + * Modus test, `dns.token` nur im `:live`-Platz, `HETZNER_DNS_TOKEN` in der + * `.env`: `get()` lieferte den gespeicherten Live-Wert (der Rückfall oben), + * `source()` meldete dazu „environment" und `outline()` null. Die Karte in der + * Konsole las sich damit als „Aus der Serverdatei" über ein Zugangsdatum, das + * gerade aus der Datenbank in Kraft ist — ein Betreiber, der den Token wechseln + * will, wird in die `.env` geschickt, wo eine Änderung wirkungslos bleibt. + * Genau die Falle, die im Kopfkommentar von OperatingMode als Anlass dieses + * ganzen Vorhabens steht, und R19s Attrappe wörtlich: eine Anzeige, die sich + * wie eine Auskunft liest, falsch ist, und den Nächsten vom Nachsehen abhält. + * + * Ein DRITTER Rückgabewert, nicht 'stored': 'stored' blendet den + * „Vergessen"-Knopf ein, und der zeigte dann auf den leeren Testplatz. + */ +it('names the live slot as the source when it is the one in force', function () { + config()->set('provisioning.dns.token', 'from-env'); + $this->vault->put('dns.token', 'live-token-value', $this->by, OperatingMode::Live); + OperatingMode::set(OperatingMode::Test); + + expect($this->vault->get('dns.token'))->toBe('live-token-value') + ->and($this->vault->source('dns.token'))->toBe('stored_live'); +}); + +it('shows the outline and the date of the value that is actually in force', function () { + // Die zweite Folge desselben Befunds: ohne Umriss fehlt der Block + // „Hinterlegt / Geändert" ganz, und die Karte behauptet durch Weglassen, + // es liege nichts vor. + $this->vault->put('dns.token', 'live-token-value', $this->by, OperatingMode::Live); + OperatingMode::set(OperatingMode::Test); + + $outline = $this->vault->outline('dns.token'); + + expect($outline)->not->toBeNull() + ->and($outline)->toContain('alue') + ->and($this->vault->updatedAt('dns.token'))->not->toBeNull(); +}); + +it('keeps the strict entry out of the fallback here too', function () { + // Stripe hat keinen Rückfall, in keine Richtung — source() darf ihn + // deshalb auch nicht als 'stored_live' anzeigen, sonst widerspräche die + // Karte wieder der Kasse. + $this->vault->put('stripe.secret', 'sk_live_real', $this->by, OperatingMode::Live); + OperatingMode::set(OperatingMode::Test); + + expect($this->vault->get('stripe.secret'))->toBeNull() + ->and($this->vault->source('stripe.secret'))->toBe('none') + ->and($this->vault->outline('stripe.secret'))->toBeNull(); +});