Adopt PHPUnit for plugin unit tests

This commit is contained in:
ms2k 2026-08-07 01:01:58 +09:00
commit 5a16298c22
5 changed files with 2038 additions and 190 deletions

18
tests/bootstrap.php Normal file
View file

@ -0,0 +1,18 @@
<?php
namespace OPNsense\NordVPN;
class Settings
{
public object $general;
public object $managed;
public function __construct()
{
$this->general = (object)['countries' => ''];
$this->managed = (object)[
'server_country' => 'NL',
'server_hostname' => 'nl.example.invalid',
];
}
}

View file

@ -1,190 +0,0 @@
<?php
// SPDX-License-Identifier: BSD-2-Clause
// SPDX-FileCopyrightText: 2026 ms2k
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/NordApi.php';
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/EndpointStore.php';
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/EnrollmentInspector.php';
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/StatusStore.php';
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/SystemRunner.php';
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/TunnelHealth.php';
use OPNsense\NordVPN\EnrollmentInspector;
use OPNsense\NordVPN\EndpointStore;
use OPNsense\NordVPN\NordApi;
use OPNsense\NordVPN\StatusStore;
use OPNsense\NordVPN\SystemRunner;
use OPNsense\NordVPN\TunnelHealth;
$tests = [];
$test = static function (string $name, callable $body) use (&$tests): void {
$tests[$name] = $body;
};
$check = static function (bool $condition, string $message = 'assertion failed'): void {
if (!$condition) {
throw new RuntimeException($message);
}
};
$key = base64_encode(str_repeat("\0", 32));
$test('credentials use the NordVPN access-token authorization scheme', static function () use ($check, $key): void {
$api = new NordApi(static function ($path, $query, $authorization) use ($check, $key): array {
$check($path === '/v1/users/services/credentials');
$check($query === []);
$check($authorization === 'Bearer token:synthetic-token');
return ['nordlynx_private_key' => $key];
});
$check($api->credentials('synthetic-token') === $key);
});
$test('recommendations apply the country allowlist', static function () use ($check, $key): void {
$requests = [];
$api = new NordApi(static function ($path, $query) use (&$requests, $key): array {
$requests[] = [$path, $query];
if ($path === '/v1/servers/countries') {
return [['id' => 99, 'code' => 'nl', 'name' => 'Netherlands']];
}
return [[
'hostname' => 'nl.example.invalid',
'station' => '198.51.100.10',
'locations' => [['country' => ['code' => 'nl']]],
'technologies' => [[
'id' => 35,
'pivot' => ['status' => 'online'],
'metadata' => [['name' => 'public_key', 'value' => $key]],
]],
]];
});
$servers = $api->recommendations(['NL']);
$check(count($servers) === 1);
$check($servers[0]['country'] === 'NL');
$check($requests[1][1]['filters[country_id]'] === 99);
$check($requests[1][1]['filters[servers_technologies]'] === 35);
});
$test('an empty country allowlist queries recommendations directly', static function () use ($check): void {
$api = new NordApi(static function ($path): array {
if ($path === '/v1/servers/countries') {
throw new RuntimeException('countries should not be queried');
}
return [];
});
$check($api->recommendations([]) === []);
});
$test('endpoint discovery paginates the complete NordLynx address set', static function () use ($check): void {
$requests = [];
$api = new NordApi(static function ($path, $query) use (&$requests): array {
$requests[] = [$path, $query];
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)
);
}
return [['station' => '203.0.113.10'], ['station' => 'invalid']];
});
$addresses = $api->endpointAddresses();
$check(count($addresses) === 501);
$check($requests[0][0] === '/v1/servers');
$check($requests[0][1]['limit'] === 500);
$check($requests[1][1]['offset'] === 500);
$check($requests[0][1]['filters[servers_technologies]'] === 35);
});
$test('endpoint refresh writes an atomic reusable table', static function () use ($check): 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);
$check($store->refreshIfStale($api));
$check($store->available());
$check(file_get_contents($path) === "203.0.113.10\n");
$check(!$store->refreshIfStale($api));
$check($requests === 1);
unlink($path);
rmdir($directory);
});
$test('RFC 1918 warning follows destination selector semantics', static function () use ($check): 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'],
]);
$check($inspector->includesPrivateDestinations('any', false));
$check($inspector->includesPrivateDestinations('nested', false));
$check(!$inspector->includesPrivateDestinations('public', false));
$check(!$inspector->includesPrivateDestinations('2001:db8::/32', false));
$check(!$inspector->includesPrivateDestinations('private', true));
$check($inspector->includesPrivateDestinations('10.0.0.0/8', true));
});
$test('status writes atomically and retains defaults', static function () use ($check): void {
$path = sys_get_temp_dir() . '/nordvpn-status-' . bin2hex(random_bytes(8)) . '.json';
$store = new StatusStore($path);
$check($store->read()['available'] === false);
$store->write(['available' => true, 'state' => 'available']);
$status = $store->read();
$check($status['available'] === true);
$check($status['updated_at'] > 0);
unlink($path);
});
$test('tunnel health requires a recent handshake', static function () use ($check): void {
$runner = new SystemRunner(static fn() => "public-key\t900\n");
$recent = new TunnelHealth($runner, 180, static fn() => 1000);
$stale = new TunnelHealth($runner, 50, static fn() => 1000);
$check($recent->check('wg0')['available']);
$check(!$stale->check('wg0')['available']);
$check(!$recent->check('invalid')['available']);
});
$test('WireGuard apply follows the OPNsense registration sequence', static function () use ($check): void {
$commands = [];
$runner = new SystemRunner(static function ($command) use (&$commands): string {
$commands[] = $command;
return '';
});
$runner->applyWireGuard();
$check($commands === [
['/usr/local/sbin/configctl', 'interface', 'invoke', 'registration'],
['/usr/local/sbin/configctl', 'template', 'reload', 'OPNsense/Wireguard'],
['/usr/local/opnsense/scripts/wireguard/wg-service-control.php', '-a', 'configure'],
]);
});
$test('firewall apply reloads rules before evicting plugin states', static function () use ($check): void {
$commands = [];
$runner = new SystemRunner(static function ($command) use (&$commands): string {
$commands[] = $command;
return '';
});
$runner->applyFirewall();
$check($commands === [
['/usr/local/etc/rc.filter_configure'],
['/sbin/pfctl', '-k', 'label', '-k', SystemRunner::FIREWALL_LABEL],
]);
});
$failures = 0;
foreach ($tests as $name => $body) {
try {
$body();
echo "ok - {$name}\n";
} catch (Throwable $exception) {
$failures++;
fwrite(STDERR, "not ok - {$name}: {$exception->getMessage()}\n");
}
}
$command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/reconciler.php');
passthru($command, $status);
if ($status !== 0) {
$failures++;
}
exit($failures === 0 ? 0 : 1);

