389 lines
16 KiB
PHP
389 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Models\Host;
|
|
use App\Models\PlanFamily;
|
|
use App\Models\PlanVersion;
|
|
use App\Services\Dns\DnsTokenCheck;
|
|
use App\Services\Proxmox\FakeProxmoxClient;
|
|
use App\Services\Proxmox\ProxmoxClient;
|
|
use App\Services\Proxmox\VmTemplateCheck;
|
|
use App\Services\Vpn\WireguardEndpointCheck;
|
|
use App\Support\Settings;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Ein UDP-Port lässt sich von außen nicht sauber anklopfen — es gibt keinen
|
|
* Handshake zu beobachten. Was ENTSCHEIDBAR ist: ob dort überhaupt eine
|
|
* Adresse steht, die ein Host im Internet erreichen kann.
|
|
*
|
|
* Am 2026-07-30 stand `10.10.90.185:51820` in der Konfiguration. Das sieht
|
|
* gesetzt aus und ist für jeden Host außerhalb dieses Netzes unerreichbar.
|
|
*
|
|
* Jeder Test ruft trotzdem Http::fake() — ohne Ausnahme, wie jeder Test dieser
|
|
* drei Prüfungen. WireguardEndpointCheck macht heute keinen Netzverkehr, aber
|
|
* die Zusicherung gilt der Prüfung, nicht der heutigen Implementierung.
|
|
*/
|
|
it('rejects a private address', function () {
|
|
Http::fake();
|
|
expect((new WireguardEndpointCheck)->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');
|
|
});
|