2026-08-07 01:07:02 +09:00
|
|
|
const { test, expect } = require("@playwright/test");
|
2026-08-07 00:29:07 +09:00
|
|
|
|
|
|
|
|
const username = process.env.OPNSENSE_UI_USERNAME;
|
|
|
|
|
const password = process.env.OPNSENSE_UI_PASSWORD;
|
|
|
|
|
|
2026-08-07 02:30:26 +09:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 00:29:07 +09:00
|
|
|
if (!username || !password) {
|
2026-08-07 01:07:02 +09:00
|
|
|
throw new Error(
|
|
|
|
|
"Set OPNSENSE_UI_USERNAME and OPNSENSE_UI_PASSWORD for the disposable OPNsense deployment.",
|
|
|
|
|
);
|
2026-08-07 00:29:07 +09:00
|
|
|
}
|
|
|
|
|
|
2026-08-07 01:07:02 +09:00
|
|
|
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.",
|
|
|
|
|
);
|
2026-08-07 00:29:07 +09:00
|
|
|
}
|
|
|
|
|
|
2026-08-07 01:07:02 +09:00
|
|
|
test("renders and preserves NordVPN enrollment controls", async ({ page }) => {
|
2026-08-07 00:29:07 +09:00
|
|
|
const errors = [];
|
2026-08-07 01:07:02 +09:00
|
|
|
page.on("console", (message) => {
|
|
|
|
|
if (message.type() === "error") errors.push(message.text());
|
2026-08-07 00:29:07 +09:00
|
|
|
});
|
2026-08-07 01:07:02 +09:00
|
|
|
page.on("pageerror", (error) => errors.push(error.stack || error.message));
|
|
|
|
|
page.on("response", (response) => {
|
|
|
|
|
if (response.status() >= 400)
|
|
|
|
|
errors.push(`${response.status()} ${response.url()}`);
|
2026-08-07 00:29:07 +09:00
|
|
|
});
|
2026-08-07 01:07:02 +09:00
|
|
|
await page.route("**/api/nordvpn/service/countries", async (route) => {
|
2026-08-07 00:29:07 +09:00
|
|
|
await route.fulfill({
|
2026-08-07 01:07:02 +09:00
|
|
|
contentType: "application/json",
|
2026-08-07 00:29:07 +09:00
|
|
|
body: JSON.stringify({
|
2026-08-07 01:07:02 +09:00
|
|
|
status: "ok",
|
2026-08-07 00:29:07 +09:00
|
|
|
countries: [
|
2026-08-07 01:07:02 +09:00
|
|
|
{ code: "XA", name: "Synthetic A" },
|
|
|
|
|
{ code: "XB", name: "Synthetic B" },
|
2026-08-07 00:29:07 +09:00
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-07 01:07:02 +09:00
|
|
|
let startBootstrap;
|
|
|
|
|
const bootstrapStarted = new Promise((resolve) => {
|
|
|
|
|
startBootstrap = resolve;
|
|
|
|
|
});
|
|
|
|
|
let finishBootstrap;
|
|
|
|
|
await page.route("**/api/nordvpn/service/bootstrap", async (route) => {
|
|
|
|
|
startBootstrap();
|
|
|
|
|
await new Promise((resolve) => {
|
|
|
|
|
finishBootstrap = resolve;
|
|
|
|
|
});
|
|
|
|
|
await route.fulfill({
|
|
|
|
|
contentType: "application/json",
|
|
|
|
|
body: JSON.stringify({ status: "ok", tunnel: {} }),
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-07 00:29:07 +09:00
|
|
|
|
2026-08-07 01:07:02 +09:00
|
|
|
await page.goto("/");
|
|
|
|
|
await page.locator("#usernamefld").fill(username);
|
|
|
|
|
await page.locator("#passwordfld").fill(password);
|
2026-08-07 04:07:29 +09:00
|
|
|
await Promise.all([
|
|
|
|
|
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
|
|
|
|
page.getByRole("button", { name: "Login" }).click(),
|
|
|
|
|
]);
|
2026-08-07 02:30:26 +09:00
|
|
|
await expect(page.locator("#usernamefld")).toBeHidden();
|
2026-08-07 01:07:02 +09:00
|
|
|
await page.goto("/ui/nordvpn");
|
|
|
|
|
await expect(
|
|
|
|
|
page.getByRole("heading", { name: "NordVPN egress enrollment" }),
|
|
|
|
|
).toBeVisible();
|
|
|
|
|
|
2026-08-07 00:29:07 +09:00
|
|
|
const countries = page.locator('[id="settings.general.countries"]');
|
2026-08-07 01:07:02 +09:00
|
|
|
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();
|
2026-08-07 00:29:07 +09:00
|
|
|
|
|
|
|
|
const failurePolicy = page.locator('[id="settings.general.failure_policy"]');
|
2026-08-07 01:07:02 +09:00
|
|
|
await expect(failurePolicy.locator('option[value="fail_closed"]')).toHaveText(
|
|
|
|
|
"Block traffic",
|
|
|
|
|
);
|
|
|
|
|
await expect(failurePolicy.locator('option[value="fail_open"]')).toHaveText(
|
|
|
|
|
"Use direct egress",
|
|
|
|
|
);
|
2026-08-07 00:29:07 +09:00
|
|
|
|
2026-08-07 01:07:02 +09:00
|
|
|
const grid = page.getByRole("grid");
|
|
|
|
|
const testRows = grid.getByRole("row").filter({ hasText: "UI_TEST_NETWORK" });
|
2026-08-07 00:34:48 +09:00
|
|
|
while (await testRows.count()) {
|
|
|
|
|
const before = await testRows.count();
|
2026-08-07 01:07:02 +09:00
|
|
|
await testRows.first().getByRole("button", { name: "Delete" }).click();
|
|
|
|
|
const removal = page.getByRole("dialog", { name: "Remove" });
|
|
|
|
|
await removal.getByRole("button", { name: "Yes" }).click();
|
2026-08-07 00:34:48 +09:00
|
|
|
await expect(testRows).toHaveCount(before - 1);
|
2026-08-07 00:29:07 +09:00
|
|
|
}
|
|
|
|
|
|
2026-08-07 01:07:02 +09:00
|
|
|
await page.getByRole("button", { name: "Add", exact: true }).click();
|
|
|
|
|
const dialog = page.locator(".modal:visible");
|
2026-08-07 00:29:07 +09:00
|
|
|
await expect(dialog).toBeVisible();
|
2026-08-07 01:07:02 +09:00
|
|
|
await dialog.locator('[id="enrollment.interfaces"]').selectOption("lan");
|
2026-08-07 00:29:07 +09:00
|
|
|
|
|
|
|
|
const source = dialog.locator('select[for="enrollment.source"]');
|
|
|
|
|
const destination = dialog.locator('select[for="enrollment.destination"]');
|
|
|
|
|
await expect(source).toBeVisible();
|
|
|
|
|
await expect(destination).toBeVisible();
|
2026-08-07 01:07:02 +09:00
|
|
|
await expect(source.locator('option[value="UI_TEST_NETWORK"]')).toHaveText(
|
|
|
|
|
"UI_TEST_NETWORK",
|
|
|
|
|
);
|
2026-08-07 04:07:29 +09:00
|
|
|
await expect(
|
|
|
|
|
destination.locator('option[value="UI_TEST_NETWORK_TWO"]'),
|
|
|
|
|
).toHaveText("UI_TEST_NETWORK_TWO");
|
2026-08-07 00:29:07 +09:00
|
|
|
|
2026-08-07 01:07:02 +09:00
|
|
|
const destinationPicker = destination.locator("xpath=..");
|
|
|
|
|
const destinationToggle = destinationPicker.locator("button.dropdown-toggle");
|
2026-08-07 00:29:07 +09:00
|
|
|
await destinationToggle.click();
|
2026-08-07 01:07:02 +09:00
|
|
|
const destinationMenu = destinationPicker
|
|
|
|
|
.locator(".dropdown-menu.open")
|
|
|
|
|
.first();
|
2026-08-07 00:29:07 +09:00
|
|
|
await expect(destinationMenu).toBeVisible();
|
|
|
|
|
const menuBox = await destinationMenu.boundingBox();
|
2026-08-07 01:07:02 +09:00
|
|
|
const footerBox = await dialog.locator(".modal-footer").boundingBox();
|
2026-08-07 00:29:07 +09:00
|
|
|
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(footerBox.y);
|
|
|
|
|
await destinationToggle.click();
|
|
|
|
|
|
2026-08-07 01:07:02 +09:00
|
|
|
await source.selectOption("UI_TEST_NETWORK");
|
2026-08-07 04:07:29 +09:00
|
|
|
await destination.selectOption([
|
|
|
|
|
"UI_TEST_NETWORK",
|
|
|
|
|
"UI_TEST_NETWORK_TWO",
|
|
|
|
|
]);
|
|
|
|
|
await dialog.locator('[id="enrollment.destination_not"]').check();
|
|
|
|
|
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
|
|
|
|
await expect(dialog).toBeVisible();
|
|
|
|
|
await expect(dialog).toContainText(
|
|
|
|
|
"Inverting destinations is only allowed for single targets to avoid mis-interpretations",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await dialog.locator('[id="enrollment.destination_not"]').uncheck();
|
2026-08-07 01:07:02 +09:00
|
|
|
await destination.selectOption("any");
|
|
|
|
|
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
2026-08-07 00:29:07 +09:00
|
|
|
await expect(dialog).toBeHidden();
|
|
|
|
|
await page.reload();
|
2026-08-07 01:07:02 +09:00
|
|
|
await expect(
|
|
|
|
|
page.getByRole("heading", { name: "NordVPN egress enrollment" }),
|
|
|
|
|
).toBeVisible();
|
2026-08-07 00:29:07 +09:00
|
|
|
|
2026-08-07 01:07:02 +09:00
|
|
|
const row = page
|
|
|
|
|
.getByRole("row")
|
|
|
|
|
.filter({ hasText: "LAN" })
|
|
|
|
|
.filter({ hasText: "UI_TEST_NETWORK" })
|
|
|
|
|
.first();
|
2026-08-07 00:29:07 +09:00
|
|
|
await expect(row).toBeVisible();
|
|
|
|
|
|
2026-08-07 01:07:02 +09:00
|
|
|
await row.getByRole("button", { name: "Edit" }).click();
|
2026-08-07 00:29:07 +09:00
|
|
|
await expect(dialog).toBeVisible();
|
2026-08-07 01:07:02 +09:00
|
|
|
await expect(dialog.locator('select[for="enrollment.source"]')).toHaveValues([
|
|
|
|
|
"UI_TEST_NETWORK",
|
|
|
|
|
]);
|
|
|
|
|
await expect(
|
|
|
|
|
dialog.locator('select[for="enrollment.destination"]'),
|
|
|
|
|
).toHaveValues(["any"]);
|
2026-08-07 02:30:26 +09:00
|
|
|
await closeDialog(dialog);
|
2026-08-07 00:29:07 +09:00
|
|
|
|
2026-08-07 01:07:02 +09:00
|
|
|
await row.getByRole("button", { name: "Clone" }).click();
|
2026-08-07 00:29:07 +09:00
|
|
|
await expect(dialog).toBeVisible();
|
2026-08-07 01:07:02 +09:00
|
|
|
await expect(dialog.locator('select[for="enrollment.source"]')).toHaveValues([
|
|
|
|
|
"UI_TEST_NETWORK",
|
|
|
|
|
]);
|
|
|
|
|
await expect(
|
|
|
|
|
dialog.locator('select[for="enrollment.destination"]'),
|
|
|
|
|
).toHaveValues(["any"]);
|
2026-08-07 02:30:26 +09:00
|
|
|
await closeDialog(dialog);
|
2026-08-07 00:29:07 +09:00
|
|
|
|
2026-08-07 00:34:48 +09:00
|
|
|
const beforeDelete = await testRows.count();
|
2026-08-07 01:07:02 +09:00
|
|
|
await row.getByRole("button", { name: "Delete" }).click();
|
|
|
|
|
const removal = page.getByRole("dialog", { name: "Remove" });
|
2026-08-07 00:29:07 +09:00
|
|
|
await expect(removal).toBeVisible();
|
2026-08-07 01:07:02 +09:00
|
|
|
await removal.getByRole("button", { name: "Yes" }).click();
|
2026-08-07 00:34:48 +09:00
|
|
|
await expect(testRows).toHaveCount(beforeDelete - 1);
|
2026-08-07 00:29:07 +09:00
|
|
|
|
2026-08-07 02:30:26 +09:00
|
|
|
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");
|
|
|
|
|
|
2026-08-07 00:29:07 +09:00
|
|
|
expect(errors).toEqual([]);
|
|
|
|
|
});
|