Harden NordVPN reconciliation and WebGUI testing

This commit is contained in:
ms2k 2026-08-07 02:30:26 +09:00
commit b4a30ca190
11 changed files with 777 additions and 97 deletions

View file

@ -2,23 +2,26 @@
These Playwright tests use a disposable OPNsense deployment. They create and delete only entries containing the synthetic `UI_TEST_NETWORK` alias and require `OPNSENSE_UI_TEST_ALLOW_MUTATION=1`.
Start a QEMU fixture after building a package. The launcher needs a compatible OPNsense base image and transfer disk, neither of which is stored in this repository.
Run a QEMU fixture after building a package. The runner generates a disposable root WebGUI password, stores it only in `tests/ui/.runtime/credentials.env`, and removes it with the QEMU overlay when it exits. It needs a compatible OPNsense base image and transfer disk, neither of which is stored in this repository.
```sh
NORDVPN_PACKAGE_PATH=/path/to/os-nordvpn.pkg \
OPNSENSE_VM_IMAGE=/path/to/opnsense.qcow2 \
OPNSENSE_TRANSFER_IMAGE=/path/to/transfer.img \
tests/ui/start-opnsense.sh
tests/ui/run-opnsense.sh
```
In another terminal, run the browser suite against the disposable WebGUI:
The runner retains a failed VM for inspection. Stop it with:
```sh
OPNSENSE_UI_BASE_URL=https://127.0.0.1:18443 \
OPNSENSE_UI_USERNAME=root \
OPNSENSE_UI_PASSWORD=... \
OPNSENSE_UI_TEST_ALLOW_MUTATION=1 \
sh -c 'cd tests/ui && pnpm install --frozen-lockfile && pnpm test'
tests/ui/stop-opnsense.sh
```
To keep the VM running for interactive inspection, use `start-opnsense.sh` instead, then load its ignored credential file before running the suite:
```sh
. tests/ui/.runtime/credentials.env
pnpm --dir tests/ui test
```
The suite mocks only the countries API response with synthetic values. It covers populated country options, failure-policy labels, native alias selectors, Add/Edit/Clone state restoration, and the selector menu/footer layout.

View file

@ -1,11 +1,43 @@
<?php
require_once '/usr/local/etc/inc/config.inc';
require_once '/usr/local/etc/inc/auth.inc';
use OPNsense\Core\Config;
use OPNsense\Firewall\Alias;
$password = trim((string)file_get_contents('/mnt/ui-password'));
if (!preg_match('/^[a-f0-9]{64}$/', $password)) {
throw new RuntimeException('The UI test password is invalid.');
}
$root =& getUserEntry('root');
if ($root === false) {
throw new RuntimeException('The root user is unavailable.');
}
local_user_set_password($root, $password);
local_user_set($root, true);
write_config('Set disposable WebGUI test password');
$users = Config::getInstance()->object()->system->user;
foreach ($users as $user) {
if ((string)$user->name === 'root' && password_verify($password, (string)$user->password)) {
break;
}
}
if (!isset($user) || (string)$user->name !== 'root' || !password_verify($password, (string)$user->password)) {
throw new RuntimeException('The disposable WebGUI password was not saved.');
}
exec('/usr/local/sbin/configctl webgui restart', $output, $status);
if ($status !== 0) {
throw new RuntimeException('The WebGUI did not restart.');
}
$aliases = new Alias();
foreach ($aliases->aliases->alias->iterateItems() as $alias) {
if ((string)$alias->name === 'UI_TEST_NETWORK') {
return;
}
}
$alias = $aliases->aliases->alias->add();
$alias->setNodes([
'enabled' => '1',

75
tests/ui/run-opnsense.sh Executable file
View file

@ -0,0 +1,75 @@
#!/bin/sh
set -eu
umask 077
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
runtime_dir="$script_dir/.runtime"
launcher_file="$runtime_dir/launcher.pid"
mkdir -p "$runtime_dir"
if [ -r "$launcher_file" ]; then
read -r existing_launcher < "$launcher_file"
case "$existing_launcher" in
*[!0-9]* | '') ;;
*)
if kill -0 "$existing_launcher" 2>/dev/null; then
echo "An OPNsense UI fixture is already running." >&2
exit 1
fi
;;
esac
fi
cleanup()
{
status=$?
set +e
if [ "$status" -ne 0 ] && [ "${OPNSENSE_UI_KEEP_VM_ON_FAILURE:-1}" = '1' ]; then
printf '%s\n' "OPNSENSE_UI_VM_RETAINED=$runtime_dir"
trap - EXIT
exit "$status"
fi
[ -n "${launcher_pid:-}" ] && kill "$launcher_pid" 2>/dev/null
[ -n "${launcher_pid:-}" ] && wait "$launcher_pid" 2>/dev/null
rm -f "$runtime_dir/launcher.log"
}
trap cleanup EXIT INT TERM
"$script_dir/start-opnsense.sh" > "$runtime_dir/launcher.log" 2>&1 &
launcher_pid=$!
attempts=240
while [ ! -f "$runtime_dir/ready" ]; do
if ! kill -0 "$launcher_pid" 2>/dev/null; then
cat "$runtime_dir/launcher.log" >&2
exit 1
fi
attempts=$((attempts - 1))
[ "$attempts" -gt 0 ] || {
cat "$runtime_dir/launcher.log" >&2
exit 1
}
sleep 1
done
set -a
. "$runtime_dir/credentials.env"
set +a
attempts=60
while ! curl --fail --insecure --silent --connect-timeout 2 "$OPNSENSE_UI_BASE_URL/" > /dev/null; do
if ! kill -0 "$launcher_pid" 2>/dev/null; then
cat "$runtime_dir/launcher.log" >&2
exit 1
fi
attempts=$((attempts - 1))
[ "$attempts" -gt 0 ] || {
cat "$runtime_dir/launcher.log" >&2
exit 1
}
sleep 1
done
pnpm --dir "$script_dir" install --frozen-lockfile
pnpm --dir "$script_dir" test

