Tell a read-only token apart from one that can write
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus
parent
0f4226c488
commit
2eff3a5a3c
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Dns;
|
||||
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Whether the configured Hetzner DNS token can actually WRITE to the zone,
|
||||
* not merely read it.
|
||||
*
|
||||
* Listing the zone proves nothing: a read-only token lists it just as
|
||||
* cleanly as a read-write one, and looks identical in the console — it only
|
||||
* fails once a customer VM needs its A record, after the customer has
|
||||
* already paid. So this writes a throwaway TXT record and deletes it again
|
||||
* immediately; the write (and its rejection) is the only thing that actually
|
||||
* tells the two kinds of token apart.
|
||||
*
|
||||
* Deliberately does its own HTTP instead of going through HttpHetznerDnsClient:
|
||||
* that client always reads the STORED token from the vault, and this check
|
||||
* has to be able to test a candidate value before it is ever saved — exactly
|
||||
* the same reason StripeCheck does not go through HttpStripeClient.
|
||||
*/
|
||||
final class DnsTokenCheck
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function run(?string $candidate = null): array
|
||||
{
|
||||
$token = $candidate ?: app(SecretVault::class)->get('dns.token');
|
||||
$zone = ProvisioningSettings::dnsZone();
|
||||
|
||||
if (blank($token) || blank($zone)) {
|
||||
return ['ok' => false, 'reason' => 'missing'];
|
||||
}
|
||||
|
||||
try {
|
||||
$zones = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
|
||||
->get('https://dns.hetzner.com/api/v1/zones');
|
||||
} catch (Throwable) {
|
||||
return ['ok' => false, 'reason' => 'unreachable'];
|
||||
}
|
||||
|
||||
if ($zones->status() === 401 || $zones->status() === 403) {
|
||||
return ['ok' => false, 'reason' => 'rejected'];
|
||||
}
|
||||
|
||||
$zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null;
|
||||
|
||||
if ($zoneId === null) {
|
||||
return ['ok' => false, 'reason' => 'zone_not_found', 'zone' => $zone];
|
||||
}
|
||||
|
||||
// The actual point of this whole check. A read-only token gets this
|
||||
// far and looks in the console like a working one; it fails only when
|
||||
// a customer VM needs its A record — after payment.
|
||||
$probe = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
|
||||
->post('https://dns.hetzner.com/api/v1/records', [
|
||||
'zone_id' => $zoneId,
|
||||
'type' => 'TXT',
|
||||
'name' => '_clupilot-write-probe-'.Str::lower(Str::random(12)),
|
||||
'value' => 'clupilot readiness probe',
|
||||
'ttl' => 60,
|
||||
]);
|
||||
|
||||
if (! $probe->successful()) {
|
||||
return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()];
|
||||
}
|
||||
|
||||
// Always clean up, even when the delete itself fails: a leftover TXT
|
||||
// record with this name is harmless, but has to be named in the
|
||||
// result so someone can remove it by hand.
|
||||
$recordId = $probe->json('record.id');
|
||||
$removed = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
|
||||
->delete('https://dns.hetzner.com/api/v1/records/'.$recordId)
|
||||
->successful();
|
||||
|
||||
return ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Proxmox;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\PlanVersion;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Whether every published plan's VM template actually EXISTS on a node, not
|
||||
* merely that a `template_vmid` is filled in somewhere.
|
||||
*
|
||||
* Asks the same question as the onboarding step VerifyVmTemplate, but over
|
||||
* ALL active hosts instead of one, and BEFORE a purchase instead of after:
|
||||
* ProvisioningChecks::vm_template (Task 8) is a field check only — it cannot
|
||||
* tell a `template_vmid` that was typed by mistake from one that is really
|
||||
* there, and asking Proxmox on every page load does not belong on a
|
||||
* readiness page. This is the on-demand button that actually asks.
|
||||
*/
|
||||
final class VmTemplateCheck
|
||||
{
|
||||
public function __construct(private ProxmoxClient $pve) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function run(): array
|
||||
{
|
||||
$required = PlanVersion::query()
|
||||
->whereNotNull('published_at')
|
||||
->whereNotNull('template_vmid')
|
||||
->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now()))
|
||||
->pluck('template_vmid')->map(fn ($v) => (int) $v)->unique()->values()->all();
|
||||
|
||||
// A catalogue with no published version requires no template. The
|
||||
// same case VerifyVmTemplate explicitly lets through.
|
||||
if ($required === []) {
|
||||
return ['ok' => true, 'reason' => 'nothing_published'];
|
||||
}
|
||||
|
||||
$hosts = Host::query()->where('status', 'active')->whereNotNull('api_token_ref')->get();
|
||||
|
||||
if ($hosts->isEmpty()) {
|
||||
return ['ok' => false, 'reason' => 'no_usable_host'];
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($required as $vmid) {
|
||||
$found = false;
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
try {
|
||||
if ($this->pve->forHost($host)->vmExists($host->node ?? 'pve', $vmid)) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// A host that is not answering right now is not proof of
|
||||
// a missing template. Keep looking at the others.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $found) {
|
||||
$missing[] = $vmid;
|
||||
}
|
||||
}
|
||||
|
||||
return $missing === []
|
||||
? ['ok' => true, 'reason' => 'present']
|
||||
: ['ok' => false, 'reason' => 'template_missing', 'vmids' => $missing];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Vpn;
|
||||
|
||||
use App\Support\ProvisioningSettings;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Whether the WireGuard hub endpoint an onboarding host is told to dial is
|
||||
* even CAPABLE of being reached from outside this network.
|
||||
*
|
||||
* Deliberately makes no network call. A UDP port cannot be knocked on
|
||||
* cleanly from the outside — there is no handshake to observe without a real
|
||||
* peer, and this class runs on every readiness page load, not on demand. What
|
||||
* IS decidable without touching the wire: whether the address configured is
|
||||
* one a host on the internet could even attempt to reach. `10.10.90.185` and
|
||||
* `203.0.113.11` (RFC 5737) both look "set" and are unreachable from any host
|
||||
* outside this operator's own network or a documentation example generator —
|
||||
* exactly the two shapes seen in this installation's configuration on
|
||||
* 2026-07-30.
|
||||
*/
|
||||
final class WireguardEndpointCheck
|
||||
{
|
||||
/**
|
||||
* RFC 5737 "TEST-NET" ranges: documentation and example addresses, never
|
||||
* routed on the real internet. PHP's own FILTER_FLAG_NO_RES_RANGE does
|
||||
* NOT reject these (verified: 203.0.113.11 passes filter_var with that
|
||||
* flag) — exactly the range a seed value or a copy-pasted example lands
|
||||
* in, which is precisely the failure mode this check exists to catch.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const DOCUMENTATION_RANGES = [
|
||||
'192.0.2.0/24', // TEST-NET-1
|
||||
'198.51.100.0/24', // TEST-NET-2
|
||||
'203.0.113.0/24', // TEST-NET-3
|
||||
];
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function run(?string $endpoint = null): array
|
||||
{
|
||||
$endpoint ??= ProvisioningSettings::wgEndpoint();
|
||||
|
||||
if (blank($endpoint)) {
|
||||
return ['ok' => false, 'reason' => 'missing'];
|
||||
}
|
||||
|
||||
if (! str_contains($endpoint, ':')) {
|
||||
return ['ok' => false, 'reason' => 'no_port'];
|
||||
}
|
||||
|
||||
$host = Str::beforeLast($endpoint, ':');
|
||||
|
||||
// No valid IP means: a name. It can resolve publicly, and guessing
|
||||
// here would be worse than letting it through — the check states what
|
||||
// it knows, not what it suspects.
|
||||
if (! filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
return ['ok' => true, 'reason' => 'hostname', 'address' => $host];
|
||||
}
|
||||
|
||||
// NO_PRIV_RANGE covers RFC 1918, NO_RES_RANGE covers loopback and
|
||||
// link-local among others — exactly the private address that stood
|
||||
// at one host on 2026-07-30.
|
||||
$public = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
||||
|
||||
if ($public === false || $this->isDocumentationAddress($host)) {
|
||||
return ['ok' => false, 'reason' => 'not_public', 'address' => $host];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'reason' => 'public', 'address' => $host];
|
||||
}
|
||||
|
||||
private function isDocumentationAddress(string $ip): bool
|
||||
{
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$long = ip2long($ip);
|
||||
|
||||
foreach (self::DOCUMENTATION_RANGES as $cidr) {
|
||||
[$subnet, $bits] = explode('/', $cidr);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
if (($long & $mask) === (ip2long($subnet) & $mask)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,15 @@
|
|||
though no current test happens to need it. -->
|
||||
<env name="STRIPE_WEBHOOK_SECRET" value="" force="true"/>
|
||||
<env name="STRIPE_WEBHOOK_SECRET_TEST" value="" force="true"/>
|
||||
<!-- Blank so the suite never reads this operator's REAL Hetzner DNS token
|
||||
from .env. This one is load-bearing in a way the two above are not:
|
||||
DnsTokenCheck proves write access by actually WRITING a TXT record
|
||||
to the configured zone and deleting it again — a suite that fell
|
||||
through to the real token would write to and delete from the real
|
||||
clupilot.cloud zone on every test run. Every test that exercises
|
||||
DnsTokenCheck uses Http::fake() and supplies its own candidate
|
||||
token instead. -->
|
||||
<env name="HETZNER_DNS_TOKEN" value="" force="true"/>
|
||||
<!-- Pinned so the suite stops depending on the operator's .env. It did:
|
||||
HostStepsTest asserted `fsn-01.node.clupilot.com` and passed only
|
||||
because this server's .env carried the wrong zone. Correcting the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
<?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\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');
|
||||
});
|
||||
|
||||
// --- 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');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
Loading…
Reference in New Issue