Files
computer-playbook/tests/unit/javascript/roles/web-app-keycloak/files/logout-panel.test.js
kevinveenbirkenbachandClaude Opus 5 6fee7ca136 feat(quality): parse every shipped language and run its unit suite
Four parse checks join the `lint-*` family, each using the language's own
parser rather than a reimplementation: `php -l`, `ruby -c`, `sqlfluff parse`,
and stylelint with an empty rule set (it raises CssSyntaxError whatever the
rules say, so the check stays syntax-only and grows no style opinion). Three
unit runners join `test-unit`: node:test, PHPUnit, minitest -- the first and
third ship with their runtime, so only PHPUnit adds a dependency.

Provisioning is best effort and the lint script decides whether it can run. A
machine without PHP must not fail the gate, but it must not look like success
either: the skip names how many files went unchecked. The first version of
these scripts read `git ls-files`, which inside the compose container returns
nothing (git refuses the bind-mounted repo as "dubious ownership") and reported
"No SQL files found." over 52 files -- green, and a lie. They use `find` now,
as the surrounding lint scripts already did.

sqlfluff needed two corrections before it parsed anything real. `%(name)s` is a
psycopg parameter marker, not SQL, so the placeholder templater is configured
rather than the files exempted; and the dialect follows each role's declared
engine (`meta/services.yml`), which is postgres for six roles and mysql for
svc-db-mariadb and pixelfed.

Recipes stay in scripts, not in the Makefile: the targets delegate, matching
every other target in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:10 +02:00