View file

@ -3,6 +3,18 @@ const { test, expect } = require("@playwright/test");
const username = process.env.OPNSENSE_UI_USERNAME;
const password = process.env.OPNSENSE_UI_PASSWORD;
async function closeDialog(dialog) {
const hidden = dialog.evaluate(
(element) =>
new Promise((resolve) => {
window.jQuery(element).one("hidden.bs.modal", resolve);
}),
);
await dialog.getByRole("button", { name: "Close" }).click();
await hidden;
await expect(dialog).toBeHidden();
}
if (!username || !password) {
throw new Error(
"Set OPNSENSE_UI_USERNAME and OPNSENSE_UI_PASSWORD for the disposable OPNsense deployment.",
@ -57,21 +69,12 @@ test("renders and preserves NordVPN enrollment controls", async ({ page }) => {
await page.locator("#usernamefld").fill(username);
await page.locator("#passwordfld").fill(password);
await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator("#usernamefld")).toBeHidden();
await page.goto("/ui/nordvpn");
await expect(
page.getByRole("heading", { name: "NordVPN egress enrollment" }),
).toBeVisible();
const accessToken = page.locator("#accessToken");
const bootstrap = page.getByRole("button", { name: "Bootstrap NordLynx" });
await accessToken.fill("synthetic-token");
await bootstrap.click();
await bootstrapStarted;
await expect(accessToken).toHaveValue("");
await expect(bootstrap).toBeDisabled();
finishBootstrap();
await expect(bootstrap).toBeEnabled();
const countries = page.locator('[id="settings.general.countries"]');
await expect(countries.locator('option[value="XA"]')).toHaveText(
"Synthetic A",
@ -150,8 +153,7 @@ test("renders and preserves NordVPN enrollment controls", async ({ page }) => {
await expect(
dialog.locator('select[for="enrollment.destination"]'),
).toHaveValues(["any"]);
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await closeDialog(dialog);
await row.getByRole("button", { name: "Clone" }).click();
await expect(dialog).toBeVisible();
@ -161,8 +163,7 @@ test("renders and preserves NordVPN enrollment controls", async ({ page }) => {
await expect(
dialog.locator('select[for="enrollment.destination"]'),
).toHaveValues(["any"]);
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await closeDialog(dialog);
const beforeDelete = await testRows.count();
await row.getByRole("button", { name: "Delete" }).click();
@ -171,5 +172,16 @@ test("renders and preserves NordVPN enrollment controls", async ({ page }) => {
await removal.getByRole("button", { name: "Yes" }).click();
await expect(testRows).toHaveCount(beforeDelete - 1);
const accessToken = page.locator("#accessToken");
const bootstrap = page.getByRole("button", { name: "Bootstrap NordLynx" });
await accessToken.fill("synthetic-token");
await bootstrap.click();
await bootstrapStarted;
await expect(accessToken).toHaveValue("");
await expect(bootstrap).toBeDisabled();
finishBootstrap();
await expect(bootstrap).toBeEnabled();
await expect(page.locator("#bootstrapMsg")).toHaveText("Bootstrap started");
expect(errors).toEqual([]);
});

View file

@ -10,24 +10,52 @@ umask 077
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
runtime_dir="$script_dir/.runtime"
ui_port=${OPNSENSE_UI_PORT:-18443}
credentials_file="$runtime_dir/credentials.env"
launcher_file="$runtime_dir/launcher.pid"
test -r "$NORDVPN_PACKAGE_PATH"
test -r "$OPNSENSE_VM_IMAGE"
test -r "$OPNSENSE_TRANSFER_IMAGE"
mkdir -p "$runtime_dir"
if [ -r "$launcher_file" ]; then
read -r previous_launcher < "$launcher_file"
case "$previous_launcher" in
*[!0-9]* | '') ;;
*)
if kill -0 "$previous_launcher" 2>/dev/null; then
echo "An OPNsense UI fixture is already running." >&2
exit 1
fi
;;
esac
fi
rm -f "$runtime_dir/opnsense.qcow2" "$runtime_dir/serial.in" "$runtime_dir/serial.out" \
"$runtime_dir/serial.log" "$runtime_dir/transfer.img"
"$runtime_dir/serial.log" "$runtime_dir/transfer.img" "$runtime_dir/ready" \
"$credentials_file" "$runtime_dir/ui-password"
password=$(openssl rand -hex 32)
printf '%s\n' "$password" > "$runtime_dir/ui-password"
printf '%s\n' "OPNSENSE_UI_BASE_URL=https://127.0.0.1:$ui_port" > "$credentials_file"
printf '%s\n' 'OPNSENSE_UI_USERNAME=root' >> "$credentials_file"
printf '%s\n' "OPNSENSE_UI_PASSWORD=$password" >> "$credentials_file"
printf '%s\n' 'OPNSENSE_UI_TEST_ALLOW_MUTATION=1' >> "$credentials_file"
chmod 600 "$credentials_file" "$runtime_dir/ui-password"
printf '%s\n' "$$" > "$launcher_file"
mkfifo "$runtime_dir/serial.in" "$runtime_dir/serial.out"
qemu-img create -q -f qcow2 -F qcow2 -b "$OPNSENSE_VM_IMAGE" "$runtime_dir/opnsense.qcow2"
cp "$OPNSENSE_TRANSFER_IMAGE" "$runtime_dir/transfer.img"
mcopy -o -i "$runtime_dir/transfer.img" "$NORDVPN_PACKAGE_PATH" ::os-nordvpn.pkg
mcopy -o -i "$runtime_dir/transfer.img" "$script_dir/guest-setup.php" ::guest-setup.php
mcopy -o -i "$runtime_dir/transfer.img" "$runtime_dir/ui-password" ::ui-password
cleanup()
{
set +e
[ -n "${qemu_pid:-}" ] && kill "$qemu_pid" 2>/dev/null
[ -n "${serial_log_pid:-}" ] && kill "$serial_log_pid" 2>/dev/null
rm -f "$runtime_dir/credentials.env" "$runtime_dir/ui-password" "$runtime_dir/ready" \
"$runtime_dir/transfer.img" "$runtime_dir/opnsense.qcow2" "$launcher_file" \
"$runtime_dir/serial.in" "$runtime_dir/serial.out" "$runtime_dir/serial.log"
}
trap cleanup EXIT INT TERM
@ -46,6 +74,7 @@ qemu-system-x86_64 \
qemu_pid=$!
cat "$runtime_dir/serial.out" >> "$runtime_dir/serial.log" &
serial_log_pid=$!
wait_for()
{
@ -77,7 +106,8 @@ send "mount -uw /\n"
sleep 2
send "sh /etc/rc\n"
wait_for 'root@OPNsense:/ #' 300
send "mount -o ro -t msdosfs /dev/vtbd1 /mnt && pkg add -f /mnt/os-nordvpn.pkg && php /mnt/guest-setup.php && umount /mnt && pfctl -d && echo UI_VM_READY\n"
send "mount -o ro -t msdosfs /dev/vtbd1 /mnt && pkg add -f /mnt/os-nordvpn.pkg && php /mnt/guest-setup.php && umount /mnt && (pfctl -d >/dev/null 2>&1 || true) && echo UI_VM_READY\n"
wait_for 'UI_VM_READY' 120
printf '%s\n' "OPNSENSE_UI_BASE_URL=https://127.0.0.1:$ui_port"
touch "$runtime_dir/ready"
printf '%s\n' "OPNSENSE_UI_CREDENTIALS_FILE=$credentials_file"
wait "$qemu_pid"

23
tests/ui/stop-opnsense.sh Executable file
View file

@ -0,0 +1,23 @@
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
pid_file="$script_dir/.runtime/launcher.pid"
if [ ! -r "$pid_file" ]; then
rm -f "$script_dir/.runtime/launcher.log"
exit 0
fi
read -r pid < "$pid_file"
case "$pid" in
'' | *[!0-9]*) exit 1 ;;
esac
command=$(ps -p "$pid" -o command= 2>/dev/null || true)
case "$command" in
*"$script_dir/start-opnsense.sh"*)
kill "$pid"
rm -f "$script_dir/.runtime/launcher.log"
;;
*) exit 1 ;;
esac

View 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);
}
}

View file

@ -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]']);
}
}

View file

@ -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';

View file

@ -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);
}

View file

@ -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");