CluPilotCloud/tests/Feature/Admin/HostVpnStateTest.php

138 lines
5.6 KiB
PHP

<?php
use App\Livewire\Admin\Hosts;
use App\Models\Host;
use App\Services\Wireguard\FakeWireguardHub;
use App\Services\Wireguard\PeerSnapshot;
use App\Services\Wireguard\WireguardHub;
use Carbon\CarbonImmutable;
use Livewire\Livewire;
/**
* The hosts list is the only place a dead management tunnel is visible at all
* (see host-vpn-state-report.md) — Traefik keeps serving customers on a dead
* peer, so nothing else fails loudly. These tests pin the three states the
* console must tell apart, plus the honest "don't know" fourth.
*/
function snapshotAgedSeconds(string $publicKey, int $seconds): PeerSnapshot
{
return new PeerSnapshot(
publicKey: $publicKey,
endpoint: '203.0.113.50:51820',
allowedIps: '10.66.0.9/32',
latestHandshake: CarbonImmutable::now()->subSeconds($seconds),
rxBytes: 100,
txBytes: 100,
);
}
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, hubReachable: true))->toBe('not_configured');
});
it('reports connected for a fresh handshake', function () {
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
$snapshot = snapshotAgedSeconds('HOSTPUB=', 30);
expect($host->vpnState($snapshot, hubReachable: true))->toBe('connected');
});
it('reports silent for a stale handshake', function () {
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
$snapshot = snapshotAgedSeconds('HOSTPUB=', 60 * 60 * 24 * 2); // two days
expect($host->vpnState($snapshot, hubReachable: true))->toBe('silent');
});
it('reports silent for a configured peer that never handshaked', function () {
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
$snapshot = new PeerSnapshot('HOSTPUB=', null, '10.66.0.9/32', null, 0, 0);
expect($host->vpnState($snapshot, hubReachable: true))->toBe('silent');
});
it('reports unknown when the hub could not be asked, even for a configured host', function () {
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
expect($host->vpnState(null, hubReachable: false))->toBe('unknown');
});
it('never reports not_configured as unknown or vice versa regardless of hub reachability', function () {
$unconfigured = Host::factory()->create(['wg_pubkey' => null]);
expect($unconfigured->vpnState(null, hubReachable: false))->toBe('not_configured');
});
/**
* Pins the exact threshold boundary — this is the comparison the 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.
// travelTo() freezes both Carbon and CarbonImmutable's now().
$now = CarbonImmutable::parse('2026-07-28 12:00:00');
$this->travelTo($now);
$atThreshold = new PeerSnapshot('HOSTPUB=', null, '10.66.0.9/32', $now->subSeconds(Host::VPN_HANDSHAKE_FRESH_SECONDS), 0, 0);
$pastThreshold = new PeerSnapshot('HOSTPUB=', null, '10.66.0.9/32', $now->subSeconds(Host::VPN_HANDSHAKE_FRESH_SECONDS + 1), 0, 0);
expect($host->vpnState($atThreshold, hubReachable: true))->toBe('connected')
->and($host->vpnState($pastThreshold, hubReachable: true))->toBe('silent');
$this->travelBack();
});
it('renders the three VPN states distinctly in the hosts list', function () {
$hub = new FakeWireguardHub;
app()->instance(WireguardHub::class, $hub);
$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=']);
$hub->addPeer('CONNPUB=', '10.66.0.10');
$hub->addPeer('SILENTPUB=', '10.66.0.11');
$hub->snapshots['CONNPUB='] = snapshotAgedSeconds('CONNPUB=', 20);
$hub->snapshots['SILENTPUB='] = snapshotAgedSeconds('SILENTPUB=', 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 the hub is unreachable', function () {
$hub = new FakeWireguardHub;
$hub->failPeers = true;
app()->instance(WireguardHub::class, $hub);
$host = Host::factory()->active()->create(['name' => 'pve-unreachable-1', 'wg_pubkey' => 'HOSTPUB=']);
$this->actingAs(admin(), 'operator')->get(route('admin.hosts'))
->assertOk()
->assertSeeInOrder([$host->name, __('hosts.vpn.unknown')]);
});
it('calls the hub once for the whole list, not once per host', function () {
$hub = new FakeWireguardHub;
app()->instance(WireguardHub::class, $hub);
foreach (range(1, 5) as $i) {
Host::factory()->active()->create(['wg_pubkey' => 'PUB'.$i.'=']);
}
Livewire::actingAs(admin(), 'operator')->test(Hosts::class);
expect($hub->peersCalls)->toBe(1);
});