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(); });