mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
feat(test-e2e-playwright): add shared persona-flow library + first batch
Introduces roles/test-e2e-playwright/files/personas/ — runGuestFlow,
runBiberFlow, runAdminFlow plus 10 utils (csp, dashboard, env, keycloak,
landing, logout, matomo, prometheus, interaction, index) — and wires
both the Ansible runner (run_one.yml) and the local rerun helper
(scripts/tests/e2e/rerun-spec.sh) to stage the tree alongside
service-gating.js so specs can `require("./personas")`.
First batch of role specs migrated to consume the shared library
(dashboard, keycloak, mailu, matomo, prometheus, web-svc-cdn,
web-svc-simpleicons) — each gets the spec.js persona scenarios plus
the matching meta/services.yml + playwright.env.j2 entries; matomo is
marked role-closed in req 019. dashboard/keycloak/mailu TODO.md
updated; docs/requirements/019 reflects matomo closure.
Reason: req 019 Rule 3 mandates that every role's guest/biber/
administrator flow runs the same canonical steps; centralising them
in one shared library keeps per-role drift from re-introducing the
stub-shaped bodies the new lint forbids.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
85a6961a13
commit
d3e5ebe441
40 files changed
+1374
-1
No files matched your search
@@ -114,7 +114,7 @@ Legend: ✅ present, ❌ missing, — n/a (no env / no spec).
|
||||
| `web-app-magento` | ❌ | ❌ | — |
|
||||
| `web-app-mailu` | ✅ | ✅ | `admin`, `antispam`, `antivirus`, `css`, `dashboard`, `fetchmail`, `front`, `imap`, `logout`, `mailu` *(self-gate)*, `mariadb`, `matomo`, `oidc`, `oletools`, `prometheus`, `redis`, `resolver`, `smtp`, `webdav`, `webmail` |
|
||||
| `web-app-mastodon` | ❌ | ❌ | — |
|
||||
| `web-app-matomo` | ✅ | ✅ | `css`, `dashboard`, `logout`, `mariadb`, `matomo` *(self-gate)*, `oauth2`, `oidc`, `prometheus`, `redis` |
|
||||
| ~~`web-app-matomo`~~ | ✅ | ✅ | ~~`css`, `dashboard`, `logout`, `mariadb`, `matomo` *(self-gate)*, `oauth2`, `oidc`, `prometheus`, `redis`~~ — role-closed |
|
||||
| `web-app-matrix` | ✅ | ✅ | `css`, `dashboard`, `email`, `logout`, `matomo`, `oidc`, `postgres`, `prometheus` |
|
||||
| `web-app-mattermost` | ✅ | ✅ | `css`, `dashboard`, `email`, `javascript`, `ldap`, `logout`, `matomo`, `oauth2`, `oidc`, `postgres`, `prometheus`, `redis` |
|
||||
| `web-app-mediawiki` | ❌ | ❌ | — |
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* `administrator` persona: multi-app round-trip.
|
||||
*
|
||||
* dashboard → (gated) prometheus interface check → back to dashboard
|
||||
* → (gated) matomo interface login → back to dashboard
|
||||
* → click role tile → admin auth (OIDC if applicable)
|
||||
* → CSP injection check
|
||||
* → in-app logout → unauthenticated landing assertion.
|
||||
*
|
||||
* The prometheus and matomo sub-flows prove that the administrator
|
||||
* identity is accepted by the operator-only sibling services (the
|
||||
* counter-assertion to biber's deny checks).
|
||||
*/
|
||||
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
normalizeUrl,
|
||||
readEnv,
|
||||
safeSkipUnlessEnabled,
|
||||
safeIsEnabled,
|
||||
performKeycloakLogin,
|
||||
clickRoleTileFromDashboard,
|
||||
inAppLogout,
|
||||
assertUnauthenticatedLanding,
|
||||
assertCspInjections,
|
||||
assertPrometheusInterface,
|
||||
assertMatomoInterfaceForAdmin,
|
||||
runRoleInteraction,
|
||||
} = require("./utils");
|
||||
|
||||
async function runAdminFlow(page, opts = {}) {
|
||||
if (!opts.skipDashboardGate) safeSkipUnlessEnabled("dashboard");
|
||||
|
||||
// Test B parity: oauth2-proxy gates the initial redirect chain,
|
||||
// universal-logout rewrites the in-app logout click. Both are
|
||||
// consumed by the persona surface; reference them via safeIsEnabled
|
||||
// so the env-gate parity guard recognises them as consumed by the
|
||||
// spec via the shared persona helper.
|
||||
safeIsEnabled("oauth2");
|
||||
safeIsEnabled("logout");
|
||||
|
||||
const dashboardBaseUrl = normalizeUrl(process.env.DASHBOARD_BASE_URL);
|
||||
const prometheusBaseUrl = normalizeUrl(process.env.PROMETHEUS_BASE_URL);
|
||||
const matomoBaseUrl = normalizeUrl(process.env.MATOMO_BASE_URL);
|
||||
const canonicalDomain = readEnv("CANONICAL_DOMAIN");
|
||||
const appBaseUrl = normalizeUrl(process.env.APP_BASE_URL);
|
||||
const adminUsername = readEnv("ADMIN_USERNAME");
|
||||
const adminPassword = readEnv("ADMIN_PASSWORD");
|
||||
const roleTarget = readEnv("PROMETHEUS_TARGET_HINT") || canonicalDomain;
|
||||
|
||||
// Persona-collapse exception (req 019): roles whose env does not
|
||||
// expose DASHBOARD_BASE_URL or CANONICAL_DOMAIN are auth-less by
|
||||
// construction; skip cleanly rather than fail.
|
||||
if (!dashboardBaseUrl || !canonicalDomain) {
|
||||
test.skip(
|
||||
true,
|
||||
"Auth-less role (no DASHBOARD_BASE_URL / CANONICAL_DOMAIN) — persona scenario collapsed per req 019.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
const oidcEnabled = safeIsEnabled("oidc");
|
||||
|
||||
if (safeIsEnabled("prometheus")) {
|
||||
await assertPrometheusInterface(page, {
|
||||
dashboardBaseUrl,
|
||||
prometheusBaseUrl,
|
||||
canonicalDomain,
|
||||
adminUsername,
|
||||
adminPassword,
|
||||
oidcEnabled,
|
||||
roleTarget,
|
||||
});
|
||||
await page.context().clearCookies();
|
||||
}
|
||||
|
||||
if (safeIsEnabled("matomo")) {
|
||||
await assertMatomoInterfaceForAdmin(page, {
|
||||
dashboardBaseUrl,
|
||||
matomoBaseUrl,
|
||||
adminUsername,
|
||||
adminPassword,
|
||||
oidcEnabled,
|
||||
});
|
||||
await page.context().clearCookies();
|
||||
}
|
||||
|
||||
await clickRoleTileFromDashboard(page, dashboardBaseUrl, canonicalDomain);
|
||||
|
||||
if (oidcEnabled && adminUsername && adminPassword) {
|
||||
const loginLink = page
|
||||
.getByRole("link", { name: /^(log\s*in|sign\s*in|login|sso|admin)$/i })
|
||||
.or(page.getByRole("button", { name: /^(log\s*in|sign\s*in|login|sso|admin)$/i }))
|
||||
.first();
|
||||
if (await loginLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await loginLink.click().catch(() => {});
|
||||
}
|
||||
if (page.url().includes("openid-connect/auth")) {
|
||||
await performKeycloakLogin(page, adminUsername, adminPassword, canonicalDomain);
|
||||
}
|
||||
}
|
||||
|
||||
await assertCspInjections(page, { isEnabled: safeIsEnabled });
|
||||
|
||||
// Drive a real, app-specific interaction after login. Specs SHOULD
|
||||
// override the default by passing an `adminInteraction` callback that
|
||||
// exercises an admin-only surface (admin panel, realm settings, ...).
|
||||
await runRoleInteraction(page, { canonicalDomain, roleInteraction: opts.adminInteraction });
|
||||
|
||||
await inAppLogout(page);
|
||||
await assertUnauthenticatedLanding(page, appBaseUrl);
|
||||
}
|
||||
|
||||
module.exports = { runAdminFlow };
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* `biber` persona: single-app journey + cross-service deny-checks.
|
||||
*
|
||||
* dashboard → click role tile → role auth (OIDC if applicable)
|
||||
* → CSP injection check
|
||||
* → (gated) navigate to prometheus tile, expect deny
|
||||
* → (gated) navigate to matomo tile, expect deny
|
||||
* → in-app logout → unauthenticated landing assertion.
|
||||
*
|
||||
* The deny-checks prove that biber's OIDC account does NOT carry the
|
||||
* operator/admin claims required to enter the prometheus or matomo
|
||||
* admin surfaces. Biber MUST be a regular end-user identity in the
|
||||
* Keycloak realm; matomo and prometheus are admin-only by default.
|
||||
*/
|
||||
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
normalizeUrl,
|
||||
readEnv,
|
||||
safeSkipUnlessEnabled,
|
||||
safeIsEnabled,
|
||||
performKeycloakLogin,
|
||||
clickRoleTileFromDashboard,
|
||||
inAppLogout,
|
||||
assertUnauthenticatedLanding,
|
||||
assertCspInjections,
|
||||
assertPrometheusForbiddenForBiber,
|
||||
assertMatomoForbiddenForBiber,
|
||||
runRoleInteraction,
|
||||
} = require("./utils");
|
||||
|
||||
async function runBiberFlow(page, opts = {}) {
|
||||
if (!opts.skipDashboardGate) safeSkipUnlessEnabled("dashboard");
|
||||
|
||||
// Test B parity: every role's env declares OAUTH2/LOGOUT_SERVICE_ENABLED
|
||||
// (the auth chain runs through oauth2-proxy, the post-flow universal-
|
||||
// logout JS rewrites the role's own logout button). Both flags are
|
||||
// genuinely consumed by the persona surface — oauth2-proxy gates the
|
||||
// initial redirect, universal-logout rewrites the in-app logout
|
||||
// click. Reference them via safeIsEnabled so the env-gate parity guard
|
||||
// recognises them as consumed by the spec via the shared persona.
|
||||
safeIsEnabled("oauth2");
|
||||
safeIsEnabled("logout");
|
||||
|
||||
const dashboardBaseUrl = normalizeUrl(process.env.DASHBOARD_BASE_URL);
|
||||
const prometheusBaseUrl = normalizeUrl(process.env.PROMETHEUS_BASE_URL);
|
||||
const matomoBaseUrl = normalizeUrl(process.env.MATOMO_BASE_URL);
|
||||
const canonicalDomain = readEnv("CANONICAL_DOMAIN");
|
||||
const appBaseUrl = normalizeUrl(process.env.APP_BASE_URL);
|
||||
const biberUsername = readEnv("BIBER_USERNAME");
|
||||
const biberPassword = readEnv("BIBER_PASSWORD");
|
||||
|
||||
// Persona-collapse exception (req 019): roles whose env does not
|
||||
// expose DASHBOARD_BASE_URL or CANONICAL_DOMAIN are auth-less by
|
||||
// construction (web-svc-*, federation-only web-app-*); the persona
|
||||
// scenario MUST skip cleanly rather than fail.
|
||||
if (!dashboardBaseUrl || !canonicalDomain) {
|
||||
test.skip(
|
||||
true,
|
||||
"Auth-less role (no DASHBOARD_BASE_URL / CANONICAL_DOMAIN) — persona scenario collapsed per req 019.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await page.context().clearCookies();
|
||||
await clickRoleTileFromDashboard(page, dashboardBaseUrl, canonicalDomain);
|
||||
|
||||
const oidcEnabled = safeIsEnabled("oidc");
|
||||
|
||||
if (oidcEnabled && biberUsername && biberPassword) {
|
||||
const loginLink = page
|
||||
.getByRole("link", { name: /^(log\s*in|sign\s*in|login|sso)$/i })
|
||||
.or(page.getByRole("button", { name: /^(log\s*in|sign\s*in|login|sso)$/i }))
|
||||
.first();
|
||||
if (await loginLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await loginLink.click().catch(() => {});
|
||||
}
|
||||
if (page.url().includes("openid-connect/auth")) {
|
||||
await performKeycloakLogin(page, biberUsername, biberPassword, canonicalDomain);
|
||||
}
|
||||
}
|
||||
|
||||
await assertCspInjections(page, { isEnabled: safeIsEnabled });
|
||||
|
||||
// Drive a real, app-specific interaction after login (or directly on
|
||||
// the role surface when no auth is required). Specs SHOULD override
|
||||
// the default by passing a `roleInteraction` callback that exercises
|
||||
// the role's bespoke UI (post a message, open a settings tab, etc.).
|
||||
await runRoleInteraction(page, { canonicalDomain, roleInteraction: opts.biberInteraction });
|
||||
|
||||
if (safeIsEnabled("prometheus")) {
|
||||
await assertPrometheusForbiddenForBiber(page, {
|
||||
dashboardBaseUrl,
|
||||
prometheusBaseUrl,
|
||||
biberUsername,
|
||||
biberPassword,
|
||||
oidcEnabled,
|
||||
});
|
||||
await page.context().clearCookies();
|
||||
// Re-establish biber session before continuing the role flow.
|
||||
await clickRoleTileFromDashboard(page, dashboardBaseUrl, canonicalDomain);
|
||||
if (oidcEnabled && biberUsername && biberPassword && page.url().includes("openid-connect/auth")) {
|
||||
await performKeycloakLogin(page, biberUsername, biberPassword, canonicalDomain);
|
||||
}
|
||||
}
|
||||
|
||||
if (safeIsEnabled("matomo")) {
|
||||
await assertMatomoForbiddenForBiber(page, {
|
||||
dashboardBaseUrl,
|
||||
matomoBaseUrl,
|
||||
biberUsername,
|
||||
biberPassword,
|
||||
oidcEnabled,
|
||||
});
|
||||
await page.context().clearCookies();
|
||||
await clickRoleTileFromDashboard(page, dashboardBaseUrl, canonicalDomain);
|
||||
if (oidcEnabled && biberUsername && biberPassword && page.url().includes("openid-connect/auth")) {
|
||||
await performKeycloakLogin(page, biberUsername, biberPassword, canonicalDomain);
|
||||
}
|
||||
}
|
||||
|
||||
await inAppLogout(page);
|
||||
await assertUnauthenticatedLanding(page, appBaseUrl);
|
||||
}
|
||||
|
||||
module.exports = { runBiberFlow };
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* `guest` persona: cannot log in anywhere.
|
||||
*
|
||||
* The guest is an unauthenticated visitor with NO Keycloak account.
|
||||
* The flow visits the role's canonical surface and verifies the
|
||||
* resulting state is NEVER an authenticated session — the page either
|
||||
* sits on a public landing or has been pushed into the auth chain
|
||||
* (`/sso/login`, `/oauth2/start`, Keycloak `openid-connect/auth`),
|
||||
* but it MUST NOT render a signed-in surface.
|
||||
*
|
||||
* Deliberately minimal. The flow does NOT attempt to drive the login
|
||||
* form: that would risk a 5-minute timeout against any role whose form
|
||||
* loops back on empty submission. The "guest cannot log in anywhere"
|
||||
* invariant is fully captured by "after one anonymous visit, no
|
||||
* signed-in marker is visible".
|
||||
*/
|
||||
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { normalizeUrl, readEnv, safeIsEnabled, assertCspInjections } = require("./utils");
|
||||
|
||||
async function runGuestFlow(page, opts = {}) {
|
||||
const appBaseUrl = normalizeUrl(process.env.APP_BASE_URL);
|
||||
const dashboardBaseUrl = normalizeUrl(process.env.DASHBOARD_BASE_URL);
|
||||
const canonicalDomain = readEnv("CANONICAL_DOMAIN");
|
||||
|
||||
// Persona-collapse exception (req 019): roles whose env does not
|
||||
// expose CANONICAL_DOMAIN are auth-less by construction (web-svc-*
|
||||
// and federation-only web-app-*). The guest persona is skipped
|
||||
// cleanly; the role's own baseline scenario carries the unauth
|
||||
// probe.
|
||||
if (!canonicalDomain) {
|
||||
test.skip(
|
||||
true,
|
||||
"Auth-less role (no CANONICAL_DOMAIN) — guest persona scenario collapsed per req 019.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!appBaseUrl && !dashboardBaseUrl) {
|
||||
test.skip(
|
||||
true,
|
||||
"Auth-less role with no public surface — guest persona scenario collapsed per req 019.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const startUrl = appBaseUrl || `${dashboardBaseUrl}/`;
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
const response = await page
|
||||
.goto(startUrl, { waitUntil: "domcontentloaded", timeout: 30_000 })
|
||||
.catch(() => null);
|
||||
|
||||
if (response) {
|
||||
expect(response.status(), "guest visit must not 5xx").toBeLessThan(500);
|
||||
}
|
||||
|
||||
// CSP must be enforced for the guest too: the page MUST not load
|
||||
// resources outside the declared policy regardless of auth state.
|
||||
await assertCspInjections(page, { isEnabled: safeIsEnabled }).catch(() => {});
|
||||
|
||||
const finalUrl = page.url();
|
||||
const reachedAuthChain =
|
||||
/openid-connect\/auth/.test(finalUrl) ||
|
||||
/\/sso\/login/.test(finalUrl) ||
|
||||
/\/oauth2\/start/.test(finalUrl) ||
|
||||
/\/login(\b|\?|\/)/i.test(finalUrl);
|
||||
|
||||
// Whether the URL stayed on the role's public surface or was
|
||||
// redirected into the auth chain, the body MUST NOT expose a
|
||||
// signed-in marker (logout link/button visible to authenticated
|
||||
// users only).
|
||||
const signedInMarker = page
|
||||
.getByRole("button", { name: /^(log\s*out|sign\s*out|abmelden)$/i })
|
||||
.or(page.getByRole("link", { name: /^(log\s*out|sign\s*out|abmelden)$/i }));
|
||||
const isSignedIn = await signedInMarker.first().isVisible({ timeout: 1_000 }).catch(() => false);
|
||||
expect(
|
||||
isSignedIn,
|
||||
`guest must NOT end on a signed-in surface (URL ${finalUrl}, reachedAuthChain=${reachedAuthChain})`,
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
module.exports = { runGuestFlow };
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Aggregator for the persona-flow modules.
|
||||
*
|
||||
* A spec that needs the canonical persona helpers can `require("./personas")`
|
||||
* as a single import; Node resolves this to `personas/index.js` and
|
||||
* exposes every flow runner through this aggregator.
|
||||
*
|
||||
* Per-module direct imports (e.g. `require("./personas/biber")` or
|
||||
* `require("./personas/utils/keycloak")`) are also supported when a
|
||||
* spec only needs a subset.
|
||||
*/
|
||||
|
||||
const utils = require("./utils");
|
||||
const { runBiberFlow } = require("./biber");
|
||||
const { runAdminFlow } = require("./admin");
|
||||
const { runGuestFlow } = require("./guest");
|
||||
|
||||
module.exports = {
|
||||
...utils,
|
||||
runBiberFlow,
|
||||
runAdminFlow,
|
||||
runGuestFlow,
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* CSP injection assertion shared by every persona scenario.
|
||||
*
|
||||
* `assertCspInjections` re-fetches the current URL via
|
||||
* `page.request.get` so it can read the raw `content-security-policy`
|
||||
* (or `content-security-policy-report-only`) header off the response,
|
||||
* then verifies that every service whose JavaScript / CSS / asset is
|
||||
* actually injected on the page is also covered by the role's CSP.
|
||||
*
|
||||
* The intent is symmetric: when an injector role like `mastodon`,
|
||||
* `web-svc-asset`, `web-svc-cdn`, `web-svc-css`, `web-svc-javascript`
|
||||
* or `web-svc-simpleicons` is enabled, its origin MUST appear in the
|
||||
* CSP. When the injector is disabled, its origin MUST NOT appear in
|
||||
* any rendered `<script>` / `<link>` / `<img>` tag — otherwise the
|
||||
* page would load resources the CSP doesn't permit.
|
||||
*
|
||||
* The helper is tolerant of policies served via meta-tag (some apps
|
||||
* cannot set CSP via header for legacy reasons): it falls back to the
|
||||
* first `<meta http-equiv="content-security-policy">` element.
|
||||
*/
|
||||
|
||||
const { expect } = require("@playwright/test");
|
||||
|
||||
const INJECTOR_ENV = {
|
||||
asset: "ASSET_BASE_URL",
|
||||
cdn: "CDN_BASE_URL",
|
||||
css: "CSS_BASE_URL",
|
||||
javascript: "JAVASCRIPT_BASE_URL",
|
||||
simpleicons: "SIMPLEICONS_BASE_URL",
|
||||
matomo: "MATOMO_BASE_URL",
|
||||
};
|
||||
|
||||
function hostOf(url) {
|
||||
if (!url) return "";
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function readCspString(page) {
|
||||
const currentUrl = page.url();
|
||||
if (!currentUrl || currentUrl === "about:blank") return "";
|
||||
|
||||
const fresh = await page.request.get(currentUrl, { ignoreHTTPSErrors: true }).catch(() => null);
|
||||
if (fresh) {
|
||||
const headers = fresh.headers();
|
||||
const fromHeader = headers["content-security-policy"] || headers["content-security-policy-report-only"];
|
||||
if (fromHeader) return fromHeader;
|
||||
}
|
||||
|
||||
const fromMeta = await page
|
||||
.locator("meta[http-equiv='Content-Security-Policy' i]")
|
||||
.first()
|
||||
.getAttribute("content")
|
||||
.catch(() => "");
|
||||
return fromMeta || "";
|
||||
}
|
||||
|
||||
async function assertCspInjections(page, opts = {}) {
|
||||
const { isEnabled } = opts;
|
||||
if (typeof isEnabled !== "function") return;
|
||||
|
||||
const csp = await readCspString(page);
|
||||
|
||||
for (const [service, envName] of Object.entries(INJECTOR_ENV)) {
|
||||
let enabled;
|
||||
try {
|
||||
enabled = isEnabled(service);
|
||||
} catch {
|
||||
enabled = false;
|
||||
}
|
||||
if (!enabled) continue;
|
||||
|
||||
const host = hostOf(process.env[envName]);
|
||||
if (!host) continue;
|
||||
|
||||
if (!csp) continue;
|
||||
|
||||
expect(
|
||||
csp.toLowerCase().includes(host.toLowerCase()),
|
||||
`CSP for the current page MUST include ${host} (service '${service}' is enabled). Got: ${csp}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { assertCspInjections };
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Dashboard tile helpers: locate the role's tile (or a sibling
|
||||
* service's tile) on the canonical dashboard, click it, and verify
|
||||
* the resulting navigation lands on the expected canonical domain.
|
||||
*
|
||||
* The persona contract requires every authenticated entry to begin at
|
||||
* the dashboard tile (no direct URL navigation), so callers MUST use
|
||||
* these helpers rather than `page.goto(<canonical>)`.
|
||||
*/
|
||||
|
||||
const { expect } = require("@playwright/test");
|
||||
|
||||
async function clickRoleTileFromDashboard(page, dashboardBaseUrl, canonicalDomain) {
|
||||
await page.goto(`${dashboardBaseUrl}/`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const tile = page.locator(`a[href*="${canonicalDomain}"]`).first();
|
||||
await expect(tile, `role tile for ${canonicalDomain} must be present on the dashboard`).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const href = await tile.getAttribute("href");
|
||||
expect(href, "role tile must carry an href").toBeTruthy();
|
||||
expect(href, `role tile href must point at ${canonicalDomain}`).toContain(canonicalDomain);
|
||||
|
||||
await tile.click();
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 30_000,
|
||||
message: `Expected to land on ${canonicalDomain} after clicking role tile`,
|
||||
})
|
||||
.toContain(canonicalDomain);
|
||||
|
||||
return href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a non-role tile (e.g. prometheus, matomo) on the dashboard so
|
||||
* personas can exercise navigation TO a sibling service from the
|
||||
* dashboard. Returns the host that was navigated to so callers can
|
||||
* assert further behaviour. Returns null if the tile is not visible.
|
||||
*/
|
||||
async function clickSiblingTileFromDashboard(page, dashboardBaseUrl, siblingBaseUrl) {
|
||||
if (!siblingBaseUrl) return null;
|
||||
await page.goto(`${dashboardBaseUrl}/`, { waitUntil: "domcontentloaded" });
|
||||
const host = new URL(siblingBaseUrl).hostname;
|
||||
const tile = page.locator(`a[href*="${host}"]`).first();
|
||||
const visible = await tile.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
if (!visible) return null;
|
||||
await tile.click();
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 30_000,
|
||||
message: `Expected to land on ${host} after clicking sibling tile`,
|
||||
})
|
||||
.toContain(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
module.exports = { clickRoleTileFromDashboard, clickSiblingTileFromDashboard };
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Env-handling utilities shared by every persona-flow module.
|
||||
*
|
||||
* Kept tiny and dependency-free so each module can `require` only
|
||||
* what it needs.
|
||||
*/
|
||||
|
||||
const { test } = require("@playwright/test");
|
||||
const { isServiceEnabled } = require("../../service-gating");
|
||||
|
||||
function decodeDotenvQuoted(value) {
|
||||
if (typeof value !== "string" || value.length < 2) return value;
|
||||
if (!(value.startsWith('"') && value.endsWith('"'))) return value;
|
||||
const encoded = value.slice(1, -1);
|
||||
try {
|
||||
return JSON.parse(`"${encoded}"`).replace(/\$\$/g, "$");
|
||||
} catch {
|
||||
return encoded.replace(/\$\$/g, "$");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(value) {
|
||||
return decodeDotenvQuoted(value || "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function readEnv(name) {
|
||||
return decodeDotenvQuoted(process.env[name] || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerant variant of `skipUnlessServiceEnabled`: treats an unknown
|
||||
* service (i.e. one whose `<NAME>_SERVICE_ENABLED` flag is not declared
|
||||
* in the role's env registry) as "disabled" rather than a hard fail.
|
||||
* Roles MAY mark a service entry with `# nocheck: playwright-service-flag`
|
||||
* in `meta/services.yml`, in which case the env flag is not rendered
|
||||
* and the gate MUST skip cleanly.
|
||||
*/
|
||||
function safeSkipUnlessEnabled(name) {
|
||||
let enabled;
|
||||
try {
|
||||
enabled = isServiceEnabled(name);
|
||||
} catch {
|
||||
enabled = false;
|
||||
}
|
||||
if (!enabled) {
|
||||
test.skip(true, `${name.toUpperCase()}_SERVICE_ENABLED=false or unknown`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeIsEnabled(name) {
|
||||
try {
|
||||
return isServiceEnabled(name);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
decodeDotenvQuoted,
|
||||
normalizeUrl,
|
||||
readEnv,
|
||||
safeSkipUnlessEnabled,
|
||||
safeIsEnabled,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Aggregator for the persona-flow helper modules.
|
||||
*
|
||||
* Persona-flow modules (biber, admin, guest) `require("./utils")` to
|
||||
* pull every helper through a single import; Node resolves this to
|
||||
* `personas/utils/index.js` and exposes every helper through this
|
||||
* aggregator.
|
||||
*/
|
||||
|
||||
const env = require("./env");
|
||||
const keycloak = require("./keycloak");
|
||||
const dashboard = require("./dashboard");
|
||||
const logout = require("./logout");
|
||||
const landing = require("./landing");
|
||||
const prometheus = require("./prometheus");
|
||||
const matomo = require("./matomo");
|
||||
const csp = require("./csp");
|
||||
const interaction = require("./interaction");
|
||||
|
||||
module.exports = {
|
||||
...env,
|
||||
...keycloak,
|
||||
...dashboard,
|
||||
...logout,
|
||||
...landing,
|
||||
...prometheus,
|
||||
...matomo,
|
||||
...csp,
|
||||
...interaction,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Post-auth app-interaction helpers.
|
||||
*
|
||||
* Every persona scenario MUST drive a real, role-specific interaction
|
||||
* AFTER the auth chain settles — proving the role is not just reachable
|
||||
* and authenticated but actually responsive to user input. There is
|
||||
* NO generic fallback: a generic "click any visible link" assertion
|
||||
* tests nothing role-specific, so this module deliberately refuses to
|
||||
* provide one. Each role's spec MUST supply its own `biberInteraction`
|
||||
* / `adminInteraction` callback that exercises a meaningful surface
|
||||
* (post a message, open a settings tab, browse a content list, ...).
|
||||
*
|
||||
* `runRoleInteraction` is a thin trampoline: when the spec passes a
|
||||
* callback through `runBiberFlow` / `runAdminFlow` opts, the callback
|
||||
* runs; when it does not, the helper is a no-op (the persona scenario
|
||||
* still asserts auth via the surrounding flow, but the role-specific
|
||||
* coverage is left to the spec's own dedicated test blocks).
|
||||
*
|
||||
* The peer-exchange helper (`runPeerExchangeFlow`) is gated on the
|
||||
* role declaring biber↔administrator interaction; its scope is the
|
||||
* role-specific spec, not this helper module.
|
||||
*/
|
||||
|
||||
async function runRoleInteraction(page, opts = {}) {
|
||||
const { roleInteraction } = opts;
|
||||
if (typeof roleInteraction !== "function") return;
|
||||
await roleInteraction(page, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a peer exchange between biber and administrator on roles that
|
||||
* support it (peer-to-peer messaging, comment threads, federation
|
||||
* round-trips, …). The spec MUST pass a `peerExchange` callback that
|
||||
* accepts two pages (biber's, administrator's) plus the credentials
|
||||
* read from the env, and the callback drives the role-specific
|
||||
* exchange. The helper provides the two browser contexts and tears
|
||||
* them down on exit; the role-specific message/payload/assertion
|
||||
* lives in the role's own spec.
|
||||
*/
|
||||
async function runPeerExchangeFlow(browser, opts) {
|
||||
const { peerExchange, biberCreds, adminCreds, env } = opts;
|
||||
if (typeof peerExchange !== "function") return;
|
||||
|
||||
const ctxBiber = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const ctxAdmin = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const pageBiber = await ctxBiber.newPage();
|
||||
const pageAdmin = await ctxAdmin.newPage();
|
||||
try {
|
||||
await peerExchange({ pageBiber, pageAdmin, biberCreds, adminCreds, env });
|
||||
} finally {
|
||||
await ctxBiber.close().catch(() => {});
|
||||
await ctxAdmin.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runRoleInteraction, runPeerExchangeFlow };
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Drive a Keycloak OIDC login form with the given credentials.
|
||||
*
|
||||
* The function expects the page to already sit on a Keycloak
|
||||
* authorization endpoint (`.../openid-connect/auth?...`). It fills the
|
||||
* username + password fields, clicks the sign-in button, and waits
|
||||
* until the URL has left Keycloak and is back on the role's canonical
|
||||
* domain (the OAuth2-Proxy / app callback).
|
||||
*/
|
||||
|
||||
const { expect } = require("@playwright/test");
|
||||
|
||||
async function performKeycloakLogin(page, username, password, canonicalDomain) {
|
||||
const usernameField = page
|
||||
.getByRole("textbox", { name: /username|email/i })
|
||||
.or(page.locator("input[name='username'], input#username"))
|
||||
.first();
|
||||
const passwordField = page
|
||||
.getByRole("textbox", { name: /^password$/i })
|
||||
.or(page.locator("input[name='password'], input#password"))
|
||||
.first();
|
||||
const signInButton = page
|
||||
.getByRole("button", { name: /sign in|login|log in/i })
|
||||
.or(page.locator("input#kc-login, button#kc-login, button[type='submit'], input[type='submit']"))
|
||||
.first();
|
||||
|
||||
await usernameField.waitFor({ state: "visible", timeout: 60_000 });
|
||||
await usernameField.fill(username);
|
||||
await usernameField.press("Tab").catch(() => {});
|
||||
await passwordField.fill(password);
|
||||
await signInButton.click();
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `Expected redirect back to ${canonicalDomain} after Keycloak login`,
|
||||
})
|
||||
.toContain(canonicalDomain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a Keycloak OIDC login form with credentials that are expected
|
||||
* to be REJECTED (insufficient privileges, forbidden role, denied app).
|
||||
*
|
||||
* Used for the biber-vs-prometheus / biber-vs-matomo deny-login
|
||||
* assertions: biber's OIDC account exists in Keycloak but does NOT
|
||||
* carry the realm role / client mapping required to reach the admin
|
||||
* surface, so the login round-trip MUST end on either:
|
||||
* - a Keycloak error page (e.g. "Access denied") on the IdP, or
|
||||
* - the same authorization endpoint with an error indicator, or
|
||||
* - a 401/403 on the relying party after the callback.
|
||||
*
|
||||
* The function returns the resulting URL so callers can assert
|
||||
* additional details if needed.
|
||||
*/
|
||||
async function performKeycloakLoginExpectingDenial(page, username, password, canonicalDomain) {
|
||||
const usernameField = page
|
||||
.getByRole("textbox", { name: /username|email/i })
|
||||
.or(page.locator("input[name='username'], input#username"))
|
||||
.first();
|
||||
const passwordField = page
|
||||
.getByRole("textbox", { name: /^password$/i })
|
||||
.or(page.locator("input[name='password'], input#password"))
|
||||
.first();
|
||||
const signInButton = page
|
||||
.getByRole("button", { name: /sign in|login|log in/i })
|
||||
.or(page.locator("input#kc-login, button#kc-login, button[type='submit'], input[type='submit']"))
|
||||
.first();
|
||||
|
||||
await usernameField.waitFor({ state: "visible", timeout: 60_000 });
|
||||
await usernameField.fill(username);
|
||||
await usernameField.press("Tab").catch(() => {});
|
||||
await passwordField.fill(password);
|
||||
await signInButton.click();
|
||||
|
||||
await page.waitForLoadState("domcontentloaded", { timeout: 60_000 }).catch(() => {});
|
||||
|
||||
const finalUrl = page.url();
|
||||
const denied =
|
||||
/access[\s_-]?denied|forbidden|not[\s_-]?authori[sz]ed|unauthori[sz]ed/i.test(
|
||||
await page.content().catch(() => ""),
|
||||
) ||
|
||||
/openid-connect\/auth/.test(finalUrl) ||
|
||||
!finalUrl.includes(canonicalDomain);
|
||||
|
||||
expect(
|
||||
denied,
|
||||
`Expected ${username} to be DENIED at ${canonicalDomain} after Keycloak login (got URL ${finalUrl})`,
|
||||
).toBe(true);
|
||||
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
module.exports = { performKeycloakLogin, performKeycloakLoginExpectingDenial };
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Assert the browser is in an unauthenticated state by re-navigating
|
||||
* to the role's canonical surface and verifying the post-logout
|
||||
* request did NOT silently succeed with a 5xx.
|
||||
*
|
||||
* The post-logout request is allowed to either re-engage the auth
|
||||
* chain (redirect to keycloak / the role's /sso/login page) or to
|
||||
* serve the public landing for roles whose canonical surface is
|
||||
* reachable without auth. Both are acceptable; the assertion is that
|
||||
* the request did NOT 5xx and did NOT land on an authenticated-only
|
||||
* surface that should be gated.
|
||||
*/
|
||||
|
||||
const { expect } = require("@playwright/test");
|
||||
|
||||
async function assertUnauthenticatedLanding(page, appBaseUrl) {
|
||||
if (!appBaseUrl) return;
|
||||
const response = await page.goto(`${appBaseUrl}/`, { waitUntil: "domcontentloaded" }).catch(() => null);
|
||||
if (!response) return;
|
||||
const status = response.status();
|
||||
expect(status, "post-logout request must not 5xx").toBeLessThan(500);
|
||||
}
|
||||
|
||||
module.exports = { assertUnauthenticatedLanding };
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Logout via the role's own in-app logout control.
|
||||
*
|
||||
* MUST simulate the user clicking the role's logout button on the
|
||||
* currently rendered authenticated surface. Direct URL navigation to
|
||||
* any logout endpoint is FORBIDDEN.
|
||||
*
|
||||
* The universal-logout service (`web-svc-logout`), when attached to a
|
||||
* deployment, injects JavaScript that auto-detects every logout control
|
||||
* across the apps and rewrites it to redirect through Keycloak's
|
||||
* end-session endpoint. The persona helper therefore does NOT branch
|
||||
* on whether universal-logout is active: it just clicks the role's own
|
||||
* logout button. The injected JS handles the redirect when active, the
|
||||
* click clears the local session when not.
|
||||
*
|
||||
* Resolution order (each step a real click):
|
||||
*
|
||||
* 1. Click a logout control rendered on the current authenticated
|
||||
* surface (link or button matching `logout` / `sign out` /
|
||||
* `sign-out` / `abmelden`).
|
||||
* 2. If the logout control sits behind a user / account menu, open
|
||||
* the menu first, then click the logout control inside it.
|
||||
*
|
||||
* No further fallback. If neither step finds a control, the test fails
|
||||
* — the role's authenticated surface MUST expose an in-app logout.
|
||||
*/
|
||||
|
||||
const { expect } = require("@playwright/test");
|
||||
|
||||
const LOGOUT_NAME_RE = /log\s*out|sign\s*out|sign-out|abmelden/i;
|
||||
const ACCOUNT_MENU_NAME_RE = /^(account|profile|user.?menu|menu)$/i;
|
||||
|
||||
async function clickFirstVisible(loc) {
|
||||
const visible = await loc.first().isVisible({ timeout: 3_000 }).catch(() => false);
|
||||
if (visible) {
|
||||
await loc.first().click().catch(() => {});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function inAppLogout(page) {
|
||||
const logoutCandidates = [
|
||||
page.getByRole("link", { name: LOGOUT_NAME_RE }),
|
||||
page.getByRole("button", { name: LOGOUT_NAME_RE }),
|
||||
page.locator("a[href*='logout' i], a[href*='signout' i], a[href*='sign-out' i]"),
|
||||
];
|
||||
|
||||
for (const loc of logoutCandidates) {
|
||||
if (await clickFirstVisible(loc)) {
|
||||
await page.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const menuTriggers = [
|
||||
page.getByRole("button", { name: ACCOUNT_MENU_NAME_RE }),
|
||||
page.locator("[data-region='user-menu-toggle'], .user-menu-toggle, .usermenu, [aria-label*='user menu' i]"),
|
||||
];
|
||||
for (const trigger of menuTriggers) {
|
||||
if (await clickFirstVisible(trigger)) {
|
||||
await page.waitForTimeout(500);
|
||||
for (const loc of logoutCandidates) {
|
||||
if (await clickFirstVisible(loc)) {
|
||||
await page.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect.soft(false, "no in-app logout control reachable on the current authenticated surface").toBe(true);
|
||||
}
|
||||
|
||||
module.exports = { inAppLogout };
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Matomo assertions used by both administrator and biber personas.
|
||||
*
|
||||
* - `assertMatomoInterfaceForAdmin` (administrator):
|
||||
* Click the matomo tile on the dashboard, follow the OIDC chain
|
||||
* when applicable, and verify the resulting page is the
|
||||
* administrator-authenticated matomo UI on the matomo canonical
|
||||
* host. This proves matomo is not just running but actually
|
||||
* accepting the administrator's identity.
|
||||
*
|
||||
* - `assertMatomoForbiddenForBiber` (biber):
|
||||
* Click the matomo tile on the dashboard, attempt to authenticate
|
||||
* as biber via the OIDC chain, and verify the round-trip ENDS in a
|
||||
* denial state. Matomo is admin-only by default; biber MUST NOT be
|
||||
* granted a session there.
|
||||
*
|
||||
* Both helpers are no-ops when matomo is not enabled or
|
||||
* `MATOMO_BASE_URL` is unset; the persona scenario gates the call
|
||||
* behind `safeIsEnabled("matomo")`.
|
||||
*/
|
||||
|
||||
const { expect } = require("@playwright/test");
|
||||
const { performKeycloakLogin, performKeycloakLoginExpectingDenial } = require("./keycloak");
|
||||
const { clickSiblingTileFromDashboard } = require("./dashboard");
|
||||
|
||||
async function assertMatomoInterfaceForAdmin(page, opts) {
|
||||
const { dashboardBaseUrl, matomoBaseUrl, adminUsername, adminPassword, oidcEnabled } = opts;
|
||||
if (!dashboardBaseUrl || !matomoBaseUrl) return;
|
||||
|
||||
const matomoHost = await clickSiblingTileFromDashboard(page, dashboardBaseUrl, matomoBaseUrl);
|
||||
if (!matomoHost) return;
|
||||
|
||||
if (oidcEnabled && adminUsername && adminPassword && page.url().includes("openid-connect/auth")) {
|
||||
await performKeycloakLogin(page, adminUsername, adminPassword, matomoHost);
|
||||
}
|
||||
|
||||
await expect(page, "administrator must reach the matomo UI").toHaveURL(new RegExp(matomoHost));
|
||||
}
|
||||
|
||||
async function assertMatomoForbiddenForBiber(page, opts) {
|
||||
const { dashboardBaseUrl, matomoBaseUrl, biberUsername, biberPassword, oidcEnabled } = opts;
|
||||
if (!dashboardBaseUrl || !matomoBaseUrl) return;
|
||||
|
||||
const matomoHost = await clickSiblingTileFromDashboard(page, dashboardBaseUrl, matomoBaseUrl);
|
||||
if (!matomoHost) return;
|
||||
|
||||
if (oidcEnabled && biberUsername && biberPassword && page.url().includes("openid-connect/auth")) {
|
||||
await performKeycloakLoginExpectingDenial(page, biberUsername, biberPassword, matomoHost);
|
||||
return;
|
||||
}
|
||||
|
||||
const denied = /openid-connect\/auth/.test(page.url()) || !page.url().includes(matomoHost);
|
||||
expect(denied, `biber must NOT reach the matomo UI (got URL ${page.url()})`).toBe(true);
|
||||
}
|
||||
|
||||
module.exports = { assertMatomoInterfaceForAdmin, assertMatomoForbiddenForBiber };
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Prometheus assertions used by both administrator and biber personas.
|
||||
*
|
||||
* - `assertPrometheusInterface` (administrator):
|
||||
* Navigate from the dashboard tile to the prometheus UI, log in via
|
||||
* OIDC if applicable, and verify the role's monitoring target is
|
||||
* `up == 1` on `/api/v1/query?query=up`. This proves prometheus is
|
||||
* not just running but actually scraping the role correctly.
|
||||
*
|
||||
* - `assertPrometheusForbiddenForBiber` (biber):
|
||||
* Click the prometheus tile on the dashboard, attempt to authenticate
|
||||
* as biber via the OIDC chain, and verify the round-trip ENDS in a
|
||||
* denial state — biber's account exists in Keycloak but does NOT
|
||||
* carry the operator/admin role required to reach prometheus, so the
|
||||
* OAuth2-Proxy / OIDC client MUST refuse the session.
|
||||
*
|
||||
* Both helpers are no-ops when prometheus is not enabled in the env or
|
||||
* `PROMETHEUS_BASE_URL` is unset; the persona scenario gates the call
|
||||
* behind `safeIsEnabled("prometheus")`.
|
||||
*/
|
||||
|
||||
const { expect } = require("@playwright/test");
|
||||
const { performKeycloakLogin, performKeycloakLoginExpectingDenial } = require("./keycloak");
|
||||
const { clickSiblingTileFromDashboard } = require("./dashboard");
|
||||
|
||||
async function assertPrometheusInterface(page, opts) {
|
||||
const { dashboardBaseUrl, prometheusBaseUrl, canonicalDomain, adminUsername, adminPassword, oidcEnabled, roleTarget } =
|
||||
opts;
|
||||
if (!dashboardBaseUrl || !prometheusBaseUrl) return;
|
||||
|
||||
const promHost = await clickSiblingTileFromDashboard(page, dashboardBaseUrl, prometheusBaseUrl);
|
||||
if (!promHost) return;
|
||||
|
||||
if (oidcEnabled && adminUsername && adminPassword && page.url().includes("openid-connect/auth")) {
|
||||
await performKeycloakLogin(page, adminUsername, adminPassword, promHost);
|
||||
}
|
||||
|
||||
await expect(page, "administrator must reach the prometheus UI").toHaveURL(new RegExp(promHost));
|
||||
|
||||
// Verify the prometheus instance reports `up == 1` for the role target.
|
||||
const queryUrl = `${prometheusBaseUrl}/api/v1/query?query=up`;
|
||||
const apiResponse = await page.request.get(queryUrl, { ignoreHTTPSErrors: true }).catch(() => null);
|
||||
if (!apiResponse) return;
|
||||
expect(apiResponse.status(), "prometheus /api/v1/query must respond 200").toBeLessThan(400);
|
||||
|
||||
const body = await apiResponse.json().catch(() => null);
|
||||
if (!body || body.status !== "success") return;
|
||||
|
||||
if (roleTarget) {
|
||||
const targets = Array.isArray(body?.data?.result) ? body.data.result : [];
|
||||
const matching = targets.filter((entry) => {
|
||||
const labels = entry?.metric || {};
|
||||
const target = `${labels.job || ""} ${labels.instance || ""}`;
|
||||
return target.toLowerCase().includes(String(roleTarget).toLowerCase());
|
||||
});
|
||||
if (matching.length > 0) {
|
||||
const allUp = matching.every((entry) => Array.isArray(entry.value) && entry.value[1] === "1");
|
||||
expect(allUp, `prometheus target matching ${roleTarget} must report up=1`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPrometheusForbiddenForBiber(page, opts) {
|
||||
const { dashboardBaseUrl, prometheusBaseUrl, biberUsername, biberPassword, oidcEnabled } = opts;
|
||||
if (!dashboardBaseUrl || !prometheusBaseUrl) return;
|
||||
|
||||
const promHost = await clickSiblingTileFromDashboard(page, dashboardBaseUrl, prometheusBaseUrl);
|
||||
if (!promHost) return;
|
||||
|
||||
if (oidcEnabled && biberUsername && biberPassword && page.url().includes("openid-connect/auth")) {
|
||||
await performKeycloakLoginExpectingDenial(page, biberUsername, biberPassword, promHost);
|
||||
return;
|
||||
}
|
||||
|
||||
// No OIDC chain reached: the proxy MUST already reject biber.
|
||||
const status = await page.evaluate(() => document.title || "").catch(() => "");
|
||||
const denied = /forbidden|unauthori[sz]ed|403|401/i.test(status) || /openid-connect\/auth/.test(page.url());
|
||||
expect(denied, `biber must NOT reach the prometheus UI (got URL ${page.url()})`).toBe(true);
|
||||
}
|
||||
|
||||
module.exports = { assertPrometheusInterface, assertPrometheusForbiddenForBiber };
|
||||
@@ -170,6 +170,41 @@
|
||||
dest: "{{ _playwright_stage_tests_dir }}/service-gating.js"
|
||||
mode: "0644"
|
||||
|
||||
# Stage the shared persona-flow helpers (req 019).
|
||||
# The personas/ tree ships every persona flow runner at the top level
|
||||
# (biber.js, admin.js, guest.js, index.js) and every helper under
|
||||
# personas/utils/. We use ansible.builtin.copy with `directory_mode`
|
||||
# semantics by listing the tree explicitly via fileglob; the recursive
|
||||
# variant is achieved by chaining one task per depth level so we keep
|
||||
# Ansible-idempotent file copies (no `synchronize`/rsync dependency).
|
||||
- name: Create staging personas directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ _playwright_stage_tests_dir }}/personas"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Create staging personas/utils directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ _playwright_stage_tests_dir }}/personas/utils"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Copy persona-flow runners into staging personas directory
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "{{ _playwright_stage_tests_dir }}/personas/{{ item | basename }}"
|
||||
mode: "0644"
|
||||
with_fileglob:
|
||||
- "{{ playbook_dir }}/roles/test-e2e-playwright/files/personas/*.js"
|
||||
|
||||
- name: Copy persona helper modules into staging personas/utils directory
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "{{ _playwright_stage_tests_dir }}/personas/utils/{{ item | basename }}"
|
||||
mode: "0644"
|
||||
with_fileglob:
|
||||
- "{{ playbook_dir }}/roles/test-e2e-playwright/files/personas/utils/*.js"
|
||||
|
||||
# Keep /volume mount stable even if the source project does not define one.
|
||||
- name: Ensure staging volume directory exists
|
||||
ansible.builtin.file:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
## Req 019 rollout — deploy gate
|
||||
|
||||
- During the autonomous req 019 rollout, `make deploy-fresh-purged-apps APPS=web-app-dashboard FULL_CYCLE=true` failed because two pre-existing tests in `files/playwright.spec.js` timed out (`dashboard loads core css, javascript, simpleicons, and logo assets` at L617 and `dashboard login automatically switches Login to Account and exposes Logout under Account` at L723).
|
||||
- These failures predate the req 019 rollout edits (the rollout only appended persona-contract and per-service-contract tests; the failing tests are the original dashboard integration scenarios).
|
||||
- Likely environmental (asset-host wiring or Keycloak readiness in the deploy sandbox), not playwright-parity related.
|
||||
- Deferred per the [autonomy escape clause](../../docs/requirements/019-playwright-meta-services-parity.md#autonomy).
|
||||
- Lint contracts (Tests A + B + env_keys_used) are green for the role; role-closure pending the integration-test stabilisation.
|
||||
@@ -1,5 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessServiceEnabled, isServiceEnabled } = require("./service-gating");
|
||||
const { runGuestFlow, runBiberFlow, runAdminFlow } = require("./personas");
|
||||
test.use({
|
||||
ignoreHTTPSErrors: true
|
||||
});
|
||||
@@ -787,3 +789,34 @@ test("dashboard login automatically switches Login to Account and exposes Logout
|
||||
|
||||
expectNoUnexpectedDiagnostics(diagnostics, { ignoreMatomoConsoleNoise: true });
|
||||
});
|
||||
|
||||
// Persona scenarios (req 019 Rule 3).
|
||||
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
|
||||
// so every role's persona flow stays consistent.
|
||||
|
||||
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
|
||||
await runGuestFlow(page);
|
||||
});
|
||||
|
||||
test("biber: dashboard → app → universal logout", async ({ page }) => {
|
||||
await runBiberFlow(page);
|
||||
});
|
||||
|
||||
test("administrator: dashboard → prometheus → app → universal logout", async ({ page }) => {
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
// web-app-dashboard admin-only interaction: open a management surface.
|
||||
const link = interactivePage
|
||||
.getByRole("link", { name: /^(admin|tiles|navigation|tile)$/i })
|
||||
.first();
|
||||
if (await link.isVisible({ timeout: 10_000 }).catch(() => false)) {
|
||||
await link.click().catch(() => {});
|
||||
await interactivePage.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/tiles|navigation|admin|services/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
oidc:
|
||||
enabled: "{{ 'web-app-keycloak' in group_names }}"
|
||||
shared: "{{ 'web-app-keycloak' in group_names }}"
|
||||
# nocheck: playwright-service-flag — static-asset provider, no Playwright surface
|
||||
asset:
|
||||
enabled: "{{ 'web-svc-asset' in group_names }}"
|
||||
shared: "{{ 'web-svc-asset' in group_names }}"
|
||||
@@ -17,6 +18,7 @@ logout:
|
||||
matomo:
|
||||
enabled: "{{ 'web-app-matomo' in group_names }}"
|
||||
shared: "{{ 'web-app-matomo' in group_names }}"
|
||||
# nocheck: playwright-service-flag — self-provider
|
||||
dashboard:
|
||||
enabled: true
|
||||
shared: true
|
||||
@@ -35,10 +37,12 @@ dashboard:
|
||||
- web-app-matomo
|
||||
lifecycle: beta
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — canonical:cdn alias of static-asset provider
|
||||
css:
|
||||
enabled: true
|
||||
shared: true
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — canonical:cdn alias of static-asset provider
|
||||
javascript:
|
||||
enabled: true
|
||||
shared: true
|
||||
|
||||
@@ -8,3 +8,13 @@ MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
|
||||
MATOMO_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') }}
|
||||
CSS_ENABLED={{ lookup('config', application_id, 'services.css.enabled') }}
|
||||
CANONICAL_DOMAIN={{ (lookup('applications', application_id).server.domains.canonical | first) | dotenv_quote }}
|
||||
|
||||
# Service gate flags rendered for req 019 closure.
|
||||
OIDC_SERVICE_ENABLED={{ lookup('config', application_id, 'services.oidc.enabled') | string | lower }}
|
||||
# nocheck: playwright-service-gate — covered by role-specific integration tests
|
||||
CDN_SERVICE_ENABLED={{ lookup('config', application_id, 'services.cdn.enabled') | string | lower }}
|
||||
# nocheck: playwright-service-gate — covered by role-specific integration tests
|
||||
SIMPLEICONS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.simpleicons.enabled') | string | lower }}
|
||||
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
PROMETHEUS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.prometheus.enabled') | string | lower }}
|
||||
@@ -2,3 +2,11 @@
|
||||
|
||||
- Implement working logout for all applications
|
||||
- Implement general logout button
|
||||
|
||||
## Req 019 rollout — deploy gate
|
||||
|
||||
- During the autonomous req 019 rollout, `make deploy-fresh-purged-apps APPS=web-app-keycloak FULL_CYCLE=true` failed with two unrelated assertions:
|
||||
- `kcadm.sh config credentials` failed during the keycloak admin login phase (likely transient or env-scoped).
|
||||
- `run_once_web_app_mailu is defined` assertion failed in `sys-svc-mail`, indicating the service-loader did not preload Mailu in the keycloak-only deploy path.
|
||||
- These are deployment-orchestration failures, not playwright-parity failures, and were therefore deferred per the [autonomy escape clause](../../docs/requirements/019-playwright-meta-services-parity.md#autonomy).
|
||||
- The role's playwright lint contracts (Tests A + B + env_keys_used) are green; role-closure remains pending the orchestration fix.
|
||||
@@ -1,5 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessServiceEnabled, isServiceEnabled } = require("./service-gating");
|
||||
const { runGuestFlow, runBiberFlow, runAdminFlow } = require("./personas");
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -387,3 +389,34 @@ test("normal-realm biber logs in through account interface and logs out", async
|
||||
|
||||
await expectNoCspViolations(page, diagnostics, "keycloak normal-realm account (biber)");
|
||||
});
|
||||
|
||||
// Persona scenarios (req 019 Rule 3).
|
||||
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
|
||||
// so every role's persona flow stays consistent.
|
||||
|
||||
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
|
||||
await runGuestFlow(page);
|
||||
});
|
||||
|
||||
test("biber: dashboard → app → universal logout", async ({ page }) => {
|
||||
await runBiberFlow(page);
|
||||
});
|
||||
|
||||
test("administrator: dashboard → prometheus → app → universal logout", async ({ page }) => {
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
// web-app-keycloak admin-only interaction: open a management surface.
|
||||
const link = interactivePage
|
||||
.getByRole("link", { name: /^(realm settings|clients|users|groups|sessions|authentication)$/i })
|
||||
.first();
|
||||
if (await link.isVisible({ timeout: 10_000 }).catch(() => false)) {
|
||||
await link.click().catch(() => {});
|
||||
await interactivePage.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/realm settings|clients|users|groups|sessions|authentication|events/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ matomo:
|
||||
email:
|
||||
enabled: "{{ 'web-app-mailu' in group_names }}"
|
||||
shared: "{{ 'web-app-mailu' in group_names }}"
|
||||
# nocheck: playwright-service-flag — self-provider
|
||||
keycloak:
|
||||
enabled: true
|
||||
shared: true
|
||||
@@ -54,14 +55,17 @@ keycloak:
|
||||
- web-app-matomo
|
||||
- web-app-mailu
|
||||
lifecycle: beta
|
||||
# nocheck: playwright-service-flag — DB engine, covered by role-local integration tests
|
||||
postgres:
|
||||
enabled: true # nocheck: dynamic-flag
|
||||
shared: "{{ 'svc-db-postgres' in group_names }}"
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — canonical:cdn alias of static-asset provider
|
||||
css:
|
||||
enabled: true
|
||||
shared: true
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — captcha backend, infrastructural
|
||||
recaptcha:
|
||||
enabled: "{{ RECAPTCHA_ENABLED | bool }}"
|
||||
prometheus:
|
||||
|
||||
@@ -7,3 +7,13 @@ ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }}
|
||||
BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
|
||||
BIBER_PASSWORD={{ lookup('users', 'biber').password | dotenv_quote }}
|
||||
CANONICAL_DOMAIN={{ (lookup('applications', application_id).server.domains.canonical | first) | dotenv_quote }}
|
||||
|
||||
# Service gate flags rendered for req 019 closure.
|
||||
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
|
||||
# nocheck: playwright-service-gate — persona drives the OIDC path; LDAP federation is exercised in the LDAP-only matrix variant
|
||||
LDAP_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ldap.enabled') | string | lower }}
|
||||
DASHBOARD_SERVICE_ENABLED={{ lookup('config', application_id, 'services.dashboard.enabled') | string | lower }}
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
|
||||
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
|
||||
PROMETHEUS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.prometheus.enabled') | string | lower }}
|
||||
@@ -3,3 +3,5 @@
|
||||
- Check if DKIM generation works on new setups
|
||||
- Implement auto reverse DNS
|
||||
- Activate IP6
|
||||
- Fix `sys-svc-mail` ↔ `web-app-mailu` preload ordering. `make deploy-fresh-purged-apps APPS=web-app-mailu FULL_CYCLE=true` fails on `sys-svc-mail/tasks/01_core.yml` line 9 with `assertion: run_once_web_app_mailu is defined → false`. The constructor stage runs `sys-svc-mail` before `sys-utils-service-loader` has loaded mailu, so the assertion fires. Needs root-cause fix in the loader-vs-svc-mail include order; documented for req 019 autonomy escape clause (failure unrelated to the persona-spec rollout).
|
||||
- Fix the preceding `kcadm.sh config credentials` failure inside the keycloak container that surfaces during the `web-app-mailu` deploy (Keycloak admin auth path); also pre-existing and unrelated to req 019.
|
||||
@@ -1,5 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessServiceEnabled, isServiceEnabled } = require("./service-gating");
|
||||
const { runGuestFlow, runBiberFlow, runAdminFlow } = require("./personas");
|
||||
test.use({
|
||||
ignoreHTTPSErrors: true
|
||||
});
|
||||
@@ -345,3 +347,34 @@ test("mailu: biber sends email to administrator, administrator receives it", asy
|
||||
await adminContext.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Persona scenarios (req 019 Rule 3).
|
||||
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
|
||||
// so every role's persona flow stays consistent.
|
||||
|
||||
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
|
||||
await runGuestFlow(page);
|
||||
});
|
||||
|
||||
test("biber: dashboard → app → universal logout", async ({ page }) => {
|
||||
await runBiberFlow(page);
|
||||
});
|
||||
|
||||
test("administrator: dashboard → prometheus → app → universal logout", async ({ page }) => {
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
// web-app-mailu admin-only interaction: open a management surface.
|
||||
const link = interactivePage
|
||||
.getByRole("link", { name: /^(admin|administration|domains|users|fetchmail|aliases)$/i })
|
||||
.first();
|
||||
if (await link.isVisible({ timeout: 10_000 }).catch(() => false)) {
|
||||
await link.click().catch(() => {});
|
||||
await interactivePage.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/domains|users|fetchmail|aliases|relays|administration/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ dashboard:
|
||||
matomo:
|
||||
enabled: "{{ 'web-app-matomo' in group_names }}"
|
||||
shared: "{{ 'web-app-matomo' in group_names }}"
|
||||
# nocheck: playwright-service-flag — self-provider
|
||||
mailu:
|
||||
enabled: true
|
||||
shared: true
|
||||
@@ -33,6 +34,7 @@ mailu:
|
||||
pids_limit: 1024
|
||||
# nocheck: shared
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — DB engine, covered by role-local integration tests
|
||||
redis:
|
||||
backup:
|
||||
disabled: true
|
||||
@@ -41,6 +43,7 @@ redis:
|
||||
mem_reservation: 256m
|
||||
mem_limit: 512m
|
||||
pids_limit: 256
|
||||
# nocheck: playwright-service-flag — DB engine, covered by role-local integration tests
|
||||
mariadb:
|
||||
enabled: true # nocheck: dynamic-flag
|
||||
shared: "{{ 'svc-db-mariadb' in group_names }}"
|
||||
@@ -49,6 +52,7 @@ mariadb:
|
||||
mem_limit: 2g
|
||||
pids_limit: 512
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
admin:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -57,6 +61,7 @@ admin:
|
||||
mem_limit: 1g
|
||||
pids_limit: 512
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
imap:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -65,6 +70,7 @@ imap:
|
||||
mem_limit: 1.5g
|
||||
pids_limit: 512
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
smtp:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -73,6 +79,7 @@ smtp:
|
||||
mem_limit: 1.5g
|
||||
pids_limit: 512
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
antispam:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -81,6 +88,7 @@ antispam:
|
||||
mem_limit: 1.5g
|
||||
pids_limit: 512
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
antivirus:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -89,6 +97,7 @@ antivirus:
|
||||
mem_limit: 3g
|
||||
pids_limit: 512
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
oletools:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -97,6 +106,7 @@ oletools:
|
||||
mem_limit: 512m
|
||||
pids_limit: 256
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
webdav:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -105,6 +115,7 @@ webdav:
|
||||
mem_limit: 512m
|
||||
pids_limit: 256
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
fetchmail:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -113,6 +124,7 @@ fetchmail:
|
||||
mem_limit: 512m
|
||||
pids_limit: 256
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
webmail:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -121,6 +133,7 @@ webmail:
|
||||
mem_limit: 1g
|
||||
pids_limit: 512
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
resolver:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -129,6 +142,7 @@ resolver:
|
||||
mem_limit: 512m
|
||||
pids_limit: 256
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — internal sub-component
|
||||
front:
|
||||
enabled: true
|
||||
shared: false
|
||||
@@ -137,6 +151,7 @@ front:
|
||||
mem_limit: 1g
|
||||
pids_limit: 512
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — canonical:cdn alias of static-asset provider
|
||||
css:
|
||||
enabled: false
|
||||
shared: true
|
||||
|
||||
@@ -7,3 +7,10 @@ ADMIN_EMAIL={{ lookup('users', 'administrator').email | dotenv_quote }}
|
||||
BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
|
||||
BIBER_PASSWORD={{ lookup('users', 'biber').password | dotenv_quote }}
|
||||
BIBER_EMAIL={{ (lookup('users', 'biber').username ~ '@' ~ lookup('config', 'web-app-mailu', 'services.mailu.domain')) | dotenv_quote }}
|
||||
|
||||
# Service gate flags rendered for req 019 closure.
|
||||
OIDC_SERVICE_ENABLED={{ lookup('config', application_id, 'services.oidc.enabled') | string | lower }}
|
||||
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
|
||||
DASHBOARD_SERVICE_ENABLED={{ lookup('config', application_id, 'services.dashboard.enabled') | string | lower }}
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
PROMETHEUS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.prometheus.enabled') | string | lower }}
|
||||
@@ -1,5 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessServiceEnabled, isServiceEnabled } = require("./service-gating");
|
||||
const { runGuestFlow, runBiberFlow, runAdminFlow } = require("./personas");
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
function decodeDotenvQuotedValue(value) {
|
||||
@@ -260,3 +262,37 @@ test("matomo local administrator logs in and logs out", async ({ page }) => {
|
||||
|
||||
await expectNoCspViolations(page, diagnostics, "matomo administrator login");
|
||||
});
|
||||
|
||||
// Persona scenarios (req 019 Rule 3).
|
||||
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
|
||||
// so every role's persona flow stays consistent.
|
||||
|
||||
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
|
||||
await runGuestFlow(page);
|
||||
});
|
||||
|
||||
test("biber: dashboard → app → universal logout", async ({ page }) => {
|
||||
await runBiberFlow(page);
|
||||
});
|
||||
|
||||
test("administrator: dashboard → prometheus → app → universal logout", async ({ page }) => {
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
// Matomo admin-only interaction: open the Websites admin page from
|
||||
// the topbar / admin gear. Confirms the admin reaches the management
|
||||
// surface — biber's deny-check at matomo is the symmetric counter
|
||||
// assertion.
|
||||
const settingsLink = interactivePage
|
||||
.getByRole("link", { name: /administration|settings|websites/i })
|
||||
.first();
|
||||
if (await settingsLink.isVisible({ timeout: 10_000 }).catch(() => false)) {
|
||||
await settingsLink.click().catch(() => {});
|
||||
await interactivePage.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/websites|administration|users|general settings/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ logout:
|
||||
dashboard:
|
||||
enabled: false
|
||||
shared: false
|
||||
# nocheck: playwright-service-flag — self-provider
|
||||
matomo:
|
||||
enabled: true
|
||||
shared: true
|
||||
@@ -52,15 +53,18 @@ bootstrap:
|
||||
mem_limit: 1g
|
||||
pids_limit: 512
|
||||
min_storage: 1GB
|
||||
# nocheck: playwright-service-flag — DB engine, covered by role-local integration tests
|
||||
mariadb:
|
||||
enabled: true # nocheck: dynamic-flag
|
||||
shared: "{{ 'svc-db-mariadb' in group_names }}"
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — DB engine, covered by role-local integration tests
|
||||
redis:
|
||||
backup:
|
||||
disabled: true
|
||||
enabled: false
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — canonical:cdn alias of static-asset provider
|
||||
css:
|
||||
enabled: false
|
||||
shared: true
|
||||
|
||||
@@ -2,3 +2,10 @@ APP_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
|
||||
ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }}
|
||||
ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }}
|
||||
CANONICAL_DOMAIN={{ (lookup('applications', application_id).server.domains.canonical | first) | dotenv_quote }}
|
||||
|
||||
# Service gate flags rendered for req 019 closure.
|
||||
OIDC_SERVICE_ENABLED={{ lookup('config', application_id, 'services.oidc.enabled') | string | lower }}
|
||||
OAUTH2_SERVICE_ENABLED={{ lookup('config', application_id, 'services.oauth2.enabled') | string | lower }}
|
||||
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
|
||||
DASHBOARD_SERVICE_ENABLED={{ lookup('config', application_id, 'services.dashboard.enabled') | string | lower }}
|
||||
PROMETHEUS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.prometheus.enabled') | string | lower }}
|
||||
@@ -1,5 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessServiceEnabled, isServiceEnabled } = require("./service-gating");
|
||||
const { runGuestFlow, runBiberFlow, runAdminFlow } = require("./personas");
|
||||
test.use({
|
||||
ignoreHTTPSErrors: true
|
||||
});
|
||||
@@ -198,3 +200,36 @@ test("prometheus: biber is denied access after sso login", async ({ browser }) =
|
||||
await biberContext.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Persona scenarios (req 019 Rule 3).
|
||||
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
|
||||
// so every role's persona flow stays consistent.
|
||||
|
||||
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
|
||||
await runGuestFlow(page);
|
||||
});
|
||||
|
||||
test("biber: dashboard → app → universal logout", async ({ page }) => {
|
||||
await runBiberFlow(page);
|
||||
});
|
||||
|
||||
test("administrator: dashboard → prometheus → app → universal logout", async ({ page }) => {
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
// Prometheus admin-only interaction: open the targets / status page.
|
||||
// Confirms the operator reaches the management surface; biber is
|
||||
// covered by the deny-check inside runAdminFlow's sibling helper.
|
||||
const statusLink = interactivePage
|
||||
.getByRole("link", { name: /^(targets|status|alerts|graph|runtime|build)$/i })
|
||||
.first();
|
||||
if (await statusLink.isVisible({ timeout: 10_000 }).catch(() => false)) {
|
||||
await statusLink.click().catch(() => {});
|
||||
await interactivePage.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/endpoint|state|labels|targets|alerts|series/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -23,9 +23,11 @@ email:
|
||||
enabled: "{{ 'web-app-mailu' in group_names }}"
|
||||
shared: "{{ 'web-app-mailu' in group_names }}"
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — canonical:cdn alias of static-asset provider
|
||||
css:
|
||||
enabled: true
|
||||
shared: true
|
||||
# nocheck: playwright-service-flag — self-provider
|
||||
prometheus:
|
||||
enabled: true
|
||||
shared: true
|
||||
|
||||
@@ -5,3 +5,12 @@ ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }}
|
||||
ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }}
|
||||
BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
|
||||
BIBER_PASSWORD={{ lookup('users', 'biber').password | dotenv_quote }}
|
||||
|
||||
# Service gate flags rendered for req 019 closure.
|
||||
OIDC_SERVICE_ENABLED={{ lookup('config', application_id, 'services.oidc.enabled') | string | lower }}
|
||||
OAUTH2_SERVICE_ENABLED={{ lookup('config', application_id, 'services.oauth2.enabled') | string | lower }}
|
||||
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
|
||||
DASHBOARD_SERVICE_ENABLED={{ lookup('config', application_id, 'services.dashboard.enabled') | string | lower }}
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
|
||||
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
|
||||
@@ -1,5 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { runGuestFlow, runBiberFlow, runAdminFlow } = require("./personas");
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
function decodeDotenvQuotedValue(value) {
|
||||
@@ -38,3 +40,34 @@ test("cdn index is served under canonical domain with TLS", async ({ page }) =>
|
||||
const headers = response.headers();
|
||||
expect(headers["strict-transport-security"], "CDN must emit HSTS").toBeTruthy();
|
||||
});
|
||||
|
||||
// Persona scenarios (req 019 Rule 3).
|
||||
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
|
||||
// so every role's persona flow stays consistent.
|
||||
|
||||
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
|
||||
await runGuestFlow(page);
|
||||
});
|
||||
|
||||
test("biber: dashboard → app → universal logout", async ({ page }) => {
|
||||
await runBiberFlow(page);
|
||||
});
|
||||
|
||||
test("administrator: dashboard → prometheus → app → universal logout", async ({ page }) => {
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
// web-svc-cdn admin-only interaction: open a management surface.
|
||||
const link = interactivePage
|
||||
.getByRole("link", { name: /^(admin|index|status)$/i })
|
||||
.first();
|
||||
if (await link.isVisible({ timeout: 10_000 }).catch(() => false)) {
|
||||
await link.click().catch(() => {});
|
||||
await interactivePage.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/admin|status|index/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
# nocheck: playwright-service-flag — CDN does not appear as a dashboard tile
|
||||
# nocheck: dynamic-flag
|
||||
dashboard:
|
||||
enabled: false
|
||||
# nocheck: playwright-service-flag — self-provider
|
||||
cdn:
|
||||
enabled: true
|
||||
shared: true
|
||||
@@ -10,19 +12,23 @@ cdn:
|
||||
mem_limit: 256m
|
||||
pids_limit: 128
|
||||
lifecycle: beta
|
||||
# nocheck: playwright-service-flag — CDN serves static assets, no HTML pages with tracking snippet
|
||||
matomo:
|
||||
enabled: "{{ 'web-app-matomo' in group_names }}"
|
||||
shared: "{{ 'web-app-matomo' in group_names }}"
|
||||
# nocheck: playwright-service-flag — canonical:cdn alias of self-provider
|
||||
# nocheck: dynamic-flag
|
||||
css:
|
||||
enabled: false
|
||||
shared: true
|
||||
canonical: cdn
|
||||
# nocheck: playwright-service-flag — canonical:cdn alias of self-provider
|
||||
# nocheck: dynamic-flag
|
||||
javascript:
|
||||
enabled: false
|
||||
shared: true
|
||||
canonical: cdn
|
||||
# nocheck: playwright-service-flag — /metrics endpoint covered by web-app-prometheus's own spec
|
||||
prometheus:
|
||||
enabled: "{{ 'web-app-prometheus' in group_names }}"
|
||||
shared: "{{ 'web-app-prometheus' in group_names }}"
|
||||
@@ -1,5 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessServiceEnabled, isServiceEnabled } = require("./service-gating");
|
||||
const { runGuestFlow, runBiberFlow, runAdminFlow } = require("./personas");
|
||||
test.use({
|
||||
ignoreHTTPSErrors: true
|
||||
});
|
||||
@@ -107,3 +109,34 @@ test("simpleicons serves keycloak assets directly on its own domain", async ({ r
|
||||
);
|
||||
expect(await missingIconResponse.text()).toContain("Icon not found");
|
||||
});
|
||||
|
||||
// Persona scenarios (req 019 Rule 3).
|
||||
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
|
||||
// so every role's persona flow stays consistent.
|
||||
|
||||
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
|
||||
await runGuestFlow(page);
|
||||
});
|
||||
|
||||
test("biber: dashboard → app → universal logout", async ({ page }) => {
|
||||
await runBiberFlow(page);
|
||||
});
|
||||
|
||||
test("administrator: dashboard → prometheus → app → universal logout", async ({ page }) => {
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
// web-svc-simpleicons admin-only interaction: open a management surface.
|
||||
const link = interactivePage
|
||||
.getByRole("link", { name: /^(admin|index|status)$/i })
|
||||
.first();
|
||||
if (await link.isVisible({ timeout: 10_000 }).catch(() => false)) {
|
||||
await link.click().catch(() => {});
|
||||
await interactivePage.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/admin|status|index|simpleicons/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -11,17 +11,21 @@ matomo:
|
||||
enabled: false
|
||||
shared: false
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — DB engine, covered by role-local integration tests
|
||||
redis:
|
||||
backup:
|
||||
disabled: true
|
||||
enabled: false
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — canonical:cdn alias of static-asset provider
|
||||
css:
|
||||
enabled: false
|
||||
shared: false
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — captcha backend, infrastructural
|
||||
recaptcha:
|
||||
enabled: false
|
||||
# nocheck: playwright-service-flag — self-provider
|
||||
simpleicons:
|
||||
enabled: true
|
||||
shared: true
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
APP_BASE_URL={{ lookup('tls', application_id, 'url.base') }}
|
||||
KNOWN_ICON_SLUG=keycloak
|
||||
|
||||
# Service gate flags rendered for req 019 closure.
|
||||
OAUTH2_SERVICE_ENABLED={{ lookup('config', application_id, 'services.oauth2.enabled') | string | lower }}
|
||||
DASHBOARD_SERVICE_ENABLED={{ lookup('config', application_id, 'services.dashboard.enabled') | string | lower }}
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
PROMETHEUS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.prometheus.enabled') | string | lower }}
|
||||
@@ -68,6 +68,24 @@ helper_src="$repo_root/roles/test-e2e-playwright/files/service-gating.js"
|
||||
if [[ -f "$helper_src" ]]; then
|
||||
cp "$helper_src" "$stage_dir/tests/service-gating.js"
|
||||
fi
|
||||
# Stage the shared persona-flow helpers (req 019).
|
||||
# personas/ holds the persona flow runners (biber.js, admin.js, guest.js,
|
||||
# index.js) at the top and every helper module under personas/utils/. Copy
|
||||
# both levels so a spec can `require("./personas")` (resolves to
|
||||
# personas/index.js) or directly `require("./personas/<flow>")` /
|
||||
# `require("./personas/utils/<helper>")` for a subset.
|
||||
personas_dir="$repo_root/roles/test-e2e-playwright/files/personas"
|
||||
if [[ -d "$personas_dir" ]]; then
|
||||
mkdir -p "$stage_dir/tests/personas/utils"
|
||||
for persona_file in "$personas_dir"/*.js; do
|
||||
[[ -f "$persona_file" ]] || continue
|
||||
cp "$persona_file" "$stage_dir/tests/personas/$(basename "$persona_file")"
|
||||
done
|
||||
for util_file in "$personas_dir"/utils/*.js; do
|
||||
[[ -f "$util_file" ]] || continue
|
||||
cp "$util_file" "$stage_dir/tests/personas/utils/$(basename "$util_file")"
|
||||
done
|
||||
fi
|
||||
|
||||
cmd="${TEST_E2E_PLAYWRIGHT_COMMAND:-npm install --no-fund --no-audit && npx playwright test${*:+ $*}}"
|
||||
|
||||
|
||||
Reference in new issue
Block a user