set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); }); // ---- P1 (1): Der Signaturschlüssel darf NICHT über die Modusgrenze fallen ---- it('never lets a stored live signing secret stand in for an empty test slot', function () { // Jeder andere Tresorwert bezeichnet dasselbe Konto in beiden Modi und darf // deshalb vom Live-Platz einspringen. Dieser nicht: Stripe stellt je Modus // ein EIGENES Signaturgeheimnis aus. Ohne diese Trennung prüft der // Testbetrieb seine Ereignisse mit dem Live-Schlüssel — sie scheitern alle, // und die Konsole meldet den Platz trotzdem als belegt. config()->set('services.stripe.webhook_secret', ''); config()->set('services.stripe.webhook_secret_test', ''); app(SecretVault::class)->put( 'stripe.webhook_secret', 'whsec_live_only', Operator::factory()->create(), OperatingMode::Live ); OperatingMode::set(OperatingMode::Test); expect(StripeWebhookSecret::current())->toBe('') ->and(app(SecretVault::class)->source('stripe.webhook_secret'))->toBe('none'); }); it('still uses the test line from the server file when only the live slot is stored', function () { // Die Sperre gilt dem gespeicherten Platz, nicht der Serverdatei: dort // steht je Modus eine eigene Zeile, und die ist für diesen Modus richtig. config()->set('services.stripe.webhook_secret_test', 'whsec_test_env'); app(SecretVault::class)->put( 'stripe.webhook_secret', 'whsec_live_only', Operator::factory()->create(), OperatingMode::Live ); OperatingMode::set(OperatingMode::Test); expect(StripeWebhookSecret::current())->toBe('whsec_test_env') ->and(app(SecretVault::class)->source('stripe.webhook_secret'))->toBe('environment'); }); it('leaves the ordinary entries falling back as they did', function () { // Die Trennung gilt NUR dem Signaturschlüssel. Ein DNS-Token bezeichnet // dasselbe Hetzner-Konto in beiden Modi — dort ist der Rückfall gewollt und // ausdrücklich getestet (SecretVaultModeTest). app(SecretVault::class)->put( 'dns.token', 'token_live', Operator::factory()->create(), OperatingMode::Live ); OperatingMode::set(OperatingMode::Test); expect(app(SecretVault::class)->get('dns.token'))->toBe('token_live') ->and(app(SecretVault::class)->source('dns.token'))->toBe('stored_live'); }); // ---- P1 (2): Alte Record-IDs dürfen keinen Host unlöschbar machen ---- it('does not make a host unpurgeable because its record id predates the cloud API', function () { // Mein Fehler im Review: ich hatte auf DIESER Datenbank null Zeilen gezählt // und daraus geschlossen, es gebe nirgends welche. Der Live-Server ist eine // andere Installation. Eine Alt-ID muss den Abbau deshalb durchlassen — // sonst bleibt der Host für immer im Pool, weil PurgeHost bei jedem Versuch // an derselben Zeile stirbt. Settings::set('provisioning.dns_zone', 'probe.example'); Http::preventStrayRequests(); Http::fake(); // Kein `toThrow(Throwable::class)` — Pest prüft bei einer Schnittstelle // die MELDUNG, nicht den Typ, und eine Verneinung davon ist immer grün. // Hier zählt schlicht, dass der Aufruf zurückkommt. (new HttpHetznerDnsClient)->deleteRecord('rec-123'); // Und zwar OHNE einen Aufruf zu bauen, der irgendetwas anderes trifft. Http::assertNothingSent(); }); it('says the leftover record out loud instead of swallowing it', function () { // Durchlassen heißt nicht verschweigen: der Eintrag bleibt in der Zone // stehen, und die einzige Stelle, an der das noch auffallen kann, ist das // Log. Ein stiller Rücksprung wäre die Attrappe, vor der R19 warnt. Settings::set('provisioning.dns_zone', 'probe.example'); Http::fake(); Log::shouldReceive('warning') ->once() ->withArgs(fn (string $message, array $context = []) => str_contains($message, 'legacy') && ($context['record_id'] ?? null) === 'rec-123'); (new HttpHetznerDnsClient)->deleteRecord('rec-123'); }); it('migrates the instance records it can address to the new format', function () { // `dns_records` trägt fqdn UND typ neben der ID — daraus lässt sich der // RRSet-Name exakt bilden. Für Hosts geht das nicht (dort steht nur der // Kurzname), die bleiben der toleranten Löschung oben überlassen. Settings::set('provisioning.dns_zone', 'probe.example'); $instance = Instance::factory()->create(); $legacy = DnsRecord::query()->create([ 'instance_id' => $instance->id, 'provider' => 'hetzner', 'record_id' => 'rec-legacy-42', 'fqdn' => 'berger.probe.example', 'type' => 'A', 'value' => '203.0.113.9', ]); app(LegacyRecordIds::class)->migrate(); expect($legacy->fresh()->record_id)->toBe('berger/A'); }); it('leaves a record it cannot address alone rather than guessing', function () { Settings::set('provisioning.dns_zone', 'probe.example'); $instance = Instance::factory()->create(); $foreign = DnsRecord::query()->create([ 'instance_id' => $instance->id, 'provider' => 'hetzner', 'record_id' => 'rec-foreign', 'fqdn' => 'irgendwo.fremde-zone.test', 'type' => 'A', 'value' => '203.0.113.9', ]); app(LegacyRecordIds::class)->migrate(); expect($foreign->fresh()->record_id)->toBe('rec-foreign'); }); it('counts as done for a host whose legacy record could not be removed', function () { Settings::set('provisioning.dns_zone', 'probe.example'); $s = fakeServices(); $host = Host::factory()->active()->create(['dns_record_id' => 'rec-legacy-99']); // Der Fake spricht dieselbe Sprache wie der echte Client — auch hier. $s['dns']->deleteRecord($host->dns_record_id); })->throwsNoExceptions();