Adopt PHPUnit for plugin unit tests
This commit is contained in:
parent
7c4f4ffe0d
commit
5a16298c22
9 changed files with 2038 additions and 190 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -22,6 +22,8 @@ dist/
|
|||
artifacts/
|
||||
coverage/
|
||||
*.log
|
||||
.phpunit.result.cache
|
||||
vendor/
|
||||
tests/ui/.runtime/
|
||||
tests/ui/node_modules/
|
||||
tests/ui/playwright-report/
|
||||
|
|
|
|||
22
composer.json
Normal file
22
composer.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "ms2k/opnsense-nordvpn-plugin",
|
||||
"description": "OPNsense NordVPN plugin development dependencies",
|
||||
"license": "BSD-2-Clause",
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5"
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"OPNsense\\NordVPN\\": "src/opnsense/mvc/app/library/OPNsense/NordVPN/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "8.3.0"
|
||||
},
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
1786
composer.lock
generated
Normal file
1786
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
8
phpunit.xml
Normal file
8
phpunit.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit bootstrap="tests/bootstrap.php" colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="NordVPN plugin">
|
||||
<directory>tests/unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
18
tests/bootstrap.php
Normal file
18
tests/bootstrap.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
190
tests/run.php
190
tests/run.php
|
|
@ -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);
|
||||
89
tests/unit/NordApiTest.php
Normal file
89
tests/unit/NordApiTest.php
Normal 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]']);
|
||||
}
|
||||
}
|
||||
61
tests/unit/StoreAndInspectorTest.php
Normal file
61
tests/unit/StoreAndInspectorTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
52
tests/unit/SystemRunnerAndHealthTest.php
Normal file
52
tests/unit/SystemRunnerAndHealthTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue