From c81a89ce5a622e0c20a0864771884660b99d0dcf Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 15:36:47 +0200 Subject: [PATCH] A failed zone list is not an empty Hetzner account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner asked whether an empty token was being sent. It was not — an empty one returns `missing` — but the question was worth following, and it found a fault in the message I had just added. The check classified 401 and 403 as `rejected` and then read the body. Every OTHER unsuccessful response — 404, 429, 500, a cache's status page — has no `zones` key, `?? []` turned that into no zones, and the console then stated "this account holds no zones at all. The token probably belongs to a different Hetzner project." A claim about somebody's account, derived from an error nobody looked at, delivered with more confidence than the working case gets. `successful()` is asked first now, and an unexpected status is reported as what it is, with the number beside it: it says nothing about the zones, and it says so. That is the distinction this check already draws between `unreachable` and `read_only` — both are failures, only one of them tells you anything about the token. A genuinely empty list still means what it meant: 200 with zero zones is a token for a project without zones. The test uses a dataset rather than a loop. Http::fake() ADDS stubs instead of replacing them, so a loop would have had the first status answer all four iterations and the test would have proved one case three times over. 2050 tests pass, assets build. Co-Authored-By: Claude Opus 5 --- app/Services/Dns/DnsTokenCheck.php | 18 ++++++++++ lang/de/readiness.php | 1 + lang/en/readiness.php | 1 + .../views/livewire/admin/readiness.blade.php | 6 ++++ tests/Feature/Readiness/ActiveChecksTest.php | 36 +++++++++++++++++++ 5 files changed, 62 insertions(+) diff --git a/app/Services/Dns/DnsTokenCheck.php b/app/Services/Dns/DnsTokenCheck.php index 834c084..ed4df3f 100644 --- a/app/Services/Dns/DnsTokenCheck.php +++ b/app/Services/Dns/DnsTokenCheck.php @@ -47,6 +47,24 @@ final class DnsTokenCheck return ['ok' => false, 'reason' => 'rejected']; } + // Erst der Erfolg, DANN der Rumpf. + // + // Ohne diese Zeile wurde jede nicht klassifizierte Fehlerantwort — 404, + // 429, 500, eine Statusseite eines Zwischenspeichers — zu einer leeren + // Zonenliste: der Rumpf hat kein `zones`, `?? []` macht daraus keine + // Zonen, und die Anzeige behauptete daraufhin „in diesem Konto liegt + // keine einzige Zone". Eine Aussage über ein fremdes Konto, hergeleitet + // aus einem Fehler, den niemand angesehen hat. + // + // Das ist derselbe Fehler, den diese Prüfung an anderer Stelle + // vermeidet: `unreachable` und `read_only` sind getrennt, weil beide + // Fehlschläge sind, aber nur einer etwas über den Token sagt. Ein + // unerwarteter Status sagt über die Zonen gar nichts — also sagt er das + // jetzt auch, mit der Zahl daneben. + if (! $zones->successful()) { + 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; diff --git a/lang/de/readiness.php b/lang/de/readiness.php index 80cda00..a8990f4 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -118,4 +118,5 @@ return [ 'zone_wanted' => 'Der Token ist gültig — er hat die Zonenliste geholt. Gesucht wurde die Zone :zone.', '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.', ]; diff --git a/lang/en/readiness.php b/lang/en/readiness.php index 573c428..b736e13 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -113,4 +113,5 @@ return [ 'zone_wanted' => 'The token is valid — it fetched the zone list. It looked for the zone :zone.', '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.', ]; diff --git a/resources/views/livewire/admin/readiness.blade.php b/resources/views/livewire/admin/readiness.blade.php index 3229d59..d0f1767 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_failed') +

+ {{ __('readiness.zone_list_unclear', ['status' => $result['status'] ?? '?']) }} +

+ @endif + @if (($result['reason'] ?? null) === 'zone_not_found')

{{ __('readiness.zone_wanted', ['zone' => $result['zone'] ?? '—']) }} diff --git a/tests/Feature/Readiness/ActiveChecksTest.php b/tests/Feature/Readiness/ActiveChecksTest.php index 7681a7e..0af80df 100644 --- a/tests/Feature/Readiness/ActiveChecksTest.php +++ b/tests/Feature/Readiness/ActiveChecksTest.php @@ -428,3 +428,39 @@ it('names the zone it wanted and the zones it found', function () { ->assertSee(__('readiness.zone_wanted', ['zone' => 'clupilot.cloud'])) ->assertSee('clupilot.com, example.org'); }); + +/** + * Eine Fehlerantwort ist keine leere Zonenliste. + * + * Ohne diese Unterscheidung wurde aus jedem nicht klassifizierten Status — 404, + * 429, 500 — die Aussage „in diesem Konto liegt keine einzige Zone". Eine + * Behauptung über ein fremdes Hetzner-Konto, hergeleitet aus einem Fehler, den + * niemand angesehen hat. Dieselbe Trennung, die diese Prüfung zwischen + * `unreachable` und `read_only` schon zieht: beides sind Fehlschläge, aber nur + * einer sagt etwas über den Token. + */ +it('does not call a failed zone list an empty account', function (int $status) { + // Ein Datensatz statt einer Schleife: `Http::fake()` ERGÄNZT die Attrappen, + // es ersetzt sie nicht — in einer Schleife hätte die erste alle folgenden + // beantwortet, und der Test hätte dreimal dasselbe geprüft. + Http::fake(['dns.hetzner.com/api/v1/zones' => Http::response('nope', $status)]); + + $ergebnis = (new App\Services\Dns\DnsTokenCheck)->run('ein-token'); + + expect($ergebnis['ok'])->toBeFalse() + ->and($ergebnis['reason'])->toBe('zone_list_failed') + ->and($ergebnis['status'])->toBe($status); +})->with([404, 429, 500, 502]); + +/** + * Eine ECHTE leere Liste bleibt dagegen, was sie ist: 200 mit null Zonen heißt, + * dass der Token zu einem Projekt ohne Zonen gehört. + */ +it('still calls a genuinely empty account empty', function () { + Http::fake(['dns.hetzner.com/api/v1/zones' => Http::response(['zones' => []], 200)]); + + $ergebnis = (new App\Services\Dns\DnsTokenCheck)->run('ein-token'); + + expect($ergebnis['reason'])->toBe('zone_not_found') + ->and($ergebnis['available'])->toBe([]); +});