Retain failed failover candidate status
This commit is contained in:
parent
5a16298c22
commit
ceb684c6c6
1 changed files with 287 additions and 245 deletions
273
tests/unit/ReconcilerTest.php
Normal file
273
tests/unit/ReconcilerTest.php
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
<?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]],
|
||||
]],
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue