general = (object)['countries' => '']; $this->managed = (object)[ 'server_country' => 'NL', 'server_hostname' => 'nl.example.invalid', ]; } } $root = dirname(__DIR__); foreach (['NordApi', 'EndpointStore', 'StatusStore', 'SystemRunner', 'TunnelHealth', 'ManagedConfiguration', 'Reconciler'] as $class) { require_once $root . '/src/opnsense/mvc/app/library/OPNsense/NordVPN/' . $class . '.php'; } class TestConfiguration 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'; } } class AvailableHealth extends TunnelHealth { public function __construct() { } public function check(string $interface): array { return ['available' => true, 'last_handshake' => 1000]; } } class FailOnceRunner extends SystemRunner { public int $firewallCalls = 0; public function applyFirewall(): void { $this->firewallCalls++; if ($this->firewallCalls === 1) { throw new \RuntimeException('Synthetic firewall failure.'); } } } $path = sys_get_temp_dir() . '/nordvpn-retry-' . bin2hex(random_bytes(8)) . '.json'; $status = new StatusStore($path); $runner = new FailOnceRunner(); $reconciler = new Reconciler( new NordApi(static fn() => []), new TestConfiguration(), new AvailableHealth(), $status, $runner ); try { $reconciler->run(); throw new \RuntimeException('The first firewall failure was not reported.'); } catch (\RuntimeException $exception) { if ($exception->getMessage() !== 'Synthetic firewall failure.') { throw $exception; } } if (empty($status->read()['firewall_pending'])) { throw new \RuntimeException('The failed firewall apply was not retained as pending.'); } $reconciler->run(); if ($runner->firewallCalls !== 2 || !empty($status->read()['firewall_pending'])) { throw new \RuntimeException('The pending firewall apply was not completed on retry.'); } unlink($path); echo "ok - reconciliation retries a failed firewall apply\n";