instance(WireguardHub::class, $hub); return $hub; } it('keeps the page and the nav entry away from operators without the capability', function () { vpnHub(); // Support may run the console, but a VPN access reaches the management network. $this->actingAs(operator('Support'))->get(route('admin.vpn'))->assertForbidden(); $this->actingAs(operator('Support'))->get(route('admin.overview')) ->assertOk() ->assertDontSee(route('admin.vpn')); $this->actingAs(operator('Admin'))->get(route('admin.vpn'))->assertOk(); $this->actingAs(operator('Owner'))->get(route('admin.overview')) ->assertOk() ->assertSee(route('admin.vpn')); }); it('creates an access, hands out the config once and pushes it to the hub', function () { vpnHub(); Queue::fake(); $component = Livewire::actingAs(operator('Owner')) ->test(Vpn::class) ->set('name', 'Notebook Boban') ->call('create') ->assertHasNoErrors(); $peer = VpnPeer::query()->firstOrFail(); expect($peer->name)->toBe('Notebook Boban') ->and($peer->enabled)->toBeTrue() ->and($peer->present)->toBeFalse(); // not on the hub until the job ran $config = $component->get('newConfig'); expect($config)->toContain('[Interface]') ->toContain('Address = '.$peer->allowed_ip.'/32') ->toContain('PublicKey = HUBPUBLICKEY0000000000000000000000000000000=') ->toContain('Endpoint = vpn.clupilot.test:51820'); // The private key is shown, never stored. preg_match('/PrivateKey = (\S+)/', $config, $m); expect(Keypair::isValidKey($m[1]))->toBeTrue() ->and(VpnPeer::query()->where('public_key', $m[1])->exists())->toBeFalse(); Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && $job->enabled); }); it('accepts a peer-supplied public key and then has no private key to show', function () { vpnHub(); Queue::fake(); $own = Keypair::generate()->publicKey; $component = Livewire::actingAs(operator('Owner')) ->test(Vpn::class) ->set('name', 'Proxmox fsn1') ->set('publicKey', $own) ->call('create') ->assertHasNoErrors(); expect(VpnPeer::query()->where('public_key', $own)->exists())->toBeTrue() ->and($component->get('newConfig'))->toBeNull(); }); it('rejects a malformed key and a key that already has an access', function () { vpnHub(); $existing = VpnPeer::factory()->create(); Livewire::actingAs(operator('Owner'))->test(Vpn::class) ->set('name', 'kaputt')->set('publicKey', 'nope') ->call('create') ->assertHasErrors('publicKey'); Livewire::actingAs(operator('Owner'))->test(Vpn::class) ->set('name', 'doppelt')->set('publicKey', $existing->public_key) ->call('create') ->assertHasErrors('publicKey'); expect(VpnPeer::query()->count())->toBe(1); }); it('blocks and unblocks an access without losing the record', function () { vpnHub(); Queue::fake(); $peer = VpnPeer::factory()->create(); Livewire::actingAs(operator('Owner'))->test(Vpn::class)->call('toggle', $peer->uuid); expect($peer->fresh()->enabled)->toBeFalse(); Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled); Livewire::actingAs(operator('Owner'))->test(Vpn::class)->call('toggle', $peer->uuid); expect($peer->fresh()->enabled)->toBeTrue() ->and(VpnPeer::query()->count())->toBe(1); }); it('removes the peer from the hub before deleting the row', function () { vpnHub(); Queue::fake(); $peer = VpnPeer::factory()->create(); Livewire::actingAs(operator('Owner')) ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) ->call('delete'); expect(VpnPeer::query()->count())->toBe(0); Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled); }); it('warns that deleting a host access cuts CluPilot off from that host', function () { vpnHub(); $host = Host::factory()->active()->create(['name' => 'pve-fsn1']); $peer = VpnPeer::factory()->create(['host_id' => $host->id]); Livewire::actingAs(operator('Owner')) ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) ->assertSee('pve-fsn1'); }); it('stops mutations from a session whose capability was revoked mid-flight', function () { vpnHub(); Queue::fake(); $peer = VpnPeer::factory()->create(); $user = operator('Owner'); // Page is open and legitimate… $component = Livewire::actingAs($user)->test(Vpn::class); // …then the account is demoted. The already-rendered page must not keep its // powers: every action re-checks, it does not trust the mount. $user->syncRoles(['Support']); app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); $component->call('toggle', $peer->uuid)->assertForbidden(); expect($peer->fresh()->enabled)->toBeTrue(); Queue::assertNotPushed(ApplyVpnPeer::class); }); it('adopts host peers from the hub and marks vanished ones absent', function () { $hub = vpnHub(); $key = Keypair::generate()->publicKey; $host = Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]); $hub->addPeer($key, '10.66.0.7'); $hub->snapshots[$key] = new PeerSnapshot( publicKey: $key, endpoint: '203.0.113.9:51820', allowedIps: '10.66.0.7/32', latestHandshake: now()->subSeconds(30)->toImmutable(), rxBytes: 2048, txBytes: 4096, ); // A peer that exists in the console but is no longer on the interface. $stale = VpnPeer::factory()->create(['present' => true]); (new SyncVpnPeers)->handle($hub); $adopted = VpnPeer::query()->where('public_key', $key)->firstOrFail(); expect($adopted->name)->toBe('pve-fsn1') // named after its host, not "unknown" ->and($adopted->host_id)->toBe($host->id) ->and($adopted->present)->toBeTrue() ->and($adopted->rx_bytes)->toBe(2048) ->and($adopted->endpoint)->toBe('203.0.113.9:51820') ->and($adopted->status())->toBe('online') ->and($stale->fresh()->present)->toBeFalse(); }); it('never lets a sync overwrite the operator intent to block', function () { $hub = vpnHub(); $peer = VpnPeer::factory()->blocked()->create(); // The hub still reports it (removal has not been applied yet). $hub->addPeer($peer->public_key, $peer->allowed_ip); (new SyncVpnPeers)->handle($hub); $peer->refresh(); expect($peer->enabled)->toBeFalse() // intent survives ->and($peer->present)->toBeTrue() // reality is recorded ->and($peer->status())->toBe('pending'); }); it('does not hand the same tunnel address to a host and a VPN access', function () { $host = Host::factory()->active()->create(['wg_ip' => '10.66.0.2']); VpnPeer::factory()->create(['allowed_ip' => '10.66.0.3']); // The real allocator, not the fake — this is the collision the fake cannot show. expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.4'); }); it('does not let a failed removal resurrect a revoked access', function () { $hub = vpnHub(); Queue::fake(); $peer = VpnPeer::factory()->create(); $hub->addPeer($peer->public_key, $peer->allowed_ip); Livewire::actingAs(operator('Owner')) ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) ->call('delete'); // Removal is queued but has not run — the peer is still on the hub. expect(VpnPeer::query()->count())->toBe(0) ->and(VpnPeer::withTrashed()->count())->toBe(1); (new SyncVpnPeers)->handle($hub); // The sync must NOT adopt it back as a live access… expect(VpnPeer::query()->count())->toBe(0); // …it must retry the removal instead. Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled); }); it('clears the tombstone once the peer is really gone', function () { $hub = vpnHub(); $peer = VpnPeer::factory()->create(); $hub->addPeer($peer->public_key, $peer->allowed_ip); $peer->delete(); (new ApplyVpnPeer($peer->public_key, null, false))->handle($hub); expect($hub->peerIps())->toBe([]) ->and(VpnPeer::withTrashed()->count())->toBe(0); }); it('ignores a stale job that would undo a newer decision', function () { $hub = vpnHub(); $peer = VpnPeer::factory()->create(); $hub->addPeer($peer->public_key, $peer->allowed_ip); // Operator blocks, then immediately unblocks: two jobs are now in flight. $blockJob = new ApplyVpnPeer($peer->public_key, $peer->allowed_ip, false); $peer->update(['enabled' => false]); $peer->update(['enabled' => true]); // newer intent wins // The older block job runs late (a retry, say). It must not cut access. $blockJob->handle($hub); expect($hub->peerIps())->toHaveKey($peer->public_key) ->and($peer->fresh()->present)->toBeTrue(); }); it('keeps a revoked address and key reserved until the hub confirms', function () { vpnHub(); $peer = VpnPeer::factory()->create(['allowed_ip' => '10.66.0.2']); $peer->delete(); // The address is still configured on the hub — handing it out again would // give two machines the same tunnel IP. expect((new LocalWireguardHub)->allocateIp())->not->toBe('10.66.0.2'); Livewire::actingAs(operator('Owner'))->test(Vpn::class) ->set('name', 'wieder da')->set('publicKey', $peer->public_key) ->call('create') ->assertHasErrors('publicKey'); }); it('stops an open page from polling once the capability is revoked', function () { vpnHub(); $user = operator('Owner'); $component = Livewire::actingAs($user)->test(Vpn::class); $user->syncRoles(['Support']); app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); // The five-second poll must not keep serving peer state to a demoted account. $component->call('refreshPeers')->assertForbidden(); }); it('holds the same allocation lock the host pipeline uses', function () { vpnHub(); // Onboarding (ConfigureWireguard) holds this exact lock while it reserves a // host address. Addresses come from one subnet but live in two tables, so // no unique index can catch the other side's insert — waiting is the only // thing that keeps a host and an access off the same tunnel IP. // Short TTL so the test measures the wait instead of sitting out a timeout. expect(Illuminate\Support\Facades\Cache::lock('wireguard:allocate', 1)->get())->toBeTrue(); $started = microtime(true); Livewire::actingAs(operator('Owner'))->test(Vpn::class) ->set('name', 'parallel') ->call('create') ->assertHasNoErrors(); $elapsed = microtime(true) - $started; expect(VpnPeer::query()->count())->toBe(1) ->and($elapsed)->toBeGreaterThan(0.8); // it waited for the lock to expire }); it('never re-adds a peer whose row is gone, however stale the job', function () { $hub = vpnHub(); $peer = VpnPeer::factory()->create(); // The add job is retried late — after the access was revoked and its // tombstone already purged by a sync that saw the peer gone. $staleAdd = ApplyVpnPeer::for($peer); $peer->forceDelete(); $staleAdd->handle($hub); expect($hub->peerIps())->toBe([]) ->and(VpnPeer::withTrashed()->count())->toBe(0); }); it('serializes hub reads against hub mutations', function () { $hub = vpnHub(); $peer = VpnPeer::factory()->create(); $hub->addPeer($peer->public_key, $peer->allowed_ip); // A removal in flight holds this lock. A sync must wait for it instead of // acting on a snapshot taken around the mutation. expect(Illuminate\Support\Facades\Cache::lock('wireguard:hub', 1)->get())->toBeTrue(); $started = microtime(true); (new SyncVpnPeers)->handle($hub); expect(microtime(true) - $started)->toBeGreaterThan(0.8) ->and($peer->fresh()->present)->toBeTrue(); }); it('reports a lost creation race as a validation error, not a 500', function () { // A hub whose allocation inserts a colliding row mid-flight: exactly what a // concurrent request that skipped the lock would do between our check and // our insert. $key = Keypair::generate()->publicKey; $hub = new class($key) extends FakeWireguardHub { public function __construct(private string $collidingKey) {} public function allocateIp(): string { VpnPeer::create([ 'name' => 'gleichzeitig', 'public_key' => $this->collidingKey, 'allowed_ip' => '10.66.0.90', 'enabled' => true, ]); return '10.66.0.91'; } }; app()->instance(WireguardHub::class, $hub); Livewire::actingAs(operator('Owner'))->test(Vpn::class) ->set('name', 'verloren') ->set('publicKey', $key) ->call('create') ->assertHasErrors('publicKey'); // Only the winner's row exists. expect(VpnPeer::query()->count())->toBe(1) ->and(VpnPeer::query()->first()->name)->toBe('gleichzeitig'); }); it('keeps host peer removal behind the same hub lock', function () { $hub = vpnHub(); $hub->addPeer('HOSTKEY', '10.66.0.30'); expect(Illuminate\Support\Facades\Cache::lock('wireguard:hub', 1)->get())->toBeTrue(); $started = microtime(true); (new App\Provisioning\Jobs\RemoveWireguardPeer('HOSTKEY'))->handle($hub); expect(microtime(true) - $started)->toBeGreaterThan(0.8) ->and($hub->peerIps())->toBe([]); }); it('refuses a public key that already belongs to a host', function () { vpnHub(); $key = Keypair::generate()->publicKey; // The host exists but the scheduled sync has not adopted its peer yet. Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]); Livewire::actingAs(operator('Owner'))->test(Vpn::class) ->set('name', 'entführt') ->set('publicKey', $key) ->call('create') ->assertHasErrors('publicKey'); expect(VpnPeer::withTrashed()->count())->toBe(0); }); it('renames a peer it adopted before the host link was known', function () { $hub = vpnHub(); $key = Keypair::generate()->publicKey; $hub->addPeer($key, '10.66.0.7'); // First sync: the step has not stored wg_pubkey yet → unknown peer. (new SyncVpnPeers)->handle($hub); $peer = VpnPeer::query()->where('public_key', $key)->firstOrFail(); expect($peer->name)->toBe(__('vpn.unknown_peer')) ->and($peer->host_id)->toBeNull(); Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]); (new SyncVpnPeers)->handle($hub); expect($peer->fresh()->name)->toBe('pve-fsn1') ->and($peer->fresh()->host_id)->not->toBeNull(); }); it('does not rename an access an operator named themselves', function () { $hub = vpnHub(); $user = operator('Owner'); $key = Keypair::generate()->publicKey; $peer = VpnPeer::factory()->create(['public_key' => $key, 'name' => 'Notebook Boban', 'created_by' => $user->id]); $hub->addPeer($key, $peer->allowed_ip); Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]); (new SyncVpnPeers)->handle($hub); expect($peer->fresh()->name)->toBe('Notebook Boban'); }); it('does not leave a phantom access behind when a host is purged', function () { $hub = vpnHub(); $key = Keypair::generate()->publicKey; $host = Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]); $hub->addPeer($key, '10.66.0.7'); (new SyncVpnPeers)->handle($hub); // console adopts the host peer expect(VpnPeer::query()->count())->toBe(1); (new App\Provisioning\Jobs\PurgeHost($host->uuid))->handle(); // No enabled-but-absent leftover the operator could toggle back on. expect(VpnPeer::query()->count())->toBe(0) ->and(VpnPeer::onlyTrashed()->count())->toBe(1); // The queued removal clears both the hub peer and the tombstone. (new ApplyVpnPeer($key, null, false))->handle($hub); expect($hub->peerIps())->toBe([]) ->and(VpnPeer::withTrashed()->count())->toBe(0); });