instance(WireguardHub::class, $hub); return $hub; } it('keeps the VPN entry in the navigation for every operator', function () { vpnHub(); // Regression guard: the entry used to hang on a capability that the // capability split deleted, which would have hidden the page from everyone. foreach (['Owner', 'Admin', 'Support', 'Developer', 'Read-only'] as $role) { $this->actingAs(operator($role), 'operator')->get(route('admin.overview')) ->assertOk() ->assertSee(route('admin.vpn')); } }); it('shows each operator only their own access unless they may see all', function () { vpnHub(); $support = operator('Support'); $mine = VpnPeer::factory()->ownedBy($support)->create(['name' => 'Support-Notebook']); $foreign = VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']); // Support reaches the page — to find their own access — but sees only it. $this->actingAs($support, 'operator')->get(route('admin.vpn')) ->assertOk() ->assertSee('Support-Notebook') ->assertDontSee('Fremdes-Notebook'); // Developer may see everything, by capability rather than by ownership. $this->actingAs(operator('Developer'), 'operator')->get(route('admin.vpn')) ->assertOk() ->assertSee('Support-Notebook') ->assertSee('Fremdes-Notebook'); expect($mine->fresh()->user_id)->toBe($support->id) ->and($foreign->fresh()->user_id)->not->toBe($support->id); }); it('lets only managers create an access', function () { vpnHub(); // The form is not even offered to someone who cannot use it. $this->actingAs(operator('Support'), 'operator')->get(route('admin.vpn')) ->assertOk() ->assertDontSee(__('vpn.store_config')); $this->actingAs(operator('Owner'), 'operator')->get(route('admin.vpn')) ->assertOk() ->assertSee(__('vpn.store_config')); Livewire::actingAs(operator('Support'), 'operator')->test(Vpn::class) ->set('name', 'selbst ausgestellt') ->call('create') ->assertForbidden(); expect(VpnPeer::withTrashed()->count())->toBe(0); }); it('creates an access, hands out the config once and pushes it to the hub', function () { vpnHub(); Queue::fake(); $component = Livewire::actingAs(operator('Owner'), 'operator') ->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 = App\Services\Wireguard\ConfigHandoff::get($component->get('configToken')); expect($config)->toContain('[Interface]') ->toContain('Address = '.$peer->allowed_ip.'/32') ->toContain('PublicKey = HUBPUBLICKEY0000000000000000000000000000000=') ->toContain('Endpoint = vpn.clupilot.test:51820'); // The private key is handed over once and not kept anywhere. preg_match('/PrivateKey = (\S+)/', $config, $m); expect(Keypair::isValidKey($m[1]))->toBeTrue() ->and(VpnPeer::query()->where('public_key', $m[1])->exists())->toBeFalse() ->and($peer->config_secret)->toBeNull() // …and it never travels in the component snapshot, only a handle does. ->and($component->get('configToken'))->not->toContain($m[1]); 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'), 'operator') ->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('configToken'))->toBeNull(); }); it('rejects a malformed key and a key that already has an access', function () { vpnHub(); $existing = VpnPeer::factory()->create(); Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) ->set('name', 'kaputt')->set('publicKey', 'nope') ->call('create') ->assertHasErrors('publicKey'); Livewire::actingAs(operator('Owner'), 'operator')->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'), 'operator')->test(Vpn::class)->call('toggle', $peer->uuid); expect($peer->fresh()->enabled)->toBeFalse(); Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled); Livewire::actingAs(operator('Owner'), 'operator')->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'), 'operator') ->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'), 'operator') ->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(); // someone else's access $user = operator('Owner'); // Page is open and legitimate… $component = Livewire::actingAs($user, 'operator')->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. And it must // refuse out loud, not quietly do nothing. $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('lets an owner switch their own access off and on', function () { vpnHub(); Queue::fake(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create(); Livewire::actingAs($support, 'operator')->test(Vpn::class)->call('toggle', $peer->uuid); expect($peer->fresh()->enabled)->toBeFalse(); Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled); }); 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->kind)->toBe(VpnPeer::KIND_HOST) ->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'), 'operator') ->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'), 'operator')->test(Vpn::class) ->set('name', 'wieder da')->set('publicKey', $peer->public_key) ->call('create') ->assertHasErrors('publicKey'); }); it('narrows what an open page shows once the account is demoted', function () { vpnHub(); $user = operator('Owner'); VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']); $component = Livewire::actingAs($user, 'operator')->test(Vpn::class)->assertSee('Fremdes-Notebook'); $user->syncRoles(['Support']); app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); // The five-second poll keeps working — the page is still theirs — but it // must stop serving other people's accesses to a demoted account. $component->call('refreshPeers')->assertDontSee('Fremdes-Notebook'); }); 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'), 'operator')->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'), 'operator')->test(Vpn::class) ->set('name', 'verloren') ->set('publicKey', $key) ->call('create') ->assertHasErrors('publicKey'); // What matters: the loser gets told, not a 500, and creates nothing. // (The competing row is written inside our own transaction here, so the // rollback removes it too — a real competitor's row would survive, which a // single-connection sqlite test cannot reproduce.) expect(VpnPeer::withTrashed()->where('name', 'verloren')->count())->toBe(0); }); 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'), 'operator')->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() // Not staff: a staff access has an owner, and this one has nobody. ->and($peer->kind)->toBe(VpnPeer::KIND_SYSTEM); 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() ->and($peer->fresh()->kind)->toBe(VpnPeer::KIND_HOST); }); 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); }); it('stores the config only when asked, and encrypted', function () { vpnHub(); Queue::fake(); $owner = operator('Owner'); Livewire::actingAs($owner, 'operator')->test(Vpn::class) ->set('name', 'Notebook')->set('ownerId', $owner->id) ->set('storeConfig', true) ->call('create') ->assertHasNoErrors(); $peer = VpnPeer::query()->firstOrFail(); expect($peer->config_secret)->not->toBeNull() // Not readable without the key: no plaintext key material in the column. ->and($peer->config_secret)->not->toContain('PrivateKey') ->and($peer->config_secret)->toStartWith('v1:') ->and(App\Services\Wireguard\ConfigVault::decrypt($peer->config_secret))->toContain('[Interface]'); }); it('refuses to store when there is no key to store it under', function () { vpnHub(); config()->set('admin_access.vpn_config_key', ''); Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) ->set('name', 'Notebook')->set('storeConfig', true) ->call('create') ->assertHasErrors('storeConfig'); // Nothing half-created: no access without the storage it was asked for. expect(VpnPeer::withTrashed()->count())->toBe(0); }); it('hands a stored config back only to its owner, and only with the password', function () { vpnHub(); $support = operator('Support'); $support->forceFill(['password' => Hash::make('geheim-1234')])->save(); $peer = VpnPeer::factory()->ownedBy($support)->create([ 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); // Wrong password: nothing comes back. $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) ->set('password', 'falsch') ->call('reveal') ->assertHasErrors('password'); expect($modal->get('token'))->toBeNull(); // Right password: the config is handed over, and the retrieval is recorded. $modal->set('password', 'geheim-1234')->call('reveal')->assertHasNoErrors(); expect(App\Services\Wireguard\ConfigHandoff::get($modal->get('token')))->toContain('PrivateKey = XYZ') ->and($peer->fresh()->download_count)->toBe(1) ->and($peer->fresh()->last_downloaded_at)->not->toBeNull(); }); it('never hands someone else the private key, not even an Owner', function () { vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); // The boss can see the access and revoke it — but holding its key is a // different thing, and the policy says no to everyone but the owner. foreach (['Owner', 'Admin', 'Developer'] as $role) { $other = operator($role); expect($other->can('view', $peer))->toBeTrue() ->and($other->can('downloadConfig', $peer))->toBeFalse(); // The page shows them the access without offering its config… $this->actingAs($other, 'operator')->get(route('admin.vpn')) ->assertOk() ->assertSee($peer->name) ->assertDontSee('admin.vpn-config-access'); // …and going straight at the modal is refused, not merely hidden. Livewire::actingAs($other, 'operator') ->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) ->assertForbidden(); } // The owner still gets the button. $this->actingAs($support, 'operator')->get(route('admin.vpn')) ->assertOk() ->assertSee('admin.vpn-config-access'); }); it('locks out password guessing', function () { vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), ]); $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]); foreach (range(1, 5) as $attempt) { $modal->set('password', 'falsch'.$attempt)->call('reveal')->assertHasErrors('password'); } // Even the correct password is refused while the account is throttled. $modal->set('password', 'password')->call('reveal'); expect($modal->get('token'))->toBeNull(); }); it('sends the operator to sign in instead of a 500 when the session has ended', function () { // reveal() re-checks $this->authorize('downloadConfig', $peer) on every // call, which already refuses a guest (Gate auto-denies a policy whose // first parameter is a non-nullable Operator). That is a separate, // already-correct guard — not the one this test is for. A Gate::before // with a NULLABLE $user isolates the one line this regression is about: // Auth::guard('operator')->user() coming back null past that point, // which used to be dereferenced unguarded ($operator->password). Gate::before(fn (?Operator $user, string $ability) => $ability === 'downloadConfig' ? true : null); vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]); // The modal sat open long enough for the operator's own session to end. Auth::guard('operator')->logout(); $modal->set('password', 'geheim-1234')->call('reveal') ->assertRedirect(route('admin.login')); }); it('destroys the stored config when the access is revoked', function () { vpnHub(); Queue::fake(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); Livewire::actingAs(operator('Owner'), 'operator') ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) ->call('delete'); // The tombstone must not keep a usable credential. $tombstone = VpnPeer::withTrashed()->firstOrFail(); expect($tombstone->config_secret)->toBeNull() ->and($tombstone->secret_purged_at)->not->toBeNull(); }); it('takes the tunnel away when a staff member is revoked', function () { vpnHub(); Queue::fake(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), ]); Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class) ->call('revokeStaff', $support->id); expect($support->fresh()->isOperator())->toBeFalse() ->and(VpnPeer::query()->count())->toBe(0) ->and(VpnPeer::withTrashed()->first()->config_secret)->toBeNull(); Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled); }); it('removes a revoked colleague from the hub only after the rows are committed', function () { $hub = vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create(); $hub->addPeer($peer->public_key, $peer->allowed_ip); // Sync queue: the job runs the moment it is dispatched, so if that happened // inside the transaction it would still see a live peer and leave it up. Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class) ->call('revokeStaff', $support->id); expect($hub->peerIps())->toBe([]) ->and(VpnPeer::withTrashed()->count())->toBe(0); // tombstone cleared by the removal }); it('stops serving a revealed config once the access is revoked', function () { vpnHub(); Queue::fake(); $support = operator('Support'); $support->forceFill(['password' => Hash::make('geheim-1234')])->save(); $peer = VpnPeer::factory()->ownedBy($support)->create([ 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) ->set('password', 'geheim-1234') ->call('reveal'); $token = $modal->get('token'); expect(App\Services\Wireguard\ConfigHandoff::get($token))->toContain('PrivateKey = XYZ'); // Revoked while the modal is still open on someone's screen. Livewire::actingAs(operator('Owner'), 'operator') ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) ->call('delete'); // The open modal must not keep handing the key out of the cache. $modal->call('toggleQr')->assertForbidden(); expect(App\Services\Wireguard\ConfigHandoff::get($token))->toBeNull(); }); it('never leaves the previous config on screen after another access is created', function () { vpnHub(); Queue::fake(); $owner = operator('Owner'); $component = Livewire::actingAs($owner, 'operator')->test(Vpn::class) ->set('name', 'Erster')->set('ownerId', $owner->id) ->call('create'); $firstToken = $component->get('configToken'); expect(App\Services\Wireguard\ConfigHandoff::get($firstToken))->toContain('PrivateKey'); // Second access brings its own key, so there is no config to show — the // first one must not stay up wearing the second one's name. $component->set('name', 'Zweiter')->set('publicKey', Keypair::generate()->publicKey) ->call('create') ->assertHasNoErrors(); expect($component->get('configToken'))->toBeNull() ->and($component->get('newConfigName'))->toBeNull() ->and(App\Services\Wireguard\ConfigHandoff::get($firstToken))->toBeNull(); }); it('does not keep polling while a private config is on screen', function () { vpnHub(); Queue::fake(); $owner = operator('Owner'); $component = Livewire::actingAs($owner, 'operator')->test(Vpn::class)->assertSee('wire:poll', false); $component->set('name', 'Notebook')->set('ownerId', $owner->id)->call('create'); // Otherwise every poll would put the private key into another response. $component->assertDontSee('wire:poll', false)->assertSee('PrivateKey'); $component->call('dismissConfig') ->assertSee('wire:poll', false) ->assertDontSee('PrivateKey'); }); it('will not issue an access to someone who is no longer staff', function () { vpnHub(); $owner = operator('Owner'); $former = App\Models\User::factory()->create(); // no operator role // Not $former->id directly: operators and users are independent id // sequences, and the in-memory test database restarts both near 1 after // every test's rollback, so a users id colliding with a real operator's is // routine here, not a one-in-a-billion accident. Past the highest id either // sequence has handed out this test is an id no operator can hold — proving // the same thing ownerId=$former->id would, without depending on the two // sequences staying apart by luck. $ownerId = max($former->id, $owner->id) + 1; Livewire::actingAs($owner, 'operator')->test(Vpn::class) ->set('name', 'Ex-Kollege')->set('ownerId', $ownerId) ->call('create') ->assertHasErrors('ownerId'); expect(VpnPeer::withTrashed()->count())->toBe(0); }); it('closes the owner doors as soon as the roles are gone', function () { vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), ]); expect($support->can('downloadConfig', $peer))->toBeTrue(); // Roles removed directly, not through revokeStaff() — ownership alone must // not keep the modals reachable. $support->syncRoles([]); app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); $support = $support->fresh(); expect($support->can('downloadConfig', $peer))->toBeFalse() ->and($support->can('delete', $peer))->toBeFalse() ->and($support->can('block', $peer))->toBeFalse(); Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])->assertForbidden(); }); it('re-issues a key for an access whose config was never stored', function () { vpnHub(); Queue::fake(); $owner = operator('Owner'); $peer = VpnPeer::factory()->ownedBy($owner)->create(); $oldKey = $peer->public_key; $component = Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); // A new key, and the config handed over once — the only honest answer when // the private half of the old one exists nowhere. $peer->refresh(); expect($peer->public_key)->not->toBe($oldKey) ->and($peer->present)->toBeFalse() ->and(App\Services\Wireguard\ConfigHandoff::get($component->get('configToken'))) ->toContain('Address = '.$peer->allowed_ip); // Old key off the hub, new key on — in that order, or two peers would // briefly claim the same tunnel address. Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $oldKey && ! $job->enabled); Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && $job->enabled); }); it('keeps a stored config in step when the key is re-issued', function () { vpnHub(); Queue::fake(); $owner = operator('Owner'); $peer = VpnPeer::factory()->ownedBy($owner)->create([ 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = ALT\n"), ]); Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); // Otherwise the owner would download a configuration whose key the hub no // longer accepts. $stored = App\Services\Wireguard\ConfigVault::decrypt($peer->fresh()->config_secret); expect($stored)->not->toContain('PrivateKey = ALT') ->and($stored)->toContain('Address = '.$peer->allowed_ip); }); it('does not re-issue a host peer from the VPN page', function () { vpnHub(); Queue::fake(); $host = Host::factory()->active()->create(); $peer = VpnPeer::factory()->forHost()->create(['host_id' => $host->id]); $oldKey = $peer->public_key; Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); // Its key belongs to the host's own wg0 — swapping it here would cut the // machine off with nothing to repair it. expect($peer->fresh()->public_key)->toBe($oldKey); Queue::assertNotPushed(ApplyVpnPeer::class); }); it('revokes the replaced key even after a sync adopted it', function () { $hub = vpnHub(); $owner = operator('Owner'); $peer = VpnPeer::factory()->ownedBy($owner)->create(); $oldKey = $peer->public_key; $hub->addPeer($oldKey, $peer->allowed_ip); // Queue faked on purpose: the window only exists while the removal is still // pending, which a synchronous queue would close instantly. Queue::fake(); Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); // A sync lands before the removal runs. It must neither adopt the old key // (its address belongs to the live access) nor blow up on the unique index, // which would stop every other peer's state from being updated too. (new SyncVpnPeers)->handle($hub); expect(VpnPeer::query()->where('public_key', $oldKey)->exists())->toBeFalse() ->and(VpnPeer::query()->count())->toBe(1); (new ApplyVpnPeer($oldKey, null, false, force: true))->handle($hub); expect($hub->peerIps())->not->toHaveKey($oldKey) ->and(VpnPeer::withTrashed()->where('public_key', $oldKey)->exists())->toBeFalse(); }); it('does not let a late revocation delete an access that reused the key', function () { $hub = vpnHub(); $owner = operator('Owner'); $key = Keypair::generate()->publicKey; // Someone created a new access with a key that used to belong elsewhere. $fresh = VpnPeer::factory()->ownedBy($owner)->create(['public_key' => $key]); $hub->addPeer($key, $fresh->allowed_ip); // The old revocation job finally runs. (new ApplyVpnPeer($key, null, false, force: true))->handle($hub); expect(VpnPeer::query()->whereKey($fresh->id)->exists())->toBeTrue() ->and($hub->peerIps())->toHaveKey($key); // and they stay connected }); it('re-issues even when the config vault is unavailable', function () { vpnHub(); Queue::fake(); $owner = operator('Owner'); $peer = VpnPeer::factory()->ownedBy($owner)->create([ 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), ]); // The very situation the console tells people to fix by re-issuing. config()->set('admin_access.vpn_config_key', ''); Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); expect($peer->fresh()->config_secret)->toBeNull(); // shown once instead }); it('does not route the WireGuard endpoint through its own tunnel', function () { // The obvious fix for "the VPN does not reach the console" is to add the // server's public address to AllowedIPs. It cannot work: the endpoint is // that same address, so the handshake packets would be routed into the // tunnel they are trying to establish. Same blank page, now with no way in // at all. config()->set('provisioning.wireguard.subnet', '10.66.0.0/24'); $method = new ReflectionMethod(App\Livewire\Admin\Vpn::class, 'allowedIps'); $method->setAccessible(true); foreach (['203.0.113.10:51820', '[2001:db8::10]:51820', 'vpn.example.test:51820'] as $endpoint) { expect($method->invoke(null, $endpoint))->toBe('10.66.0.0/24', $endpoint); } }); it('points a client at the tunnel resolver only when the console is reachable there', function () { // Setting DNS otherwise hands the device a resolver that does not exist and // takes its name resolution down with it for as long as the tunnel is up. config()->set('provisioning.wireguard.hub_address', '10.66.0.1'); $build = new ReflectionMethod(App\Livewire\Admin\Vpn::class, 'clientConfig'); $build->setAccessible(true); $page = new App\Livewire\Admin\Vpn; $keypair = new App\Services\Wireguard\Keypair('priv', 'pub'); // Not ready: no resolver is running, so naming one would take the device's // whole name resolution down for as long as the tunnel is up. config()->set('admin_access.vpn_ready', false); expect($build->invoke($page, $keypair, '10.66.0.9'))->not->toContain('DNS ='); config()->set('admin_access.vpn_ready', true); expect($build->invoke($page, $keypair, '10.66.0.9'))->toContain('DNS = 10.66.0.1'); });