Harden NordVPN reconciliation and WebGUI testing
This commit is contained in:
parent
a115d2a012
commit
b4a30ca190
5 changed files with 777 additions and 97 deletions
126
tests/unit/BootstrapperTest.php
Normal file
126
tests/unit/BootstrapperTest.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
use OPNsense\NordVPN\Bootstrapper;
|
||||
use OPNsense\NordVPN\EndpointStore;
|
||||
use OPNsense\NordVPN\ManagedConfiguration;
|
||||
use OPNsense\NordVPN\NordApi;
|
||||
use OPNsense\NordVPN\ReconciliationLock;
|
||||
use OPNsense\NordVPN\Settings;
|
||||
use OPNsense\NordVPN\StatusStore;
|
||||
use OPNsense\NordVPN\SystemRunner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BootstrapperTestApi extends NordApi
|
||||
{
|
||||
public string $token = '';
|
||||
private string $key;
|
||||
|
||||
public function __construct(string $key)
|
||||
{
|
||||
$this->key = $key;
|
||||
}
|
||||
|
||||
public function recommendations(array $countryCodes = [], int $limit = 5): array
|
||||
{
|
||||
return [[
|
||||
'hostname' => 'nl.example.invalid',
|
||||
'address' => '198.51.100.10',
|
||||
'country' => 'NL',
|
||||
'public_key' => $this->key,
|
||||
]];
|
||||
}
|
||||
|
||||
public function credentials(string $token): string
|
||||
{
|
||||
$this->token = $token;
|
||||
return $this->key;
|
||||
}
|
||||
}
|
||||
|
||||
class BootstrapperTestConfiguration extends ManagedConfiguration
|
||||
{
|
||||
private Settings $settings;
|
||||
public array $provisioned = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->settings = new Settings();
|
||||
}
|
||||
|
||||
public function settings(): Settings
|
||||
{
|
||||
return $this->settings;
|
||||
}
|
||||
|
||||
public function provision(string $privateKey, array $candidate): void
|
||||
{
|
||||
$this->provisioned = [$privateKey, $candidate];
|
||||
}
|
||||
|
||||
public function repairGatewayInterface(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class BootstrapperTestRunner extends SystemRunner
|
||||
{
|
||||
public array $operations = [];
|
||||
|
||||
public function applyWireGuard(): void
|
||||
{
|
||||
$this->operations[] = 'wireguard';
|
||||
}
|
||||
|
||||
public function applyRouting(): void
|
||||
{
|
||||
$this->operations[] = 'routing';
|
||||
}
|
||||
|
||||
public function applyFirewall(): void
|
||||
{
|
||||
$this->operations[] = 'firewall';
|
||||
}
|
||||
}
|
||||
|
||||
final class BootstrapperTest extends TestCase
|
||||
{
|
||||
public function testBootstrapQueuesReconciliationAfterApplyingInitialTunnel(): void
|
||||
{
|
||||
$key = base64_encode(str_repeat("\0", 32));
|
||||
$api = new BootstrapperTestApi($key);
|
||||
$configuration = new BootstrapperTestConfiguration();
|
||||
$runner = new BootstrapperTestRunner();
|
||||
$statusPath = sys_get_temp_dir() . '/nordvpn-bootstrap-' . bin2hex(random_bytes(8)) . '.json';
|
||||
$lockPath = sys_get_temp_dir() . '/nordvpn-bootstrap-' . bin2hex(random_bytes(8)) . '.lock';
|
||||
$endpointDirectory = sys_get_temp_dir() . '/nordvpn-bootstrap-' . bin2hex(random_bytes(8));
|
||||
$endpointPath = $endpointDirectory . '/endpoints.txt';
|
||||
$configuration->settings()->general->exempt_wireguard = '1';
|
||||
$queued = 0;
|
||||
|
||||
$result = (new Bootstrapper(
|
||||
$api,
|
||||
$configuration,
|
||||
new StatusStore($statusPath),
|
||||
$runner,
|
||||
new EndpointStore($endpointPath),
|
||||
new ReconciliationLock($lockPath),
|
||||
static function () use (&$queued): void {
|
||||
$queued++;
|
||||
}
|
||||
))->run('synthetic-token');
|
||||
|
||||
self::assertSame('ok', $result['status']);
|
||||
self::assertSame('synthetic-token', $api->token);
|
||||
self::assertSame($key, $configuration->provisioned[0]);
|
||||
self::assertSame(['firewall', 'wireguard', 'routing', 'firewall'], $runner->operations);
|
||||
self::assertSame("198.51.100.10\n", file_get_contents($endpointPath));
|
||||
self::assertSame('reconciling', $result['tunnel']['state']);
|
||||
self::assertSame(1, $queued);
|
||||
|
||||
unlink($statusPath);
|
||||
unlink($lockPath);
|
||||
unlink($endpointPath);
|
||||
rmdir($endpointDirectory);
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,37 @@ final class NordApiTest extends TestCase
|
|||
$this->assertSame([], $api->recommendations([]));
|
||||
}
|
||||
|
||||
public function testRecommendationsRequestEverySelectedCountry(): void
|
||||
{
|
||||
$requests = [];
|
||||
$api = new NordApi(function ($path, $query) use (&$requests): array {
|
||||
$requests[] = [$path, $query];
|
||||
if ($path === '/v1/servers/countries') {
|
||||
return [
|
||||
['id' => 1, 'code' => 'nl', 'name' => 'Netherlands'],
|
||||
['id' => 2, 'code' => 'kr', 'name' => 'Korea'],
|
||||
];
|
||||
}
|
||||
$country = $query['filters[country_id]'] === 1 ? 'NL' : 'KR';
|
||||
return [[
|
||||
'hostname' => strtolower($country) . '.example.invalid',
|
||||
'station' => $country === 'NL' ? '198.51.100.10' : '198.51.100.11',
|
||||
'locations' => [['country' => ['code' => $country]]],
|
||||
'technologies' => [[
|
||||
'id' => 35,
|
||||
'pivot' => ['status' => 'online'],
|
||||
'metadata' => [['name' => 'public_key', 'value' => $this->key]],
|
||||
]],
|
||||
]];
|
||||
});
|
||||
|
||||
$servers = $api->recommendations(['NL', 'KR'], 1);
|
||||
|
||||
self::assertSame(1, $requests[1][1]['filters[country_id]']);
|
||||
self::assertSame(2, $requests[2][1]['filters[country_id]']);
|
||||
self::assertSame(['NL', 'KR'], array_column($servers, 'country'));
|
||||
}
|
||||
|
||||
public function testEndpointDiscoveryPaginatesCompleteNordLynxAddressSet(): void
|
||||
{
|
||||
$requests = [];
|
||||
|
|
@ -72,7 +103,7 @@ final class NordApiTest extends TestCase
|
|||
if ($query['offset'] === 0) {
|
||||
return array_map(
|
||||
static fn($index) => ['station' => sprintf('198.51.%d.%d', intdiv($index, 250), $index % 250 + 1)],
|
||||
range(0, 499)
|
||||
range(0, 999)
|
||||
);
|
||||
}
|
||||
return [['station' => '203.0.113.10'], ['station' => 'invalid']];
|
||||
|
|
@ -80,10 +111,10 @@ final class NordApiTest extends TestCase
|
|||
|
||||
$addresses = $api->endpointAddresses();
|
||||
|
||||
$this->assertCount(501, $addresses);
|
||||
$this->assertCount(1001, $addresses);
|
||||
$this->assertSame('/v1/servers', $requests[0][0]);
|
||||
$this->assertSame(500, $requests[0][1]['limit']);
|
||||
$this->assertSame(500, $requests[1][1]['offset']);
|
||||
$this->assertSame(1000, $requests[0][1]['limit']);
|
||||
$this->assertSame(1000, $requests[1][1]['offset']);
|
||||
$this->assertSame(35, $requests[0][1]['filters[servers_technologies]']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use OPNsense\NordVPN\ManagedConfiguration;
|
||||
use OPNsense\NordVPN\EndpointStore;
|
||||
use OPNsense\NordVPN\NordApi;
|
||||
use OPNsense\NordVPN\Reconciler;
|
||||
use OPNsense\NordVPN\Settings;
|
||||
|
|
@ -104,6 +105,19 @@ class UnavailableReconcilerHealth extends TunnelHealth
|
|||
}
|
||||
}
|
||||
|
||||
class FailingReconcilerEndpointStore extends EndpointStore
|
||||
{
|
||||
public function refreshIfStale(NordApi $api): bool
|
||||
{
|
||||
throw new RuntimeException('Synthetic endpoint failure.');
|
||||
}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class FailOnceReconcilerRunner extends SystemRunner
|
||||
{
|
||||
public int $firewallCalls = 0;
|
||||
|
|
@ -252,6 +266,31 @@ final class ReconcilerTest extends TestCase
|
|||
unlink($path);
|
||||
}
|
||||
|
||||
public function testEndpointRefreshFailureDoesNotDelayAvailableStatus(): void
|
||||
{
|
||||
$path = $this->temporaryStatusPath();
|
||||
$configuration = new ReconcilerTestConfiguration();
|
||||
$configuration->settings()->general->exempt_wireguard = '1';
|
||||
|
||||
$result = (new Reconciler(
|
||||
new NordApi(static fn() => []),
|
||||
$configuration,
|
||||
new AvailableReconcilerHealth(),
|
||||
new StatusStore($path),
|
||||
new RepairReconcilerRunner(),
|
||||
null,
|
||||
new FailingReconcilerEndpointStore()
|
||||
))->run();
|
||||
|
||||
$this->assertTrue($result['available']);
|
||||
$this->assertSame('available', $result['state']);
|
||||
$this->assertSame(
|
||||
'NordVPN endpoint refresh failed; the WireGuard exemption is inactive.',
|
||||
$result['endpoint_message']
|
||||
);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
private function temporaryStatusPath(): string
|
||||
{
|
||||
return sys_get_temp_dir() . '/nordvpn-reconciler-' . bin2hex(random_bytes(8)) . '.json';
|
||||
|
|
|
|||
|
|
@ -27,6 +27,44 @@ final class StoreAndInspectorTest extends TestCase
|
|||
$this->assertSame(1, $requests);
|
||||
|
||||
unlink($path);
|
||||
unlink($path . '.refreshed');
|
||||
rmdir($directory);
|
||||
}
|
||||
|
||||
public function testEndpointRefreshDefersAfterFailure(): void
|
||||
{
|
||||
$directory = sys_get_temp_dir() . '/nordvpn-endpoints-' . bin2hex(random_bytes(8));
|
||||
$path = $directory . '/endpoints.txt';
|
||||
$now = time();
|
||||
$requests = 0;
|
||||
$api = new NordApi(static function () use (&$requests): array {
|
||||
$requests++;
|
||||
throw new RuntimeException('Synthetic endpoint failure.');
|
||||
});
|
||||
$store = new EndpointStore($path, static function () use (&$now): int {
|
||||
return $now;
|
||||
});
|
||||
|
||||
try {
|
||||
$store->refreshIfStale($api);
|
||||
$this->fail('Expected endpoint refresh to fail.');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertSame('Synthetic endpoint failure.', $exception->getMessage());
|
||||
}
|
||||
$this->assertTrue($store->retryPending());
|
||||
$this->assertFalse($store->refreshIfStale($api));
|
||||
$this->assertSame(1, $requests);
|
||||
|
||||
$now += 301;
|
||||
try {
|
||||
$store->refreshIfStale($api);
|
||||
$this->fail('Expected endpoint refresh retry to fail.');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertSame('Synthetic endpoint failure.', $exception->getMessage());
|
||||
}
|
||||
$this->assertSame(2, $requests);
|
||||
|
||||
unlink($path . '.retry');
|
||||
rmdir($directory);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,33 @@ use PHPUnit\Framework\TestCase;
|
|||
|
||||
final class SystemRunnerAndHealthTest extends TestCase
|
||||
{
|
||||
public function testRunDrainsStderr(): void
|
||||
{
|
||||
$runner = new SystemRunner(null, 2);
|
||||
$output = $runner->run([
|
||||
PHP_BINARY,
|
||||
'-r',
|
||||
'fwrite(STDERR, str_repeat("x", 262144)); echo "done";',
|
||||
]);
|
||||
|
||||
self::assertSame('done', $output);
|
||||
}
|
||||
|
||||
public function testRunTimesOut(): void
|
||||
{
|
||||
$runner = new SystemRunner(null, 1);
|
||||
$started = hrtime(true);
|
||||
|
||||
try {
|
||||
$runner->run([PHP_BINARY, '-r', 'while (true) { usleep(100000); }']);
|
||||
self::fail('Expected the system command to time out.');
|
||||
} catch (\RuntimeException $exception) {
|
||||
self::assertSame('System command timed out.', $exception->getMessage());
|
||||
}
|
||||
|
||||
self::assertLessThan(3, (hrtime(true) - $started) / 1_000_000_000);
|
||||
}
|
||||
|
||||
public function testTunnelHealthRequiresRecentHandshake(): void
|
||||
{
|
||||
$runner = new SystemRunner(static fn() => "public-key\t900\n");
|
||||
|
|
|
|||
Loading…
Reference in a new issue