diff --git a/app/Services/Dns/DnsTokenCheck.php b/app/Services/Dns/DnsTokenCheck.php index ed4df3f..aaeef19 100644 --- a/app/Services/Dns/DnsTokenCheck.php +++ b/app/Services/Dns/DnsTokenCheck.php @@ -65,8 +65,30 @@ final class DnsTokenCheck return ['ok' => false, 'reason' => 'zone_list_failed', 'status' => $zones->status()]; } - $verfuegbar = collect($zones->json('zones') ?? [])->pluck('name')->filter()->values(); - $zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null; + // Und der Rumpf muss die Frage auch BEANTWORTEN. + // + // `successful()` allein genügt nicht: ein Zwischending — ein Portal, ein + // Filter, ein Firmenproxy — antwortet gern mit 200 und einer HTML-Seite. + // Die hat kein `zones`, `?? []` macht daraus null Zonen, und die Anzeige + // behauptet daraufhin, das Hetzner-Konto sei leer. Genau dieser Fall lag + // vor: Token mit Lesen und Schreiben, Zone `clupilot.cloud` mit fünfzehn + // Einträgen vorhanden — und die Konsole sagte, es gebe keine. + // + // Nur ein echtes `{"zones": [...]}` zählt als Antwort. Alles andere + // heißt: hier hat jemand anderes geredet. + $rumpf = $zones->json(); + + if (! is_array($rumpf) || ! isset($rumpf['zones']) || ! is_array($rumpf['zones'])) { + return [ + 'ok' => false, + 'reason' => 'zone_list_unreadable', + 'status' => $zones->status(), + 'body' => Str::limit((string) $zones->body(), 120), + ]; + } + + $verfuegbar = collect($rumpf['zones'])->pluck('name')->filter()->values(); + $zoneId = collect($rumpf['zones'])->firstWhere('name', $zone)['id'] ?? null; if ($zoneId === null) { // Die gefundenen Zonen gehören in die Antwort. diff --git a/lang/de/readiness.php b/lang/de/readiness.php index a8990f4..0c43bca 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -119,4 +119,5 @@ return [ 'zone_available' => 'In diesem Konto liegen: :zones. Entweder CLUPILOT_DNS_ZONE anpassen oder die Zone bei Hetzner anlegen.', 'zone_none' => 'In diesem Konto liegt keine einzige Zone. Der Token gehört vermutlich zu einem anderen Hetzner-Projekt.', 'zone_list_unclear' => 'Hetzner hat die Zonenliste nicht ausgeliefert (HTTP :status). Über die Zonen dieses Kontos sagt das nichts — der Token kann trotzdem richtig sein.', + 'zone_list_unreadable' => 'Die Antwort kam mit HTTP :status, war aber keine Zonenliste. Da hat etwas anderes geantwortet — ein Portal, ein Filter, ein Proxy. Anfang der Antwort: :body', ]; diff --git a/lang/en/readiness.php b/lang/en/readiness.php index b736e13..309e566 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -114,4 +114,5 @@ return [ 'zone_available' => 'This account holds: :zones. Either adjust CLUPILOT_DNS_ZONE or create the zone at Hetzner.', 'zone_none' => 'This account holds no zones at all. The token probably belongs to a different Hetzner project.', 'zone_list_unclear' => 'Hetzner did not return the zone list (HTTP :status). That says nothing about this account\'s zones — the token may still be correct.', + 'zone_list_unreadable' => 'The response came back with HTTP :status but was not a zone list. Something else answered — a portal, a filter, a proxy. Start of the response: :body', ]; diff --git a/resources/views/livewire/admin/readiness.blade.php b/resources/views/livewire/admin/readiness.blade.php index d0f1767..765ac65 100644 --- a/resources/views/livewire/admin/readiness.blade.php +++ b/resources/views/livewire/admin/readiness.blade.php @@ -89,6 +89,12 @@ aus, obwohl der Token gerade eben die Zonenliste geholt hat. Gesucht und gefunden nebeneinander beantwortet die Frage selbst. --}} + @if (($result['reason'] ?? null) === 'zone_list_unreadable') +
+ {{ __('readiness.zone_list_unreadable', ['status' => $result['status'] ?? '?', 'body' => $result['body'] ?? '—']) }} +
+ @endif + @if (($result['reason'] ?? null) === 'zone_list_failed'){{ __('readiness.zone_list_unclear', ['status' => $result['status'] ?? '?']) }} diff --git a/tests/Feature/Readiness/ActiveChecksTest.php b/tests/Feature/Readiness/ActiveChecksTest.php index 0af80df..66565ae 100644 --- a/tests/Feature/Readiness/ActiveChecksTest.php +++ b/tests/Feature/Readiness/ActiveChecksTest.php @@ -464,3 +464,24 @@ it('still calls a genuinely empty account empty', function () { expect($ergebnis['reason'])->toBe('zone_not_found') ->and($ergebnis['available'])->toBe([]); }); + +/** + * Der gemessene Fall vom Live-Server. + * + * Token mit Lesen und Schreiben, Zone `clupilot.cloud` mit fünfzehn Einträgen + * vorhanden — und die Konsole sagte, das Konto sei leer. `successful()` allein + * genügt nicht: ein Portal oder Filter antwortet mit 200 und einer HTML-Seite, + * die hat kein `zones`, und `?? []` machte daraus null Zonen. + */ +it('does not read someone else answering as an empty account', function (string $body, int $status) { + Http::fake(['dns.hetzner.com/api/v1/zones' => Http::response($body, $status, ['Content-Type' => 'text/html'])]); + + $ergebnis = (new App\Services\Dns\DnsTokenCheck)->run('ein-token'); + + expect($ergebnis['reason'])->toBe('zone_list_unreadable') + ->and($ergebnis)->not->toHaveKey('available'); +})->with([ + ['
Blocked by policy', 200], + ['{"error":"something else"}', 200], + ['', 200], +]);