diff --git a/app/Services/Dns/DnsTokenCheck.php b/app/Services/Dns/DnsTokenCheck.php index 7d34bc6..834c084 100644 --- a/app/Services/Dns/DnsTokenCheck.php +++ b/app/Services/Dns/DnsTokenCheck.php @@ -47,10 +47,26 @@ final class DnsTokenCheck return ['ok' => false, 'reason' => 'rejected']; } + $verfuegbar = collect($zones->json('zones') ?? [])->pluck('name')->filter()->values(); $zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null; if ($zoneId === null) { - return ['ok' => false, 'reason' => 'zone_not_found', 'zone' => $zone]; + // Die gefundenen Zonen gehören in die Antwort. + // + // `zone_not_found` allein ist eine Sackgasse: der Betreiber liest + // es als „der Token ist falsch" und tauscht ihn aus, obwohl der + // Token gerade eben erfolgreich die Zonenliste geholt hat — ein + // schlechter Token käme oben als `rejected` heraus. Was fehlt, ist + // die ZONE. Nebeneinander gestellt beantwortet sich die Frage von + // selbst: gesucht wurde `clupilot.cloud`, im Konto liegt + // `clupilot.com` — oder gar nichts, dann gehört die Zone dort + // erst angelegt. + return [ + 'ok' => false, + 'reason' => 'zone_not_found', + 'zone' => $zone, + 'available' => $verfuegbar->all(), + ]; } // The actual point of this whole check. A read-only token gets this diff --git a/lang/de/readiness.php b/lang/de/readiness.php index 07527ae..80cda00 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -114,4 +114,8 @@ return [ 'queue_provisioning' => 'Bereitstellungs-Worker läuft', 'queue_provisioning_breaks' => 'Der Zeitplaner kann laufen und Aufträge auf die provisioning-Warteschlange stellen, ohne dass sie je jemand abholt. Eine bezahlte Bestellung bleibt dann liegen — kein Fehler, keine Meldung, einfach nichts, bis jemand von Hand nachsieht.', ], + + '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.', ]; diff --git a/lang/en/readiness.php b/lang/en/readiness.php index f909d11..573c428 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -109,4 +109,8 @@ return [ 'queue_provisioning' => 'Provisioning worker is running', 'queue_provisioning_breaks' => 'The scheduler can be running and putting jobs on the provisioning queue without anyone ever picking them up. A paid order then just sits there — no error, no notice, simply nothing, until someone happens to look.', ], + + '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.', ]; diff --git a/resources/views/livewire/admin/readiness.blade.php b/resources/views/livewire/admin/readiness.blade.php index 07382c1..3229d59 100644 --- a/resources/views/livewire/admin/readiness.blade.php +++ b/resources/views/livewire/admin/readiness.blade.php @@ -25,13 +25,30 @@
{{ $check->breaks }}
@endunless @@ -67,6 +84,22 @@ im Testplatz. Die Prüfung selbst blockiert dafür jetzt (BillingChecks); hier steht, was der Knopf gerade gesehen hat, damit er ihr nicht widerspricht. --}} + {{-- Bei `zone_not_found` steht der Betreiber sonst vor einer + Sackgasse: er liest es als „Token falsch" und tauscht ihn + aus, obwohl der Token gerade eben die Zonenliste geholt hat. + Gesucht und gefunden nebeneinander beantwortet die Frage + selbst. --}} + @if (($result['reason'] ?? null) === 'zone_not_found') ++ {{ __('readiness.zone_wanted', ['zone' => $result['zone'] ?? '—']) }} + @if (! empty($result['available'])) + {{ __('readiness.zone_available', ['zones' => implode(', ', $result['available'])]) }} + @else + {{ __('readiness.zone_none') }} + @endif +
+ @endif + @if (array_key_exists('live', $result)) @php $keyMode = $result['live'] ? 'live' : 'test'; @endphpdiff --git a/tests/Feature/Readiness/ActiveChecksTest.php b/tests/Feature/Readiness/ActiveChecksTest.php index 7158952..7681a7e 100644 --- a/tests/Feature/Readiness/ActiveChecksTest.php +++ b/tests/Feature/Readiness/ActiveChecksTest.php @@ -386,3 +386,45 @@ it('is satisfied when no plan version is published yet', function () { expect($result['ok'])->toBeTrue() ->and($result['reason'])->toBe('nothing_published'); }); + +/** + * Der Fund vom Live-Server: grünes „Erfüllt" über rotem „nicht in Ordnung", + * in derselben Zeile — beim DNS-Token und bei der VM-Vorlage. + * + * Die Plakette kam allein aus der passiven Prüfung („etwas ist eingetragen"), + * die Messung stand daneben und wurde überstimmt. Wer eine Liste überfliegt, + * liest die Plakette und nicht die Kleinschrift, und geht mit „alles grün" + * weiter, obwohl gemessen wurde, dass es nicht geht. Das ist die Attrappe aus + * R19 in ihrer teuersten Form. + */ +it('lets a failed measurement beat a green badge', function () { + $operator = App\Models\Operator::factory()->role('Owner')->create(); + + $seite = Livewire::actingAs($operator, 'operator')->test(App\Livewire\Admin\Readiness::class); + + // Ohne Messung entscheidet die passive Prüfung — wie bisher. + $seite->assertOk(); + + // Mit einer gescheiterten Messung darf in dieser Zeile kein „Erfüllt" + // mehr stehen. + $seite->set('results', ['provisioning.dns_token' => ['ok' => false, 'reason' => 'zone_not_found', 'zone' => 'clupilot.cloud', 'available' => ['clupilot.com']]]) + ->assertSee('clupilot.cloud') + ->assertSee('clupilot.com') + ->assertSee(__('readiness.check_failed')); +}); + +/** + * `zone_not_found` allein ist eine Sackgasse: es liest sich wie „Token + * falsch", obwohl der Token gerade eben die Zonenliste geholt hat — ein + * schlechter käme als `rejected` heraus. Was fehlt, ist die ZONE, und das + * sagt erst der Vergleich. + */ +it('names the zone it wanted and the zones it found', function () { + $ergebnis = ['ok' => false, 'reason' => 'zone_not_found', 'zone' => 'clupilot.cloud', 'available' => ['clupilot.com', 'example.org']]; + + Livewire::actingAs(App\Models\Operator::factory()->role('Owner')->create(), 'operator') + ->test(App\Livewire\Admin\Readiness::class) + ->set('results', ['provisioning.dns_token' => $ergebnis]) + ->assertSee(__('readiness.zone_wanted', ['zone' => 'clupilot.cloud'])) + ->assertSee('clupilot.com, example.org'); +}); diff --git a/tests/Feature/TrafficTest.php b/tests/Feature/TrafficTest.php index 11f3adb..615f31e 100644 --- a/tests/Feature/TrafficTest.php +++ b/tests/Feature/TrafficTest.php @@ -193,13 +193,26 @@ it('shows allowances in the units they are sold in', function () { ->and(App\Support\Bytes::human(512))->toBe('512 B'); }); +/* + * `startOfMonth()` VOR `subMonth()`, und das ist keine Kosmetik. + * + * Carbon rechnet `subMonth()` kalendarisch: am 31. Juli ergibt das den 1. Juli, + * weil der 31. Juni überläuft. `format('Y-m')` liefert dann DIESEN Monat statt + * des Vormonats — die Zeile, die „letzte Periode" heißen soll, landet in der + * aktuellen, und der Test scheitert. An drei Tagen im Monat, nachts entdeckt. + * + * Der Produktivcode hat die Falle nicht: `InstanceTraffic::currentPeriod()` ist + * `now()->format('Y-m')` ohne Arithmetik, und wo gerechnet wird (Overview, + * ConfirmCancelPackage) steht `startOfMonth()` bzw. eine Notiz dazu. Es waren + * nur diese Tests. + */ it('lets a throttled instance run again when the new month starts', function () { [$pve, , $instance] = trafficSetup(); // Last month ended with the allowance exhausted and the NIC limited. InstanceTraffic::create([ 'instance_id' => $instance->id, - 'period' => now()->subMonth()->format('Y-m'), + 'period' => now()->startOfMonth()->subMonth()->format('Y-m'), 'tx_bytes' => 9_999_999_999_999, 'throttled' => true, 'throttled_at' => now()->subDays(3), @@ -220,7 +233,7 @@ it('keeps trying to release a throttle when Proxmox refuses once', function () { InstanceTraffic::create([ 'instance_id' => $instance->id, - 'period' => now()->subMonth()->format('Y-m'), + 'period' => now()->startOfMonth()->subMonth()->format('Y-m'), 'tx_bytes' => 9_999_999_999_999, 'throttled' => true, ]);