opnsense-nordvpn-plugin/tests/unit/ReconcilerTest.php

273 lines
7.1 KiB
PHP

<?php
use OPNsense\NordVPN\ManagedConfiguration;
use OPNsense\NordVPN\NordApi;
use OPNsense\NordVPN\Reconciler;
use OPNsense\NordVPN\Settings;
use OPNsense\NordVPN\StatusStore;
use OPNsense\NordVPN\SystemRunner;
use OPNsense\NordVPN\TunnelHealth;
use PHPUnit\Framework\TestCase;
class ReconcilerTestConfiguration extends ManagedConfiguration
{
private Settings $testSettings;
public function __construct()
{
$this->testSettings = new Settings();
}
public function settings(): Settings
{
return $this->testSettings;
}
public function interfaceName(): ?string
{
return 'wg0';
}
public function repairGatewayInterface(): bool
{
return false;
}
}
class RepairReconcilerConfiguration extends ReconcilerTestConfiguration
{
public function repairGatewayInterface(): bool
{
return true;
}
}
class FailoverReconcilerConfiguration extends ReconcilerTestConfiguration
{
public bool $candidateUpdated = false;
public int $repairCalls = 0;
public function updateCandidate(array $candidate): void
{
$this->candidateUpdated = true;
$this->settings()->managed->server_hostname = $candidate['hostname'];
$this->settings()->managed->server_country = $candidate['country'];
}
public function repairGatewayInterface(): bool
{
if (!$this->candidateUpdated) {
return false;
}
$this->candidateUpdated = false;
$this->repairCalls++;
return true;
}
}
class AvailableReconcilerHealth extends TunnelHealth
{
public function __construct()
{
}
public function check(string $interface): array
{
return ['available' => true, 'last_handshake' => 1000];
}
}
class RecoveringReconcilerHealth extends TunnelHealth
{
private int $checks = 0;
public function __construct()
{
}
public function check(string $interface): array
{
$this->checks++;
return ['available' => $this->checks > 1, 'last_handshake' => $this->checks > 1 ? 1000 : 0];
}
}
class UnavailableReconcilerHealth extends TunnelHealth
{
public function __construct()
{
}
public function check(string $interface): array
{
return ['available' => false, 'last_handshake' => 0];
}
}
class FailOnceReconcilerRunner extends SystemRunner
{
public int $firewallCalls = 0;
public function applyFirewall(): void
{
$this->firewallCalls++;
if ($this->firewallCalls === 1) {
throw new RuntimeException('Synthetic firewall failure.');
}
}
}
class RepairReconcilerRunner extends SystemRunner
{
public int $routingCalls = 0;
public int $firewallCalls = 0;
public function applyRouting(): void
{
$this->routingCalls++;
}
public function applyFirewall(): void
{
$this->firewallCalls++;
}
}
class FailoverReconcilerRunner extends SystemRunner
{
public int $wireGuardCalls = 0;
public int $routingCalls = 0;
public int $firewallCalls = 0;
public function applyWireGuard(): void
{
$this->wireGuardCalls++;
}
public function applyRouting(): void
{
$this->routingCalls++;
}
public function applyFirewall(): void
{
$this->firewallCalls++;
}
}
final class ReconcilerTest extends TestCase
{
private string $key;
protected function setUp(): void
{
$this->key = base64_encode(str_repeat("\0", 32));
}
public function testReconciliationAppliesGatewayInterfaceRepair(): void
{
$path = $this->temporaryStatusPath();
$runner = new RepairReconcilerRunner();
(new Reconciler(
new NordApi(static fn() => []),
new RepairReconcilerConfiguration(),
new AvailableReconcilerHealth(),
new StatusStore($path),
$runner
))->run();
$this->assertSame(1, $runner->routingCalls);
$this->assertSame(2, $runner->firewallCalls);
unlink($path);
}
public function testServerFailoverRepairsGatewayInterface(): void
{
$path = $this->temporaryStatusPath();
$configuration = new FailoverReconcilerConfiguration();
$runner = new FailoverReconcilerRunner();
(new Reconciler(
new NordApi(fn() => [$this->candidate('nl2.example.invalid', '198.51.100.2')]),
$configuration,
new RecoveringReconcilerHealth(),
new StatusStore($path),
$runner,
static fn($seconds) => null
))->run();
$this->assertSame(1, $runner->wireGuardCalls);
$this->assertSame(1, $configuration->repairCalls);
$this->assertSame(1, $runner->routingCalls);
$this->assertSame(1, $runner->firewallCalls);
unlink($path);
}
public function testFailedFirewallApplyIsRetried(): void
{
$path = $this->temporaryStatusPath();
$status = new StatusStore($path);
$runner = new FailOnceReconcilerRunner();
$reconciler = new Reconciler(
new NordApi(static fn() => []),
new ReconcilerTestConfiguration(),
new AvailableReconcilerHealth(),
$status,
$runner
);
try {
$reconciler->run();
$this->fail('The first firewall failure was not reported.');
} catch (RuntimeException $exception) {
$this->assertSame('Synthetic firewall failure.', $exception->getMessage());
}
$this->assertTrue($status->read()['firewall_pending']);
$reconciler->run();
$this->assertSame(2, $runner->firewallCalls);
$this->assertFalse($status->read()['firewall_pending']);
unlink($path);
}
public function testUnavailableStatusNamesLastAttemptedServer(): void
{
$path = $this->temporaryStatusPath();
$configuration = new FailoverReconcilerConfiguration();
$result = (new Reconciler(
new NordApi(fn() => [
$this->candidate('nl2.example.invalid', '198.51.100.2'),
$this->candidate('nl3.example.invalid', '198.51.100.3'),
]),
$configuration,
new UnavailableReconcilerHealth(),
new StatusStore($path),
new FailoverReconcilerRunner(),
static fn($seconds) => null
))->run();
$this->assertSame('nl3.example.invalid', $result['server_hostname']);
$this->assertSame('NL', $result['server_country']);
unlink($path);
}
private function temporaryStatusPath(): string
{
return sys_get_temp_dir() . '/nordvpn-reconciler-' . bin2hex(random_bytes(8)) . '.json';
}
private function candidate(string $hostname, string $address): array
{
return [
'hostname' => $hostname,
'station' => $address,
'locations' => [['country' => ['code' => 'NL']]],
'technologies' => [[
'id' => 35,
'pivot' => ['status' => 'online'],
'metadata' => [['name' => 'public_key', 'value' => $this->key]],
]],
];
}
}