A failed zone list is not an empty Hetzner account

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 <noreply@anthropic.com>
feature/host-bootstrap
nexxo 2026-07-31 15:36:47 +02:00
parent f9620f29a3
commit c81a89ce5a
5 changed files with 62 additions and 0 deletions

View File

@ -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;

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -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')
<p class="mt-1 text-xs text-muted">
{{ __('readiness.zone_list_unclear', ['status' => $result['status'] ?? '?']) }}
</p>
@endif
@if (($result['reason'] ?? null) === 'zone_not_found')
<p class="mt-1 text-xs text-muted">
{{ __('readiness.zone_wanted', ['zone' => $result['zone'] ?? '—']) }}

View File

@ -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('<html>nope</html>', $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([]);
});