CluPilotCloud/tests/Feature/Provisioning/HetznerCloudDnsTest.php

175 lines
8.0 KiB
PHP

<?php
use App\Services\Dns\FakeHetznerDnsClient;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
/**
* Die alte DNS-API (dns.hetzner.com/api/v1) ist abgeschaltet und leitet auf die
* Weboberfläche um; die Verwaltung liegt seit dem 7. Oktober 2025 in der
* Cloud-Konsole. Hier steht das neue Modell fest: Bearer statt Auth-API-Token,
* api.hetzner.cloud/v1 statt dns.hetzner.com/api/v1, und RRSets statt einzelner
* Records — adressiert über {name}/{typ} statt über eine Record-ID.
*
* SICHERHEITSAUFLAGE (wie in ActiveChecksTest): jeder Test hier benutzt
* Http::fake() und darf die echte Hetzner-API oder die echte Zone unter keinen
* Umständen berühren. Deshalb eine erfundene Zone statt des .env-Werts dieser
* Installation.
*/
beforeEach(function () {
Settings::set('provisioning.dns_zone', 'probe.example');
config()->set('provisioning.dns.token', 'cloud-token');
Http::preventStrayRequests();
});
it('creates an A rrset through the cloud API and returns its {name}/{type} id', function () {
Http::fake([
'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([
'rrset' => ['id' => 'berger/A', 'name' => 'berger', 'type' => 'A'],
], 201),
]);
$id = (new HttpHetznerDnsClient)->upsertRecord('berger.probe.example', 'A', '203.0.113.9');
// Der zurückgegebene Wert ist genau das, was deleteRecord() später wieder
// auseinandernehmen muss — und genau das, was die API als `rrset.id`
// ausliefert. Eine Zusammensetzung aus Name und Typ, dokumentiert in
// cloud.spec.json und am Live-Konto nachgemessen.
expect($id)->toBe('berger/A');
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& $request->url() === 'https://api.hetzner.cloud/v1/zones/probe.example/rrsets'
// Bearer, nicht mehr `Auth-API-Token` — der alte Kopf wird von der
// Cloud-API schlicht ignoriert und der Aufruf als unangemeldet
// abgewiesen.
&& $request->hasHeader('Authorization', 'Bearer cloud-token')
&& ! $request->hasHeader('Auth-API-Token')
&& $request['type'] === 'A'
&& $request['records'] === [['value' => '203.0.113.9']];
});
});
it('sends the name without the zone suffix', function () {
// Falle 1, und sie ist still: die API weist einen Namen, der auf den
// Zonennamen endet, NICHT zurück. Am Live-Konto gemessen — `POST` mit
// `name: probe.probe.example` kam mit 201 zurück und hätte
// `probe.probe.example.probe.example` in die Zone gelegt. Kein Fehler,
// keine Meldung, nur eine Adresse, die niemand auflöst. Diese Zusicherung
// ist das Einzige, was den Fall überhaupt bemerkt.
Http::fake(['api.hetzner.cloud/*' => Http::response(['rrset' => ['id' => 'berger/A']], 201)]);
(new HttpHetznerDnsClient)->upsertRecord('berger.probe.example', 'A', '203.0.113.9');
Http::assertSent(fn ($request) => $request['name'] === 'berger');
});
it('names the zone apex @ and lower-cases what it sends', function () {
Http::fake(['api.hetzner.cloud/*' => Http::response(['rrset' => ['id' => '@/A']], 201)]);
$client = new HttpHetznerDnsClient;
expect($client->upsertRecord('probe.example', 'A', '203.0.113.9'))->toBe('@/A');
Http::assertSent(fn ($request) => $request['name'] === '@');
$client->upsertRecord('BERGER.Probe.Example', 'A', '203.0.113.9');
Http::assertSent(fn ($request) => $request['name'] === 'berger');
});
it('replaces the records of an rrset that already exists', function () {
// Am Live-Konto gemessen: ein zweites POST auf einen vorhandenen RRSet
// antwortet mit 409 `uniqueness_error`. Das Ersetzen ist eine eigene
// Aktion — und mit ihr wird das Schreiben idempotent, was der alte Client
// mit Suchen-und-Aktualisieren über hundert Seiten nachbauen musste.
Http::fake([
'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([
'error' => ['code' => 'uniqueness_error', 'message' => 'RRSet(s) already exist(s)'],
], 409),
'api.hetzner.cloud/v1/zones/probe.example/rrsets/berger/A/actions/set_records' => Http::response([
'rrset' => ['id' => 'berger/A'],
], 201),
]);
$id = (new HttpHetznerDnsClient)->upsertRecord('berger.probe.example', 'A', '203.0.113.10');
expect($id)->toBe('berger/A');
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->url() === 'https://api.hetzner.cloud/v1/zones/probe.example/rrsets/berger/A/actions/set_records'
&& $request['records'] === [['value' => '203.0.113.10']]);
});
it('refuses an fqdn that does not sit in the configured zone', function () {
// Die Kehrseite derselben stillen Annahme: `fsn-01.clupilot.com` in der
// Zone `probe.example` ist kein Tippfehler, den die API abfängt — sie legt
// `fsn-01.clupilot.com.probe.example` an und meldet Erfolg. Was hier nicht
// hineingehört, wird abgelehnt, statt veröffentlicht zu werden.
Http::fake();
expect(fn () => (new HttpHetznerDnsClient)->upsertRecord('fsn-01.clupilot.com', 'A', '203.0.113.9'))
->toThrow(RuntimeException::class);
Http::assertNothingSent();
});
it('deletes an rrset by its name and type', function () {
Http::fake(['api.hetzner.cloud/*' => Http::response([], 201)]);
(new HttpHetznerDnsClient)->deleteRecord('berger/A');
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.hetzner.cloud/v1/zones/probe.example/rrsets/berger/A');
});
it('treats an already-deleted rrset as done', function () {
// Ein verlorener Rücklauf oder ein Wiederholungslauf nach einem späteren
// Fehlschlag darf einen Eintrag nicht unaufräumbar machen — und seinen
// Host nicht unlöschbar. Am Live-Konto gemessen: 404 `not_found`.
Http::fake([
'api.hetzner.cloud/*' => Http::response([
'error' => ['code' => 'not_found', 'message' => 'RRSet(s) not found'],
], 404),
]);
(new HttpHetznerDnsClient)->deleteRecord('berger/A');
})->throwsNoExceptions();
it('hands back the same id from the fake as from the real client', function () {
// Sonst beweist jeder Test, der über FakeHetznerDnsClient läuft, das
// Gegenteil von dem, was er behauptet: `rec-1` wandert in
// `hosts.dns_record_id`, PurgeHost und clupilot:prune-host-dns räumen damit
// im Test sauber auf — und im Betrieb steht dort `{name}/{typ}` und der
// echte Client wirft. Ein Fake, der eine andere Sprache spricht als das
// Original, ist eine grüne Anzeige über einer roten Grundlage.
Http::fake(['api.hetzner.cloud/*' => Http::response(['rrset' => ['id' => 'berger/A']], 201)]);
$echt = (new HttpHetznerDnsClient)->upsertRecord('berger.probe.example', 'A', '203.0.113.9');
$fake = (new FakeHetznerDnsClient)->upsertRecord('berger.probe.example', 'A', '203.0.113.9');
expect($fake)->toBe($echt);
});
it('refuses in the fake the same out-of-zone fqdn the real client refuses', function () {
expect(fn () => (new FakeHetznerDnsClient)->upsertRecord('fsn-01.clupilot.com', 'A', '203.0.113.9'))
->toThrow(RuntimeException::class);
});
it('lets an id from the old record API pass without building a broken path', function () {
// Alte Record-IDs sind mit der Cloud-Konsole nicht kompatibel. Sie ergäben
// `/rrsets/rec-123/` mit leerem Typ — einen Aufruf, der irgendetwas anderes
// trifft und dabei erfolgreich aussieht. Also wird nichts geschickt.
//
// Geworfen wird aber auch nicht, und das war die Korrektur aus dem
// Codex-Review (P1): PurgeHost ruft deleteRecord(), BEVOR es die Zeile
// löscht, die die ID hält. Ein Wurf hier machte jeden Host mit einer
// Alt-ID dauerhaft unlöschbar — jeder Versuch stirbt an derselben Zeile.
// Der Eintrag bleibt in der Zone stehen; gemeldet wird er im Log
// (siehe CodexFixRoundTest), nicht verschwiegen.
Http::fake();
(new HttpHetznerDnsClient)->deleteRecord('rec-123');
Http::assertNothingSent();
});