Harden NordVPN reconciliation and WebGUI testing
This commit is contained in:
parent
a115d2a012
commit
b4a30ca190
6 changed files with 777 additions and 97 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
75
tests/ui/run-opnsense.sh
Executable 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
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
23
tests/ui/stop-opnsense.sh
Executable 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
|
||||
Loading…
Reference in a new issue