run('10.10.90.185:51820')['ok'])->toBeFalse(); }); it('rejects a documentation address', function () { // RFC 5737 — Seed- und Beispieldaten landen erfahrungsgemäß in echten // Installationen. Http::fake(); expect((new WireguardEndpointCheck)->run('203.0.113.11:51820')['ok'])->toBeFalse(); }); it('rejects loopback', function () { Http::fake(); expect((new WireguardEndpointCheck)->run('127.0.0.1:51820')['ok'])->toBeFalse(); }); it('accepts a public address', function () { Http::fake(); expect((new WireguardEndpointCheck)->run('198.51.45.9:51820')['ok'])->toBeTrue(); }); it('accepts a hostname, which it cannot judge', function () { // Ein Name kann öffentlich auflösen; hier zu raten wäre schlechter als // durchzulassen — die Prüfung sagt, was sie weiß, nicht was sie vermutet. Http::fake(); expect((new WireguardEndpointCheck)->run('vpn.clupilot.cloud:51820')['ok'])->toBeTrue(); }); it('rejects an endpoint without a port', function () { Http::fake(); expect((new WireguardEndpointCheck)->run('198.51.45.9')['ok'])->toBeFalse(); }); it('names the offending address, so the page can show it', function () { Http::fake(); expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['reason'])->toBe('not_public'); }); it('rejects a blank endpoint', function () { Http::fake(); expect((new WireguardEndpointCheck)->run('')['reason'])->toBe('missing'); }); /** * Fix-Runde (Befund 1): FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE * does not reject RFC 6598 Carrier-Grade NAT — verified live against * `100.64.0.5:51820` before this fix, which came back `ok: true`. A host * behind CGNAT is exactly as unreachable from outside as one behind RFC 1918, * it is just an ISP handing the address out instead of an operator. */ it('rejects a carrier-grade NAT address', function () { Http::fake(); expect((new WireguardEndpointCheck)->run('100.64.0.5:51820')['ok'])->toBeFalse(); }); /** * Fix-Runde (Befund 1, vollständiger Durchgang): kein Mehrfach-Empfänger ist * je der eigene Endpunkt eines einzelnen WireGuard-Peers. */ it('rejects a multicast address', function () { Http::fake(); expect((new WireguardEndpointCheck)->run('224.1.1.1:51820')['ok'])->toBeFalse(); }); // --- DnsTokenCheck -------------------------------------------------------- // // SICHERHEITSAUFLAGE: jeder dieser Tests benutzt Http::fake() und darf unter // keinen Umständen die echte Hetzner-API oder die echte Zone berühren. Ein // Test, der clupilot.cloud tatsächlich anfasst, ist ein Fehlschlag der // Aufgabe, auch wenn er grün ist. beforeEach(function () { // Eine feste, erfundene Zone statt des .env-Werts dieser Installation — // ein Test, der den echten Zonennamen des Entwicklerrechners nachrechnet, // beweist nichts (siehe phpunit.xml zu CLUPILOT_DNS_ZONE). Settings::set('provisioning.dns_zone', 'probe.example'); }); it('confirms write access by writing a probe record and removing it again', function () { // Genau der Punkt der ganzen Prüfung: Zone LISTEN allein beweist nichts, // weil ein Leserecht-Token das ebenfalls anstandslos tut. Erst Schreiben // und Löschen beweisen ein Schreibrecht-Token. Http::fake([ 'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]), 'dns.hetzner.com/api/v1/records' => Http::response(['record' => ['id' => 'rec-probe-1']]), 'dns.hetzner.com/api/v1/records/*' => Http::response([], 200), ]); $result = (new DnsTokenCheck)->run('rw-token'); expect($result['ok'])->toBeTrue() ->and($result['reason'])->toBe('writable') ->and($result['probe_removed'])->toBeTrue(); Http::assertSent(fn ($request) => $request->method() === 'POST' && str_contains((string) $request->url(), '/records') && $request->hasHeader('Auth-API-Token', 'rw-token')); Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_contains((string) $request->url(), '/records/rec-probe-1')); }); it('reports the zone as not found when the token cannot see it', function () { // Ein Token für eine andere Zone (oder gar keine) listet erfolgreich, // findet die konfigurierte Zone darin aber nicht. Http::fake([ 'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-9', 'name' => 'someone-elses-zone.example']]]), ]); $result = (new DnsTokenCheck)->run('some-token'); expect($result['ok'])->toBeFalse() ->and($result['reason'])->toBe('zone_not_found') ->and($result['zone'])->toBe('probe.example'); // Kein Schreibversuch, wenn die Zone gar nicht gefunden wurde. Http::assertNotSent(fn ($request) => $request->method() === 'POST'); }); it('tells a read-only token apart from one that can write', function () { // Genau der Leserecht-Token aus dem Handoff: er listet die Zone // anstandslos (200) und sieht in der Konsole aus wie ein funktionierender // — bis zum Schreibversuch, den Hetzner mit 403 quittiert. Http::fake([ 'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]), 'dns.hetzner.com/api/v1/records' => Http::response(['error' => 'forbidden'], 403), ]); $result = (new DnsTokenCheck)->run('read-only-token'); expect($result['ok'])->toBeFalse() ->and($result['reason'])->toBe('read_only') ->and($result['status'])->toBe(403); // Kein Löschversuch für einen Eintrag, der nie geschrieben wurde. Http::assertNotSent(fn ($request) => $request->method() === 'DELETE'); }); /** * Fix-Runde (Befund 2): Das GET auf /zones ist gegen Throwable abgesichert * (Reason "unreachable"), der Schreibversuch danach war es nicht — ein * Netzwerkfehler genau dort hätte die Ausnahme aus run() herausgeworfen, auf * einer Seite, deren einziger Zweck ist, ruhig zu berichten. "Netz kaputt" * bleibt dabei von "Token darf nicht schreiben" (read_only, 403 s.o.) und von * "Zone falsch benannt" getrennt — dieselbe Reason wie beim GET, weil es * dieselbe Fehlerklasse ist, nur an einer anderen Stelle der Kette. */ it('reports unreachable when the write attempt itself cannot reach the network', function () { Http::fake([ 'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]), 'dns.hetzner.com/api/v1/records' => function () { throw new ConnectionException('Could not resolve host'); }, ]); $result = (new DnsTokenCheck)->run('rw-token'); expect($result['ok'])->toBeFalse() ->and($result['reason'])->toBe('unreachable'); }); /** * Fix-Runde (Befund 2, Sonderfall Aufräumen): der Schreibversuch selbst hat * bereits bewiesen, dass der Token schreiben darf — das ist der Zweck der * ganzen Prüfung, und ein kaputtes Netz beim Löschen ändert daran nichts. * `probe_removed` muss trotzdem false sein, und der liegengebliebene Eintrag * muss benennbar bleiben, sonst weiß niemand, dass er in der Zone liegt. */ it('still proves write access when the cleanup delete cannot reach the network', function () { Http::fake([ 'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]), 'dns.hetzner.com/api/v1/records' => Http::response(['record' => ['id' => 'rec-orphaned']]), 'dns.hetzner.com/api/v1/records/*' => function () { throw new ConnectionException('Could not resolve host'); }, ]); $result = (new DnsTokenCheck)->run('rw-token'); expect($result['ok'])->toBeTrue() ->and($result['reason'])->toBe('writable') ->and($result['probe_removed'])->toBeFalse() ->and($result['leftover_record_id'])->toBe('rec-orphaned'); }); it('does not attempt anything without a token or a zone', function () { // Zurückgesetzt statt sich auf den .env-Wert dieser Installation zu // verlassen — genau die Falle, die phpunit.xml zu CLUPILOT_DNS_ZONE // dokumentiert, und der Grund, warum HETZNER_DNS_TOKEN dort jetzt // ebenfalls auf leer gepinnt ist. config()->set('provisioning.dns.token', ''); Http::fake(); expect((new DnsTokenCheck)->run('')['reason'])->toBe('missing'); Http::assertNothingSent(); }); // --- VmTemplateCheck ------------------------------------------------------- beforeEach(function () { // 2026_07_26_040000_create_plan_catalogue_tables.php seeds a real baseline // catalogue (start/team/business/enterprise, all template_vmid 9000) as // part of the migration itself — not a Seeder, so it is present in every // test database and does not roll away with RefreshDatabase. Left alone, // it is unconditionally "required" by VmTemplateCheck alongside whatever // a test creates. Closed here instead of deleted: a published version // cannot be deleted (see PlanVersion::booted()), but its availability // WINDOW is deliberately not frozen — closing it changes nothing about // what the baseline promises, it only takes it out of "currently sold" // so these tests can speak about their own versions in isolation. PlanVersion::query()->whereNotNull('published_at')->update(['available_until' => now()->subDay()]); }); function makePublishedVersion(int $templateVmid, ?string $familyKey = null): void { $family = PlanFamily::query()->create([ 'key' => $familyKey ?? 'active-check-'.Str::random(8), 'name' => 'Active Check Plan', 'tier' => 9, ]); $version = $family->versions()->create([ 'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024, 'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [], 'available_from' => now(), ]); $version->update(['published_at' => now(), 'template_vmid' => $templateVmid]); } it('is satisfied once the template actually exists on an active host', function () { // FakeProxmoxClient talks to nothing real; Http::fake() rides along anyway // — every test of these three checks does, without exception. Http::fake(); $pve = new FakeProxmoxClient; $pve->clonedVmids = [4001]; app()->instance(ProxmoxClient::class, $pve); Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']); makePublishedVersion(4001); $result = app(VmTemplateCheck::class)->run(); expect($result['ok'])->toBeTrue() ->and($result['reason'])->toBe('present'); }); it('names the missing vmid when the template is not on any active host', function () { Http::fake(); $pve = new FakeProxmoxClient; // clonedVmids/runningVmids stay empty: nothing exists anywhere app()->instance(ProxmoxClient::class, $pve); Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']); makePublishedVersion(4002); $result = app(VmTemplateCheck::class)->run(); expect($result['ok'])->toBeFalse() ->and($result['reason'])->toBe('template_missing') ->and($result['vmids'])->toContain(4002); }); /** * Fix-Runde (Befund 3): ein Host, der gerade nicht antwortet, ist kein * Beweis dafür, dass die Vorlage fehlt — sie könnte genau auf diesem Host * liegen. Der bestehende FakeProxmoxClient kann das nicht abbilden (jede * Antwort ist entweder true oder false, nie eine Ausnahme), deshalb eine * anonyme Unterklasse nach dem Muster aus TrafficTest.php * ($flaky/$blind), die vmExists() gezielt scheitern lässt. */ it('does not confuse a silent host with a confirmed-missing template', function () { Http::fake(); $silent = new class extends FakeProxmoxClient { public function vmExists(string $node, int $vmid): bool { throw new RuntimeException('proxmox unreachable'); } }; app()->instance(ProxmoxClient::class, $silent); Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']); makePublishedVersion(4004); $result = app(VmTemplateCheck::class)->run(); // "build the template" and "your hosts are not answering" are two // different instructions — a reason of 'template_missing' here would // send an operator to build a template that might already exist. expect($result['ok'])->toBeFalse() ->and($result['reason'])->toBe('hosts_unreachable') ->and($result['reason'])->not->toBe('template_missing') ->and($result['vmids'])->toContain(4004); }); /** * Fix-Runde (Befund 3, Gegenprobe): ein Host, der antwortet und die Vorlage * bestätigt, darf das Ergebnis nicht kippen lassen, nur weil ein ANDERER * Host in derselben Runde schweigt — "gefunden" sticht "still", und die * Schleife bricht bei einem Treffer ab, statt den stillen Host danach noch * als Beweis für irgendetwas zu werten. * * Eine einzige ProxmoxClient-Instanz bedient in VmTemplateCheck ALLE Hosts * (`forHost($host)` wird pro Host auf demselben `$this->pve` aufgerufen), also * unterscheidet dieser Fake anhand dessen, welcher Host zuletzt über * `forHost()` hereinkam — genau das Feld, das `FakeProxmoxClient::forHost()` * ohnehin schon merkt. */ it('still confirms a template that a working host has, despite a silent one', function () { Http::fake(); $mixed = new class extends FakeProxmoxClient { public function vmExists(string $node, int $vmid): bool { if ($this->host?->node === 'silent-node') { throw new RuntimeException('proxmox unreachable'); } return parent::vmExists($node, $vmid); } }; $mixed->clonedVmids = [4005]; // what the WORKING host actually has app()->instance(ProxmoxClient::class, $mixed); Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'silent-node']); Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-2', 'node' => 'working-node']); makePublishedVersion(4005); $result = app(VmTemplateCheck::class)->run(); expect($result['ok'])->toBeTrue() ->and($result['reason'])->toBe('present'); }); it('reports no usable host when nothing is left to ask', function () { Http::fake(); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); // Kein aktiver Host mit lesbarem Token — derselbe Zustand, den // ProvisioningChecks::usable_host als Sperre meldet. Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => 'ref-1', 'node' => 'pve']); makePublishedVersion(4003); $result = app(VmTemplateCheck::class)->run(); expect($result['ok'])->toBeFalse() ->and($result['reason'])->toBe('no_usable_host'); }); it('is satisfied when no plan version is published yet', function () { Http::fake(); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); $result = app(VmTemplateCheck::class)->run(); expect($result['ok'])->toBeTrue() ->and($result['reason'])->toBe('nothing_published'); });