2026-08-07 01:01:58 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
use OPNsense\NordVPN\EndpointStore;
|
|
|
|
|
use OPNsense\NordVPN\EnrollmentInspector;
|
|
|
|
|
use OPNsense\NordVPN\NordApi;
|
2026-08-07 01:04:03 +09:00
|
|
|
use OPNsense\NordVPN\ReconciliationLock;
|
2026-08-07 01:01:58 +09:00
|
|
|
use OPNsense\NordVPN\StatusStore;
|
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
|
|
|
|
|
|
final class StoreAndInspectorTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
public function testEndpointRefreshWritesAtomicReusableTable(): void
|
|
|
|
|
{
|
|
|
|
|
$directory = sys_get_temp_dir() . '/nordvpn-endpoints-' . bin2hex(random_bytes(8));
|
|
|
|
|
$path = $directory . '/endpoints.txt';
|
|
|
|
|
$requests = 0;
|
|
|
|
|
$api = new NordApi(static function () use (&$requests): array {
|
|
|
|
|
$requests++;
|
|
|
|
|
return [['station' => '203.0.113.10']];
|
|
|
|
|
});
|
|
|
|
|
$store = new EndpointStore($path);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($store->refreshIfStale($api));
|
|
|
|
|
$this->assertTrue($store->available());
|
|
|
|
|
$this->assertSame("203.0.113.10\n", file_get_contents($path));
|
|
|
|
|
$this->assertFalse($store->refreshIfStale($api));
|
|
|
|
|
$this->assertSame(1, $requests);
|
|
|
|
|
|
|
|
|
|
unlink($path);
|
2026-08-07 02:30:26 +09:00
|
|
|
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');
|
2026-08-07 01:01:58 +09:00
|
|
|
rmdir($directory);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testRfc1918WarningFollowsDestinationSelectorSemantics(): void
|
|
|
|
|
{
|
|
|
|
|
$inspector = new EnrollmentInspector([
|
|
|
|
|
'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
|
|
|
|
'public' => ['203.0.113.0/24'],
|
|
|
|
|
'nested' => ['private'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($inspector->includesPrivateDestinations('any', false));
|
|
|
|
|
$this->assertTrue($inspector->includesPrivateDestinations('nested', false));
|
|
|
|
|
$this->assertFalse($inspector->includesPrivateDestinations('public', false));
|
|
|
|
|
$this->assertFalse($inspector->includesPrivateDestinations('2001:db8::/32', false));
|
|
|
|
|
$this->assertFalse($inspector->includesPrivateDestinations('private', true));
|
|
|
|
|
$this->assertTrue($inspector->includesPrivateDestinations('10.0.0.0/8', true));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 04:07:19 +09:00
|
|
|
public function testInversionRequiresOneNativeSelector(): void
|
|
|
|
|
{
|
|
|
|
|
$this->assertTrue(EnrollmentInspector::inversionIsUnambiguous('network_group'));
|
|
|
|
|
$this->assertFalse(EnrollmentInspector::inversionIsUnambiguous('private,relay'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 01:01:58 +09:00
|
|
|
public function testStatusWritesAtomicallyAndRetainsDefaults(): void
|
|
|
|
|
{
|
|
|
|
|
$path = sys_get_temp_dir() . '/nordvpn-status-' . bin2hex(random_bytes(8)) . '.json';
|
|
|
|
|
$store = new StatusStore($path);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($store->read()['available']);
|
|
|
|
|
$store->write(['available' => true, 'state' => 'available']);
|
|
|
|
|
$status = $store->read();
|
|
|
|
|
$this->assertTrue($status['available']);
|
|
|
|
|
$this->assertGreaterThan(0, $status['updated_at']);
|
|
|
|
|
|
|
|
|
|
unlink($path);
|
|
|
|
|
}
|
2026-08-07 01:04:03 +09:00
|
|
|
|
|
|
|
|
public function testReconciliationLockRejectsConcurrentOperations(): void
|
|
|
|
|
{
|
|
|
|
|
$path = sys_get_temp_dir() . '/nordvpn-lock-' . bin2hex(random_bytes(8));
|
|
|
|
|
$first = new ReconciliationLock($path);
|
|
|
|
|
$second = new ReconciliationLock($path);
|
|
|
|
|
|
|
|
|
|
$first->acquire();
|
|
|
|
|
try {
|
|
|
|
|
$second->acquire();
|
|
|
|
|
$this->fail('A concurrent operation acquired the lock.');
|
|
|
|
|
} catch (RuntimeException $exception) {
|
|
|
|
|
$this->assertSame('A NordVPN operation is already running.', $exception->getMessage());
|
|
|
|
|
} finally {
|
|
|
|
|
$first->release();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$second->acquire();
|
|
|
|
|
$second->release();
|
|
|
|
|
unlink($path);
|
|
|
|
|
}
|
2026-08-07 01:01:58 +09:00
|
|
|
}
|