forHost()->create([ 'host_id' => $host->id, 'name' => $host->name, 'last_handshake_at' => $handshakeSecondsAgo === null ? null : CarbonImmutable::now()->subSeconds($handshakeSecondsAgo), 'observed_at' => CarbonImmutable::now()->subSeconds($observedSecondsAgo), ]); } it('reports not_configured for a host with no wg_pubkey at all', function () { $host = Host::factory()->create(['wg_pubkey' => null]); expect($host->vpnState(null))->toBe('not_configured'); }); it('reports connected for a fresh handshake', function () { $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); $peer = vpnPeerFor($host, handshakeSecondsAgo: 30); expect($host->vpnState($peer))->toBe('connected'); }); it('reports silent for a stale handshake', function () { $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); $peer = vpnPeerFor($host, handshakeSecondsAgo: 60 * 60 * 24 * 2); // two days expect($host->vpnState($peer))->toBe('silent'); }); it('reports silent for a configured peer that never handshaked', function () { $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); $peer = vpnPeerFor($host, handshakeSecondsAgo: null); expect($host->vpnState($peer))->toBe('silent'); }); it('reports unknown when the host is configured but has never been synced', function () { $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); expect($host->vpnState(null))->toBe('unknown'); }); it('reports unknown when the sync itself has gone stale, even with an old handshake on record', function () { $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); // The sync worker stopped an hour ago; the last thing it saw was a fresh // handshake. Trusting last_handshake_at here would say "connected" for a // host nobody has actually checked on in an hour. $peer = vpnPeerFor($host, handshakeSecondsAgo: 10, observedSecondsAgo: 60 * 60); expect($host->vpnState($peer))->toBe('unknown'); }); it('never reports not_configured as unknown or vice versa regardless of sync state', function () { $unconfigured = Host::factory()->create(['wg_pubkey' => null]); expect($unconfigured->vpnState(null))->toBe('not_configured'); }); /** * Pins the exact freshness boundary — the comparison a mutation pass targets * (`<=` flipped to `<`, or the constant changed) to prove the test actually * fails when a stale handshake would otherwise read as connected. */ it('treats a handshake older than the freshness threshold as silent, not connected', function () { $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); // Frozen clock: a wall-clock version of this test is one CPU hiccup away // from putting "now" a millisecond past the boundary it means to sit at. $this->travelTo(CarbonImmutable::parse('2026-07-28 12:00:00')); $atThreshold = vpnPeerFor($host, handshakeSecondsAgo: Host::VPN_HANDSHAKE_FRESH_SECONDS); $pastThreshold = vpnPeerFor($host, handshakeSecondsAgo: Host::VPN_HANDSHAKE_FRESH_SECONDS + 1); expect($host->vpnState($atThreshold))->toBe('connected') ->and($host->vpnState($pastThreshold))->toBe('silent'); $this->travelBack(); }); /** * Pins the sync-staleness boundary — the second comparison a mutation pass * targets, proving a frozen sync reads as unknown rather than as whatever * last_handshake_at happens to say. */ it('treats a stale observed_at as unknown, not as whatever the frozen handshake says', function () { $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); $this->travelTo(CarbonImmutable::parse('2026-07-28 12:00:00')); $atThreshold = vpnPeerFor($host, handshakeSecondsAgo: 10, observedSecondsAgo: Host::VPN_SYNC_STALE_SECONDS); $pastThreshold = vpnPeerFor($host, handshakeSecondsAgo: 10, observedSecondsAgo: Host::VPN_SYNC_STALE_SECONDS + 1); expect($host->vpnState($atThreshold))->toBe('connected') ->and($host->vpnState($pastThreshold))->toBe('unknown'); $this->travelBack(); }); it('renders the three VPN states distinctly in the hosts list', function () { $unconfigured = Host::factory()->active()->create(['name' => 'pve-none-1', 'wg_pubkey' => null]); $connected = Host::factory()->active()->create(['name' => 'pve-conn-1', 'wg_pubkey' => 'CONNPUB=']); $silent = Host::factory()->active()->create(['name' => 'pve-silent-1', 'wg_pubkey' => 'SILENTPUB=']); vpnPeerFor($connected, handshakeSecondsAgo: 20); vpnPeerFor($silent, handshakeSecondsAgo: 60 * 60 * 24 * 3); $response = $this->actingAs(admin(), 'operator')->get(route('admin.hosts')); $response->assertOk() ->assertSeeInOrder([$unconfigured->name, __('hosts.vpn.not_configured')]) ->assertSeeInOrder([$connected->name, __('hosts.vpn.connected')]) // "Still · vor 3 Tagen" (or English "Silent · 3 days ago") — the fixed // word proves the state, the diffForHumans() tail proves "how long". ->assertSee(__('hosts.vpn.silent')); }); it('shows unknown, not a false connected or silent, when a configured host was never synced', function () { $host = Host::factory()->active()->create(['name' => 'pve-unsynced-1', 'wg_pubkey' => 'HOSTPUB=']); $this->actingAs(admin(), 'operator')->get(route('admin.hosts')) ->assertOk() ->assertSeeInOrder([$host->name, __('hosts.vpn.unknown')]); }); it('loads the VPN column with one eager-loaded query, not one per host', function () { // Isolated to exactly what the VPN column added — Host::query()->with('vpnPeer') // — rather than the full page response, which also carries an unrelated, // pre-existing capacity-column query per host (committedGb()) not part of // this change and out of scope here. $queryCount = 0; DB::listen(function () use (&$queryCount) { $queryCount++; }); $countQueriesFor = function (int $hostCount) use (&$queryCount) { Host::query()->delete(); VpnPeer::query()->forceDelete(); foreach (range(1, $hostCount) as $i) { $host = Host::factory()->active()->create(['wg_pubkey' => 'PUB'.$i.'=']); vpnPeerFor($host, handshakeSecondsAgo: 20); } // Reset right before the load being measured. $queryCount = 0; Host::query()->with('vpnPeer')->get()->each(fn ($h) => $h->vpnPeer); return $queryCount; }; $queriesFor2 = $countQueriesFor(2); $queriesFor10 = $countQueriesFor(10); expect($queriesFor2)->toBe(2) // one for hosts, one eager-loaded query for their vpn peers ->and($queriesFor10)->toBe($queriesFor2); });