Files
computer-playbook/roles/web-app-matomo/files/playwright/_shared.js
kevinveenbirkenbachandClaude Opus 5 4ce588fea1 test(lint): forbid a JavaScript default for an unset environment variable
`process.env.X || <value>` is a second source for something the deployment
already renders, and the two drift without a sound. playwright.config.js read
`process.env.PLAYWRIGHT_ACTION_TIMEOUT || 30_000` while nothing ever set that
variable, so every run used the unscaled 30 s and no failure said so.

Normalising an absent variable stays allowed, because it substitutes nothing:
`|| ""`, `|| '[]'`, `|| false`, `|| 0` and falling through to another
`process.env` entry only let the caller test absence itself.

The six sites the rule found:

  n8n and flowise built a Postgres port from `|| '5432'` while env.j2 renders it
  from `lookup('database', application_id, 'port')`. Node throws
  ERR_SOCKET_BAD_PORT on the resulting NaN, so an unfed port now names itself
  instead of connecting somewhere plausible.

  The bluesky broker did the same with BROKER_PORT, which compose.yml.j2 renders
  unconditionally. Its INSECURE_TLS default was `"false"`, which is what `""`
  already evaluates to.

  matomo defaulted MATOMO_TRACKING_SCOPE to "sub"; the only comparison is against
  "root", so `""` takes the identical branch.

  moodle and simpleicons defaulted a name their playwright.env.j2 always renders.
  simpleicons already asserts the value is truthy; moodle now fails on the scope
  it cannot find rather than on a name nobody configured.

Four values the code itself owns keep a default under `// nocheck: env-default`:
PDS_INTERNAL_URL (which falls through to requireEnv, not to a literal),
HANDOFF_COOKIE_NAME, HANDOFF_COOKIE_MAX_AGE and PASSWORD_SALT_HASH_ROUNDS. No
role renders any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 12:07:57 +02:00

146 lines
5.0 KiB
JavaScript

// Shared Matomo Playwright scaffolding: env vars, the per-test setup hook, the
// consumer-role list and the host / base-domain matchers. Every *.spec.js in
// this directory requires this module so each test file applies the same
// env-presence guard and reuses the same helpers and admin-login flow.
const { expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, gotoOnion, installCspViolationObserver, normalizeBaseUrl } = require("./personas");
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME);
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD);
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN);
const matomoApiToken = decodeDotenvQuotedValue(process.env.MATOMO_API_TOKEN);
const matomoTrackingScope = (process.env.MATOMO_TRACKING_SCOPE || "").trim().toLowerCase();
const matomoCanonicalDomain = (() => {
try {
return new URL(appBaseUrl).hostname;
} catch {
return "";
}
})();
// Emitted at deploy time by templates/playwright.env.j2 via the
// roles_with_service('matomo') Ansible filter: one entry per role declared as a
// matomo consumer in its meta/services.yml.
const matomoTargetRoles = (() => {
const raw = process.env.MATOMO_TARGET_ROLES_JSON || "[]";
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
})();
function attachDiagnostics(page) {
const consoleErrors = [];
const pageErrors = [];
const cspRelated = [];
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
if (/content security policy|csp/i.test(message.text())) {
cspRelated.push({ source: "console", text: message.text() });
}
});
page.on("pageerror", (error) => {
const text = String(error);
pageErrors.push(text);
if (/content security policy|csp/i.test(text)) {
cspRelated.push({ source: "pageerror", text });
}
});
return { consoleErrors, pageErrors, cspRelated };
}
// alias_urls / main_url may be a full URL or a bare host; normalise both to the hostname before matching
function hostOf(value) {
const s = String(value || "")
.trim()
.toLowerCase();
if (!s) return "";
try {
return new URL(s.includes("://") ? s : `https://${s}`).hostname;
} catch {
return s.replace(/^[a-z][a-z0-9+.-]*:\/\//, "").split("/")[0];
}
}
// MUST mirror sys-front-inj-matomo matomo_site_domain in root scope: one shared site per registrable domain, not per subdomain
function baseDomainOf(host) {
return String(host || "")
.toLowerCase()
.replace(/^(?:.*\.)?(.+\..+)$/, "$1");
}
// Tracking-site needle for a consumer host: full subdomain in 'sub' scope (one site per subdomain), registrable base in 'root' scope (one shared site)
function siteNeedleFor(host) {
return matomoTrackingScope === "root" ? baseDomainOf(host) : hostOf(host);
}
async function setupMatomoPage(page) {
await page.setViewportSize({ width: 1440, height: 1100 });
expect(appBaseUrl, "APP_BASE_URL must be set in the Playwright env file").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set in the Playwright env file").toBeTruthy();
expect(canonicalDomain, "CANONICAL_DOMAIN must be set in the Playwright env file").toBeTruthy();
await page.context().clearCookies();
await installCspViolationObserver(page);
}
async function loginAsAdmin(page) {
await gotoOnion(page, `${appBaseUrl}/index.php?module=Login`);
const usernameField = page.locator("input#login_form_login, input[name='form_login']").first();
const passwordField = page.locator("input#login_form_password, input[name='form_password']").first();
const submitButton = page
.locator("input#login_form_submit, button#login_form_submit, button[type='submit'], input[type='submit']")
.first();
await expect(usernameField, "Expected Matomo login form username field").toBeVisible({ timeout: resolveTimeout(60_000) });
await usernameField.fill(adminUsername);
await passwordField.fill(adminPassword);
await submitButton.click();
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: "Expected Matomo login to leave the Login module",
})
.not.toContain("module=Login");
}
module.exports = {
appBaseUrl,
oidcIssuerUrl,
adminUsername,
adminPassword,
biberUsername,
biberPassword,
canonicalDomain,
matomoApiToken,
matomoTrackingScope,
matomoCanonicalDomain,
matomoTargetRoles,
attachDiagnostics,
hostOf,
baseDomainOf,
siteNeedleFor,
setupMatomoPage,
loginAsAdmin,
};