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]], ]], ]; } }