217 lines
6.6 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const PROJECT_ROOT = path.resolve(__dirname, "../../../../../..");
const PANEL = path.join(
PROJECT_ROOT,
"roles/web-app-keycloak/files/javascript/logout-panel.js",
);
const CATALOGUE = path.join(
PROJECT_ROOT,
"roles/web-app-keycloak/files/logout_i18n.yml",
);
const ORIGIN = "https://logout.example.test";
const DOMAINS = ["https://shop.example.test", "https://cloud.example.test"];
/**
* Read the catalogue without a YAML dependency.
*
* The file is generated, flat and two levels deep, so a line reader is enough
* and keeps this suite free of a parser it would otherwise need for one file.
*/
function readCatalogue() {
const out = {};
let current = null;
for (const line of fs.readFileSync(CATALOGUE, "utf8").split("\n")) {
const top = line.match(/^([a-z]{2}):\s*$/);
if (top) {
current = {};
out[top[1]] = current;
continue;
}
const entry = line.match(/^ {2}([a-z_]+):\s*(.*)$/);
if (entry && current) {
let value = entry[2].trim();
if (
(value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))
) {
value = value.slice(1, -1).replace(/''/g, "'");
}
current[entry[1]] = value;
}
}
return out;
}
function element() {
const node = {
children: [],
style: { cssText: "" },
attrs: {},
id: "",
title: "",
href: "",
setAttribute(key, value) {
this.attrs[key] = value;
},
appendChild(child) {
this.children.push(child);
return child;
},
};
let text = "";
Object.defineProperty(node, "textContent", {
get: () => text,
set(value) {
text = value;
node.children.length = 0;
},
enumerable: true,
});
return node;
}
/** Load the panel against a stubbed page and return handles for assertions. */
function mount({ lang = "en", hostname = "auth.example.test", path: pathname = "/realms/x/protocol/openid-connect/logout" } = {}) {
const body = element();
const listeners = {};
const timers = {};
let next = 0;
global.window = {
__INFINITO_LOGOUT__: { origin: ORIGIN, i18n: readCatalogue() },
location: { pathname, hostname },
addEventListener(type, fn) {
listeners[type] = listeners[type] || [];
if (!listeners[type].includes(fn)) listeners[type].push(fn);
},
removeEventListener(type, fn) {
listeners[type] = (listeners[type] || []).filter((f) => f !== fn);
},
};
global.document = {
readyState: "complete",
createElement: element,
querySelector: () => null,
getElementById: () => null,
documentElement: { lang },
body,
addEventListener() {},
};
Object.defineProperty(globalThis, "navigator", {
value: { language: "en" },
configurable: true,
writable: true,
});
global.setTimeout = (fn, ms) => {
next += 1;
timers[next] = { fn, ms };
return next;
};
global.clearTimeout = (id) => {
if (id && timers[id]) timers[id].cleared = true;
};
// eslint-disable-next-line no-eval
eval(fs.readFileSync(PANEL, "utf8"));
const post = (payload) =>
(listeners.message || []).forEach((fn) =>
fn({ origin: ORIGIN, data: { source: "universal-logout", ...payload } }),
);
const panel = () => body.children[0];
return {
post,
panel,
status: () => panel().children[0].textContent,
hint: () => panel().children[1].textContent,
counter: () => panel().children[2].textContent,
rows: () => panel().children[3].children,
armed: () => (listeners.beforeunload || []).length,
live: (ms) =>
Object.values(timers).filter((t) => t.ms === ms && !t.cleared),
};
}
test("the catalogue reader sees every language", () => {
const catalogue = readCatalogue();
assert.equal(Object.keys(catalogue).length, 30);
assert.equal(catalogue.de.dir, "ltr");
assert.equal(catalogue.ar.dir, "rtl");
});
test("a clean sweep ends by telling the visitor they may leave", () => {
const page = mount();
page.post({ type: "start", domains: DOMAINS });
assert.match(page.status(), /Signing you out/);
assert.equal(page.armed(), 1);
page.post({ type: "host", host: DOMAINS[0], ok: true });
page.post({ type: "host", host: DOMAINS[1], ok: true });
page.post({ type: "done", total: 2, failed: 0 });
assert.match(page.status(), /Signed out everywhere/);
assert.match(page.hint(), /You can close this page now/);
assert.equal(page.counter(), "2 of 2 services signed out");
assert.equal(page.armed(), 0, "the guard is released once the sweep is done");
});
test("a failing host keeps a way out on screen", () => {
const page = mount();
page.post({ type: "start", domains: DOMAINS });
page.post({ type: "host", host: DOMAINS[1], ok: false });
const right = page.rows()[1].children[1];
assert.match(right.children[0].textContent, /failed/);
assert.equal(right.children[1].href, `${DOMAINS[1]}/logout?manual=1`);
});
test("the panel speaks the language Keycloak chose", () => {
const page = mount({ lang: "de" });
page.post({ type: "start", domains: DOMAINS });
assert.match(page.status(), /Sie werden von Ihren Diensten abgemeldet/);
page.post({ type: "host", host: DOMAINS[0], ok: true });
assert.equal(page.counter(), "1 von 2 Diensten abgemeldet");
});
test("right-to-left languages flip the panel", () => {
const page = mount({ lang: "ar" });
page.post({ type: "start", domains: DOMAINS });
assert.equal(page.panel().attrs.dir, "rtl");
});
test("an unknown language falls back to English", () => {
const page = mount({ lang: "klingon" });
page.post({ type: "start", domains: DOMAINS });
assert.match(page.status(), /Signing you out/);
});
test("onion sessions are told to expect a longer wait", () => {
const page = mount({ hostname: "abcdefghij.onion" });
page.post({ type: "start", domains: DOMAINS });
assert.match(page.hint(), /Over Tor this can take a while/);
});
test("a sweep that stalls says so instead of spinning forever", () => {
const page = mount();
page.post({ type: "start", domains: DOMAINS });
page.post({ type: "host", host: DOMAINS[0], ok: true });
const [sweep] = page.live(60000);
assert.ok(sweep, "the stall timer is armed on start");
sweep.fn();
assert.match(page.status(), /Could not confirm every sign-out/);
assert.doesNotMatch(page.status(), /Signed out everywhere/);
assert.equal(page.armed(), 0, "the guard fails open");
});
test("the panel stays off every other Keycloak page", () => {
const page = mount({ path: "/realms/x/account" });
assert.equal(page.armed(), 0);
});