Add OPNsense WebGUI regression tests
This commit is contained in:
parent
98f9993362
commit
1ef0b80415
7 changed files with 323 additions and 0 deletions
24
tests/ui/README.md
Normal file
24
tests/ui/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# OPNsense WebGUI integration tests
|
||||
|
||||
These Playwright tests use a disposable OPNsense deployment. They create and delete NordVPN enrollment records 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.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
In another terminal, run the browser suite against the disposable WebGUI:
|
||||
|
||||
```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'
|
||||
```
|
||||
|
||||
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.
|
||||
18
tests/ui/guest-setup.php
Normal file
18
tests/ui/guest-setup.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
require_once '/usr/local/etc/inc/config.inc';
|
||||
|
||||
use OPNsense\Core\Config;
|
||||
use OPNsense\Firewall\Alias;
|
||||
|
||||
$aliases = new Alias();
|
||||
$alias = $aliases->aliases->alias->add();
|
||||
$alias->setNodes([
|
||||
'enabled' => '1',
|
||||
'name' => 'UI_TEST_NETWORK',
|
||||
'type' => 'network',
|
||||
'proto' => 'IPv4',
|
||||
'content' => '192.0.2.0/24',
|
||||
]);
|
||||
$aliases->serializeToConfig();
|
||||
Config::getInstance()->save(['description' => 'Add synthetic UI test alias']);
|
||||
10
tests/ui/package.json
Normal file
10
tests/ui/package.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "opnsense-nordvpn-ui-tests",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.61.1"
|
||||
}
|
||||
}
|
||||
18
tests/ui/playwright.config.js
Normal file
18
tests/ui/playwright.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
const baseURL = process.env.OPNSENSE_UI_BASE_URL;
|
||||
|
||||
if (!baseURL) {
|
||||
throw new Error('Set OPNSENSE_UI_BASE_URL to the disposable OPNsense WebGUI URL.');
|
||||
}
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './specs',
|
||||
timeout: 30000,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
});
|
||||
51
tests/ui/pnpm-lock.yaml
generated
Normal file
51
tests/ui/pnpm-lock.yaml
generated
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: 1.61.1
|
||||
version: 1.61.1
|
||||
|
||||
packages:
|
||||
|
||||
'@playwright/[email protected]':
|
||||
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@playwright/[email protected]':
|
||||
dependencies:
|
||||
playwright: 1.61.1
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
playwright-core: 1.61.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
115
tests/ui/specs/enrollment.spec.js
Normal file
115
tests/ui/specs/enrollment.spec.js
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const username = process.env.OPNSENSE_UI_USERNAME;
|
||||
const password = process.env.OPNSENSE_UI_PASSWORD;
|
||||
|
||||
if (!username || !password) {
|
||||
throw new Error('Set OPNSENSE_UI_USERNAME and OPNSENSE_UI_PASSWORD for the disposable OPNsense deployment.');
|
||||
}
|
||||
|
||||
if (process.env.OPNSENSE_UI_TEST_ALLOW_MUTATION !== '1') {
|
||||
throw new Error('Set OPNSENSE_UI_TEST_ALLOW_MUTATION=1 to allow enrollment changes in the disposable deployment.');
|
||||
}
|
||||
|
||||
test('renders and preserves NordVPN enrollment controls', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => errors.push(error.stack || error.message));
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
await page.route('**/api/nordvpn/service/countries', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
status: 'ok',
|
||||
countries: [
|
||||
{ code: 'XA', name: 'Synthetic A' },
|
||||
{ code: 'XB', name: 'Synthetic B' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.locator('#usernamefld').fill(username);
|
||||
await page.locator('#passwordfld').fill(password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.goto('/ui/nordvpn');
|
||||
await expect(page.getByRole('heading', { name: 'NordVPN egress enrollment' })).toBeVisible();
|
||||
|
||||
const countries = page.locator('[id="settings.general.countries"]');
|
||||
await expect(countries.locator('option[value="XA"]')).toHaveText('Synthetic A');
|
||||
await expect(countries.locator('option[value="XB"]')).toHaveText('Synthetic B');
|
||||
await expect(countries.locator('xpath=..').locator('button.dropdown-toggle')).toBeVisible();
|
||||
|
||||
const failurePolicy = page.locator('[id="settings.general.failure_policy"]');
|
||||
await expect(failurePolicy.locator('option[value="fail_closed"]')).toHaveText('Block traffic');
|
||||
await expect(failurePolicy.locator('option[value="fail_open"]')).toHaveText('Use direct egress');
|
||||
|
||||
const grid = page.getByRole('grid');
|
||||
const deleteButtons = grid.getByRole('button', { name: 'Delete', exact: true });
|
||||
while (await deleteButtons.count()) {
|
||||
const before = await deleteButtons.count();
|
||||
await deleteButtons.first().click();
|
||||
const removal = page.getByRole('dialog', { name: 'Remove' });
|
||||
await removal.getByRole('button', { name: 'Yes' }).click();
|
||||
await expect(deleteButtons).toHaveCount(before - 1);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
const dialog = page.locator('.modal:visible');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.locator('[id="enrollment.interfaces"]').selectOption('lan');
|
||||
|
||||
const source = dialog.locator('select[for="enrollment.source"]');
|
||||
const destination = dialog.locator('select[for="enrollment.destination"]');
|
||||
await expect(source).toBeVisible();
|
||||
await expect(destination).toBeVisible();
|
||||
await expect(source.locator('option[value="UI_TEST_NETWORK"]')).toHaveText('UI_TEST_NETWORK');
|
||||
|
||||
const destinationPicker = destination.locator('xpath=..');
|
||||
const destinationToggle = destinationPicker.locator('button.dropdown-toggle');
|
||||
await destinationToggle.click();
|
||||
const destinationMenu = destinationPicker.locator('.dropdown-menu.open').first();
|
||||
await expect(destinationMenu).toBeVisible();
|
||||
const menuBox = await destinationMenu.boundingBox();
|
||||
const footerBox = await dialog.locator('.modal-footer').boundingBox();
|
||||
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(footerBox.y);
|
||||
await destinationToggle.click();
|
||||
|
||||
await source.selectOption('UI_TEST_NETWORK');
|
||||
await destination.selectOption('any');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'NordVPN egress enrollment' })).toBeVisible();
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: 'LAN' }).filter({ hasText: 'UI_TEST_NETWORK' }).first();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await row.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator('select[for="enrollment.source"]')).toHaveValues(['UI_TEST_NETWORK']);
|
||||
await expect(dialog.locator('select[for="enrollment.destination"]')).toHaveValues(['any']);
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
await row.getByRole('button', { name: 'Clone' }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator('select[for="enrollment.source"]')).toHaveValues(['UI_TEST_NETWORK']);
|
||||
await expect(dialog.locator('select[for="enrollment.destination"]')).toHaveValues(['any']);
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
const beforeDelete = await deleteButtons.count();
|
||||
await row.getByRole('button', { name: 'Delete' }).click();
|
||||
const removal = page.getByRole('dialog', { name: 'Remove' });
|
||||
await expect(removal).toBeVisible();
|
||||
await removal.getByRole('button', { name: 'Yes' }).click();
|
||||
await expect(deleteButtons).toHaveCount(beforeDelete - 1);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
83
tests/ui/start-opnsense.sh
Executable file
83
tests/ui/start-opnsense.sh
Executable file
|
|
@ -0,0 +1,83 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
umask 077
|
||||
|
||||
: "${NORDVPN_PACKAGE_PATH:?Set NORDVPN_PACKAGE_PATH to the package under test.}"
|
||||
: "${OPNSENSE_VM_IMAGE:?Set OPNSENSE_VM_IMAGE to the OPNsense qcow2 base image.}"
|
||||
: "${OPNSENSE_TRANSFER_IMAGE:?Set OPNSENSE_TRANSFER_IMAGE to the OPNsense transfer disk.}"
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
runtime_dir="$script_dir/.runtime"
|
||||
ui_port=${OPNSENSE_UI_PORT:-18443}
|
||||
|
||||
test -r "$NORDVPN_PACKAGE_PATH"
|
||||
test -r "$OPNSENSE_VM_IMAGE"
|
||||
test -r "$OPNSENSE_TRANSFER_IMAGE"
|
||||
|
||||
mkdir -p "$runtime_dir"
|
||||
rm -f "$runtime_dir/opnsense.qcow2" "$runtime_dir/serial.in" "$runtime_dir/serial.out" \
|
||||
"$runtime_dir/serial.log" "$runtime_dir/transfer.img"
|
||||
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
|
||||
|
||||
cleanup()
|
||||
{
|
||||
set +e
|
||||
[ -n "${qemu_pid:-}" ] && kill "$qemu_pid" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
qemu-system-x86_64 \
|
||||
-machine q35,accel=tcg \
|
||||
-smp 2 \
|
||||
-m 2048 \
|
||||
-display none \
|
||||
-monitor none \
|
||||
-drive file="$runtime_dir/opnsense.qcow2",if=virtio,format=qcow2 \
|
||||
-drive file="$runtime_dir/transfer.img",if=virtio,format=raw,readonly=on \
|
||||
-netdev user,id=wan,net=192.168.1.0/24,dhcpstart=192.168.1.100,host=192.168.1.254,hostfwd=tcp:127.0.0.1:"$ui_port"-192.168.1.1:443 \
|
||||
-device virtio-net-pci,netdev=wan \
|
||||
-chardev pipe,id=console,path="$runtime_dir/serial" \
|
||||
-serial chardev:console &
|
||||
qemu_pid=$!
|
||||
|
||||
cat "$runtime_dir/serial.out" >> "$runtime_dir/serial.log" &
|
||||
|
||||
wait_for()
|
||||
{
|
||||
pattern=$1
|
||||
attempts=${2:-300}
|
||||
while ! grep -Fq "$pattern" "$runtime_dir/serial.log" 2>/dev/null; do
|
||||
attempts=$((attempts - 1))
|
||||
[ "$attempts" -gt 0 ] || {
|
||||
echo "timed out waiting for: $pattern" >&2
|
||||
exit 1
|
||||
}
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
send()
|
||||
{
|
||||
printf '%b' "$1" > "$runtime_dir/serial.in"
|
||||
}
|
||||
|
||||
wait_for 'Autoboot in' 180
|
||||
send ' '
|
||||
sleep 1
|
||||
send '2\n'
|
||||
wait_for 'Enter full pathname of shell' 180
|
||||
send '\n'
|
||||
wait_for '# ' 60
|
||||
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"
|
||||
wait_for 'UI_VM_READY' 120
|
||||
printf '%s\n' "OPNSENSE_UI_BASE_URL=https://127.0.0.1:$ui_port"
|
||||
wait "$qemu_pid"
|
||||
Loading…
Reference in a new issue