View file

@ -0,0 +1,89 @@
<?php
use OPNsense\NordVPN\NordApi;
use PHPUnit\Framework\TestCase;
final class NordApiTest extends TestCase
{
private string $key;
protected function setUp(): void
{
$this->key = base64_encode(str_repeat("\0", 32));
}
public function testCredentialsUseNordVpnAccessTokenAuthorization(): void
{
$api = new NordApi(function ($path, $query, $authorization): array {
$this->assertSame('/v1/users/services/credentials', $path);
$this->assertSame([], $query);
$this->assertSame('Bearer token:synthetic-token', $authorization);
return ['nordlynx_private_key' => $this->key];
});
$this->assertSame($this->key, $api->credentials('synthetic-token'));
}
public function testRecommendationsApplyCountryAllowlist(): void
{
$requests = [];
$api = new NordApi(function ($path, $query) use (&$requests): array {
$requests[] = [$path, $query];
if ($path === '/v1/servers/countries') {
return [['id' => 99, 'code' => 'nl', 'name' => 'Netherlands']];
}
return [[
'hostname' => 'nl.example.invalid',
'station' => '198.51.100.10',
'locations' => [['country' => ['code' => 'nl']]],
'technologies' => [[
'id' => 35,
'pivot' => ['status' => 'online'],
'metadata' => [['name' => 'public_key', 'value' => $this->key]],
]],
]];
});
$servers = $api->recommendations(['NL']);
$this->assertCount(1, $servers);
$this->assertSame('NL', $servers[0]['country']);
$this->assertSame(99, $requests[1][1]['filters[country_id]']);
$this->assertSame(35, $requests[1][1]['filters[servers_technologies]']);
}
public function testEmptyCountryAllowlistQueriesRecommendationsDirectly(): void
{
$api = new NordApi(static function ($path): array {
if ($path === '/v1/servers/countries') {
throw new RuntimeException('countries should not be queried');
}
return [];
});
$this->assertSame([], $api->recommendations([]));
}
public function testEndpointDiscoveryPaginatesCompleteNordLynxAddressSet(): void
{
$requests = [];
$api = new NordApi(static function ($path, $query) use (&$requests): array {
$requests[] = [$path, $query];
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)
);
}
return [['station' => '203.0.113.10'], ['station' => 'invalid']];
});
$addresses = $api->endpointAddresses();
$this->assertCount(501, $addresses);
$this->assertSame('/v1/servers', $requests[0][0]);
$this->assertSame(500, $requests[0][1]['limit']);
$this->assertSame(500, $requests[1][1]['offset']);
$this->assertSame(35, $requests[0][1]['filters[servers_technologies]']);
}
}

View file

@ -0,0 +1,61 @@
<?php
use OPNsense\NordVPN\EndpointStore;
use OPNsense\NordVPN\EnrollmentInspector;
use OPNsense\NordVPN\NordApi;
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);
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 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);
}
}

View file

@ -0,0 +1,52 @@
<?php
use OPNsense\NordVPN\SystemRunner;
use OPNsense\NordVPN\TunnelHealth;
use PHPUnit\Framework\TestCase;
final class SystemRunnerAndHealthTest extends TestCase
{
public function testTunnelHealthRequiresRecentHandshake(): void
{
$runner = new SystemRunner(static fn() => "public-key\t900\n");
$recent = new TunnelHealth($runner, 180, static fn() => 1000);
$stale = new TunnelHealth($runner, 50, static fn() => 1000);
$this->assertTrue($recent->check('wg0')['available']);
$this->assertFalse($stale->check('wg0')['available']);
$this->assertFalse($recent->check('invalid')['available']);
}
public function testWireGuardApplyFollowsRegistrationSequence(): void
{
$commands = [];
$runner = new SystemRunner(static function ($command) use (&$commands): string {
$commands[] = $command;
return '';
});
$runner->applyWireGuard();
$this->assertSame([
['/usr/local/sbin/configctl', 'interface', 'invoke', 'registration'],
['/usr/local/sbin/configctl', 'template', 'reload', 'OPNsense/Wireguard'],
['/usr/local/opnsense/scripts/wireguard/wg-service-control.php', '-a', 'configure'],
], $commands);
}
public function testFirewallApplyReloadsRulesBeforeEvictingPluginStates(): void
{
$commands = [];
$runner = new SystemRunner(static function ($command) use (&$commands): string {
$commands[] = $command;
return '';
});
$runner->applyFirewall();
$this->assertSame([
['/usr/local/etc/rc.filter_configure'],
['/sbin/pfctl', '-k', 'label', '-k', SystemRunner::FIREWALL_LABEL],
], $commands);
}
}