Files
OPNexus/scripts/verify_i18n.mjs
T
Julian Boldt e788afab2f
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release v1.14.0
Diagnose von der Firewall aus sowie VXLAN und GIF. Gegen die OPNsense-Core-Quellen
geprüft und live auf den Test-Firewalls getestet, siehe Handbuch „Erweiterung 1.14.0“:

- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung und DNS-Abfrage, ausgeführt von der
  ausgewählten Firewall. Nur für Admins, strenge Eingabeprüfung, jeder Lauf im Audit-Log,
  Ping-Jobs werden immer wieder aufgeräumt, der Banner der Gegenstelle wird nicht weitergegeben.
  Packet-Capture gibt es bewusst nicht (Mitschnitte enthalten fremde Nutzdaten).
- **Netzwerk › Geräte:** VXLAN und GIF-Tunnel (anlegen, ändern, löschen) über dieselbe abgesicherte
  Engine wie die übrigen Geräte.
- **Fehlerkorrektur:** Auf den Host-System-Seiten war „Übersicht › Hosts“ markiert und der Seitentitel
  falsch, weil `/hostsystem` mit `/hosts` beginnt.
2026-10-09 22:49:10 +02:00

153 lines
7.9 KiB
JavaScript

// Browser verification for the DE/EN language switch. Logs in, switches to English
// through the real settings page, then walks every read-only page (and the primary
// "new" dialogs) and lists any visible text that still looks German.
//
// Safety: only GET requests reach the backend, plus the login and the preferences
// PUT. Every other write is answered locally, so no firewall is ever changed.
// Requires OPNEXUS_TEST_PASSWORD (and optionally OPNEXUS_TEST_USER / OPNEXUS_BASE_URL).
// Deliberately skips /updates: opening that page starts update checks on the firewalls.
// Text that comes from live firewall data (alias/rule names) can legitimately be German and is
// reported as GERMAN too — judge those lines by eye.
import fs from "node:fs";
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "/root/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs");
const base = process.env.OPNEXUS_BASE_URL || "http://localhost:3100";
const password = process.env.OPNEXUS_TEST_PASSWORD;
if (!password) throw new Error("Set OPNEXUS_TEST_PASSWORD to run browser verification.");
const outDir = process.env.OUT_DIR || "/tmp/opnexus-i18n-verification";
fs.mkdirSync(outDir, { recursive: true });
const GERMAN = /[äöüÄÖÜß]|\b(Keine?|Noch|Fehler|werden|wird|nicht|für|und|oder|mit|von|auf|nach|Regel|Regeln|Neue[rsn]?|Löschen|Bearbeiten|Speichern|Abbrechen|Erneut|Zurück|Weiter|Lädt|Benutzer|Passwort|Einstellungen|Gruppe|Gruppen|Zertifikate?|Sicherung|Wartung|Anmeldung|Übersicht|Hinzufügen|Aktion|Ziel|Quelle|verwaltet|geladen)\b/;
const browser = await chromium.launch({ args: ["--no-sandbox"] });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
page.setDefaultTimeout(30000);
const browserErrors = [];
page.on("pageerror", (error) => browserErrors.push(`pageerror: ${error.message}`));
// The 404 probe for a non-existent host is intentional; its failed-resource line is not a page fault.
page.on("console", (message) => { if (message.type() === "error" && !/status of 404/.test(message.text())) browserErrors.push(`console: ${message.text()}`); });
const blockedWrites = [];
await page.route("**/api/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const method = request.method();
const allowed = method === "GET" || method === "HEAD" || method === "OPTIONS" || path === "/api/auth/login" || (path === "/api/settings" && method === "PUT");
if (allowed) return route.continue();
blockedWrites.push(`${method} ${path}`);
return route.fulfill({ status: 200, contentType: "application/json", body: "{}", headers: { "access-control-allow-origin": base, "access-control-allow-credentials": "true" } });
});
async function login() {
await page.goto(`${base}/login`);
await page.locator('input[type="text"],input:not([type])').first().fill(process.env.OPNEXUS_TEST_USER || "admin");
await page.locator('input[type="password"]').fill(password);
await page.locator('button[type="submit"]').click();
await page.waitForFunction(() => !location.pathname.startsWith("/login"));
}
async function setLanguage(code) {
await page.goto(`${base}/settings`);
await page.locator('input[name="language"]').nth(code === "en" ? 1 : 0).check();
await page.waitForTimeout(800);
}
async function visibleText() {
return page.evaluate(() => document.body.innerText);
}
function scan(text) {
return text.split("\n").map((line) => line.trim()).filter((line) => line && GERMAN.test(line));
}
const findings = [];
async function inspect(name, { dialog } = {}) {
await page.waitForTimeout(1800);
const hits = scan(await visibleText());
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1);
findings.push({ name, hits, overflow });
if (dialog) {
const button = page.getByRole("button", { name: dialog }).first();
if (await button.count()) {
await button.click();
await page.waitForTimeout(600);
findings.push({ name: `${name} · dialog`, hits: scan(await visibleText()), overflow: false });
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
}
}
}
let originalLanguage = "de";
try {
await login();
originalLanguage = (await page.evaluate(() => localStorage.getItem("opnexus-language"))) || "de";
await setLanguage("en");
const lang = await page.evaluate(() => document.documentElement.lang);
if (lang !== "en") throw new Error(`<html lang> is ${lang}, expected en`);
await page.reload();
await page.waitForTimeout(1500);
const persisted = await page.evaluate(() => document.documentElement.lang);
if (persisted !== "en") throw new Error("English did not persist across reload");
const hostId = await page.evaluate(async () => {
const api = `${location.protocol}//${location.hostname}:8100/api/hosts`;
const rows = await (await fetch(api, { credentials: "include" })).json();
return rows[0]?.id ?? null;
});
const pages = [
["/dashboard"], ["/hosts", /^add host/i], ["/cluster"], ["/rules", /^new rule/i], ["/policy-hierarchy", /^new group/i],
["/network/interfaces"], ["/network/vlans"], ["/network/vips"], ["/network/gateways"], ["/network/routes"], ["/network/dns"], ["/network/dhcp"], ["/network/devices"], ["/network/compare"], ["/hostsystem/tunables"], ["/hostsystem/cron"], ["/hostsystem/syslog"], ["/hostsystem/snapshots"], ["/hostsystem/diagnostics"],
["/groups", /^new group/i], ["/aliases", /^new alias/i], ["/nat", /^new rule/i], ["/ipsec", /^new tunnel/i],
["/openvpn", /^new instance/i], ["/wireguard", /^new tunnel/i], ["/certificates", /^new certificate/i], ["/logs"], ["/audit"],
["/users", /^new user/i], ["/settings"], ["/about"],
];
if (hostId) pages.splice(3, 0, [`/hosts/${hostId}`]);
for (const [path, dialog] of pages) {
await page.goto(`${base}${path}`);
await inspect(path, { dialog });
}
await page.goto(`${base}/hosts/00000000-0000-4000-8000-000000000000`);
await page.waitForTimeout(1500);
const notFound = await visibleText();
if (!/Host not found/.test(notFound)) throw new Error(`English error text expected, got: ${notFound.slice(0, 200)}`);
await page.setViewportSize({ width: 375, height: 800 });
for (const path of ["/dashboard", "/rules", "/settings"]) {
await page.goto(`${base}${path}`);
await page.waitForTimeout(1500);
findings.push({ name: `${path} @375`, hits: [], overflow: await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1) });
await page.screenshot({ path: `${outDir}/en-${path.slice(1)}-375.png` });
}
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto(`${base}/dashboard`);
await page.waitForTimeout(1500);
await page.screenshot({ path: `${outDir}/en-dashboard-1440.png` });
await setLanguage("de");
await page.goto(`${base}/rules`);
await page.waitForTimeout(1500);
const germanBody = await visibleText();
if (!/Firewall-Regeln/.test(germanBody)) throw new Error("German rules page did not render German title");
if ((await page.evaluate(() => document.documentElement.lang)) !== "de") throw new Error("lang did not return to de");
await page.screenshot({ path: `${outDir}/de-rules-1440.png` });
} finally {
try { await setLanguage(originalLanguage === "en" ? "en" : "de"); } catch { /* best effort */ }
await browser.close();
}
let failed = false;
for (const { name, hits, overflow } of findings) {
const flag = hits.length ? "GERMAN" : overflow ? "OVERFLOW" : "ok";
console.log(`${flag.padEnd(8)} ${name}`);
for (const hit of hits.slice(0, 25)) console.log(` ${hit.slice(0, 160)}`);
if (hits.length || overflow) failed = true;
}
console.log(`blocked writes: ${blockedWrites.length ? blockedWrites.join(", ") : "none"}`);
console.log(`browser errors: ${browserErrors.length ? "\n" + [...new Set(browserErrors)].join("\n") : "none"}`);
process.exit(failed || browserErrors.length ? 1 : 0);