'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); 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); } 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)); } public function testInversionRequiresOneNativeSelector(): void { $this->assertTrue(EnrollmentInspector::inversionIsUnambiguous('network_group')); $this->assertFalse(EnrollmentInspector::inversionIsUnambiguous('private,relay')); } 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); } 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); } }