CluPilotCloud/tests/Feature/Admin/CodexFixRoundTest.php

153 lines
6.1 KiB
PHP

<?php
use App\Models\DnsRecord;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Operator;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Dns\LegacyRecordIds;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Settings;
use App\Support\StripeWebhookSecret;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Fix-Runde zum Codex-Review vom 31.7.2026, zwei P1.
*/
beforeEach(function () {
config()->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();
expect(fn () => (new HttpHetznerDnsClient)->deleteRecord('rec-123'))->not->toThrow(Throwable::class);
// 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();