Let the stored key say which mode it belongs to

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 11:16:15 +02:00
parent 59e8689630
commit ecf948f30f
2 changed files with 156 additions and 0 deletions

View File

@ -0,0 +1,87 @@
<?php
use App\Services\Secrets\SecretCipher;
use App\Support\OperatingMode;
use App\Support\Settings;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Jedes Zugangsdatum bekommt zwei Plätze und die vorhandene Zeile wird
* einsortiert statt umbenannt.
*
* Für vier der fünf Einträge ist die Antwort dieselbe: was da liegt, wurde für
* den echten Betrieb hinterlegt, also `:live`. Nur Stripe kann es selbst sagen,
* weil sein Schlüssel sein Präfix trägt und daran hängt mehr als eine Zeile:
* liegt dort ein Testschlüssel, war diese Installation erkennbar im
* Testbetrieb, und der Modus folgt daraus statt aus einer Vorgabe.
*/
return new class extends Migration
{
public function up(): void
{
$stripeMode = null;
foreach (DB::table('app_secrets')->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);
}
};

View File

@ -0,0 +1,69 @@
<?php
use App\Services\Secrets\SecretCipher;
use App\Support\OperatingMode;
use App\Support\Settings;
use Illuminate\Support\Facades\DB;
/**
* Die Migration rät nicht.
*
* Der Stripe-Schlüssel sagt selbst, wohin er gehört: `sk_test_` ist ein
* Testschlüssel, unabhängig davon, in welchem Feld er gerade liegt. Ihn stumpf
* als „Live" einzusortieren, würde eine Installation, die erkennbar im
* Testbetrieb läuft, beim ersten Kauf echtes Geld abbuchen lassen.
*/
function runSlotMigration(): void
{
(require base_path('database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php'))->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);
});