Only a real zones array counts as an answer
Measured on the live server: a token with read AND write, the zone
clupilot.cloud present with fifteen records — and the console insisting the
account held no zones at all. Both screenshots contradict the message, so the
message was wrong, and the previous fix did not go far enough.
successful() is not enough. Something in the middle — a portal, a filter, a
proxy — answers with 200 and an HTML page. That body has no `zones` key, `??
[]` turned it into no zones, and the display concluded the Hetzner account was
empty. A 200 is not a promise about who answered.
So the body has to answer the question, not merely arrive: `{"zones": [...]}`
with an actual array, or it is reported as something else having spoken, with
the status and the first 120 characters of what came back. That last part is
what turns it from a verdict into a diagnosis — an operator who sees "Blocked by
policy" knows in one line what nothing else here could have told them.
A genuine `{"zones": []}` still means what it always meant.
2053 tests pass, assets build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
parent
64045e0a04
commit
d321719180
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ __('readiness.zone_list_unreadable', ['status' => $result['status'] ?? '?', 'body' => $result['body'] ?? '—']) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (($result['reason'] ?? null) === 'zone_list_failed')
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ __('readiness.zone_list_unclear', ['status' => $result['status'] ?? '?']) }}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
['<html><body>Blocked by policy</body></html>', 200],
|
||||
['{"error":"something else"}', 200],
|
||||
['', 200],
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue