mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
179 lines
6.4 KiB
JavaScript
179 lines
6.4 KiB
JavaScript
const { test, expect } = require("@playwright/test");
|
|
const { resolveTimeout } = require("./timeouts");
|
|
|
|
const { isServiceEnabled } = require("./service-gating");
|
|
const {
|
|
appBaseUrl,
|
|
oidcIssuerUrl,
|
|
biberUsername,
|
|
biberPassword,
|
|
attachDiagnostics,
|
|
setupMatomoPage,
|
|
loginAsAdmin,
|
|
} = require("./_shared");
|
|
const { expectNoCspViolations, gotoOnion } = require("./personas");
|
|
|
|
test.use({ ignoreHTTPSErrors: true });
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await setupMatomoPage(page);
|
|
});
|
|
|
|
test("matomo local administrator logs in and logs out", async ({ page }) => {
|
|
const diagnostics = attachDiagnostics(page);
|
|
|
|
await loginAsAdmin(page);
|
|
|
|
await expect(page.locator("body")).toContainText(/dashboard|websites|matomo/i, { timeout: resolveTimeout(60_000) });
|
|
|
|
await gotoOnion(page, `${appBaseUrl}/index.php?module=Login&action=logout`);
|
|
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await page
|
|
.locator("input#login_form_login, input[name='form_login']")
|
|
.first()
|
|
.count()
|
|
.catch(() => 0)) > 0,
|
|
{
|
|
timeout: resolveTimeout(60_000),
|
|
message: "Expected Matomo login form to reappear after logout"
|
|
}
|
|
)
|
|
.toBe(true);
|
|
|
|
await expectNoCspViolations(page, diagnostics, "matomo administrator login");
|
|
});
|
|
|
|
// Biber denial at Matomo: biber's Keycloak account exists but is NOT in
|
|
// `web-app-matomo-administrator`. After the OIDC chain, oauth2-proxy MUST
|
|
// refuse the session, either with a 403 at `/oauth2/callback` or by redirecting
|
|
// to a denial surface. This is the SPOT for "biber cannot reach Matomo" since
|
|
// the persona helper no longer drives this probe.
|
|
test("matomo: biber is denied access at the admin surface", async ({ browser }) => {
|
|
test.skip(
|
|
!isServiceEnabled("sso"),
|
|
"matomo's oauth2-proxy gate is not wired yet (services.yml oauth2.enabled=false; see TODO).",
|
|
);
|
|
test.skip(
|
|
!oidcIssuerUrl || !biberUsername || !biberPassword,
|
|
"OIDC_ISSUER_URL / BIBER_USERNAME / BIBER_PASSWORD must be set in the Playwright env file",
|
|
);
|
|
|
|
const expectedOidcAuthUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`;
|
|
const expectedMatomoBaseUrl = appBaseUrl.replace(/\/$/, "");
|
|
|
|
const biberContext = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
try {
|
|
const biberPage = await biberContext.newPage();
|
|
|
|
// Register the callback listener BEFORE goto so no response is missed; the
|
|
// redirect chain may complete before a listener registered after login.
|
|
const callbackResponsePromise = biberPage
|
|
.waitForResponse(
|
|
(res) => res.url().includes("/oauth2/callback"),
|
|
{ timeout: resolveTimeout(60_000) },
|
|
)
|
|
.catch(() => null);
|
|
|
|
await biberPage.goto(`${expectedMatomoBaseUrl}/`);
|
|
|
|
await expect
|
|
.poll(() => biberPage.url(), {
|
|
timeout: resolveTimeout(30_000),
|
|
message: `Expected redirect to Keycloak OIDC auth: ${expectedOidcAuthUrl}`,
|
|
})
|
|
.toContain(expectedOidcAuthUrl);
|
|
|
|
const usernameField = biberPage
|
|
.getByRole("textbox", { name: /username|email/i })
|
|
.or(biberPage.locator("input[name='username'], input#username"))
|
|
.first();
|
|
const passwordField = biberPage
|
|
.getByRole("textbox", { name: /^password$/i })
|
|
.or(biberPage.locator("input[name='password'], input#password"))
|
|
.first();
|
|
const signInButton = biberPage
|
|
.getByRole("button", { name: /sign in|login|log in/i })
|
|
.or(biberPage.locator("input#kc-login, button#kc-login, button[type='submit'], input[type='submit']"))
|
|
.first();
|
|
|
|
await usernameField.waitFor({ state: "visible", timeout: resolveTimeout(60_000) });
|
|
await usernameField.fill(biberUsername);
|
|
await usernameField.press("Tab").catch(() => {});
|
|
await passwordField.fill(biberPassword);
|
|
await signInButton.click();
|
|
|
|
const callbackResponse = await callbackResponsePromise;
|
|
|
|
if (callbackResponse) {
|
|
// Primary path: the proxy returns 403 at /oauth2/callback. Anything below
|
|
// 400 means biber crossed into the admin surface and is a real regression.
|
|
expect(
|
|
callbackResponse.status(),
|
|
`oauth2-proxy MUST deny biber at /oauth2/callback (got ${callbackResponse.status()})`,
|
|
).toBeGreaterThanOrEqual(400);
|
|
return;
|
|
}
|
|
|
|
// Fallback: no callback observed; verify the URL did not settle on the
|
|
// authenticated Matomo surface, AND the body does not expose admin markers.
|
|
await biberPage.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(60_000) }).catch(() => {});
|
|
const finalUrl = biberPage.url();
|
|
const onAuthDenialChain =
|
|
/openid-connect\/auth/.test(finalUrl) ||
|
|
/\/oauth2\/(?:start|sign_in|callback)/.test(finalUrl);
|
|
|
|
if (onAuthDenialChain) return;
|
|
|
|
const probe = await biberPage.request
|
|
.get(`${expectedMatomoBaseUrl}/`, { ignoreHTTPSErrors: true, maxRedirects: 0 })
|
|
.catch(() => null);
|
|
if (!probe) {
|
|
expect(
|
|
false,
|
|
`biber must NOT reach the matomo UI (probe failed; outer URL ${finalUrl})`,
|
|
).toBe(true);
|
|
return;
|
|
}
|
|
const status = probe.status();
|
|
if (status === 401 || status === 403) return;
|
|
if (status >= 300 && status < 400) {
|
|
const location = probe.headers()["location"] || "";
|
|
if (/openid-connect\/auth|\/oauth2\/(?:start|sign_in|callback)/.test(location)) return;
|
|
return;
|
|
}
|
|
if (status === 200) {
|
|
const body = await probe.text().catch(() => "");
|
|
const showsAdminUi =
|
|
/id=['"]?Dashboard_/i.test(body) &&
|
|
(/id=['"]?Settings/i.test(body) || /class=['"][^'"]*activeNav/i.test(body));
|
|
if (showsAdminUi) {
|
|
expect(
|
|
false,
|
|
`biber must NOT reach the matomo UI: GET ${expectedMatomoBaseUrl}/ returned 200 with admin DOM markers.`,
|
|
).toBe(true);
|
|
return;
|
|
}
|
|
// Pre-auth or login-form surface is acceptable: biber sees matomo's login
|
|
// but is NOT past it.
|
|
const isMatomoSurface =
|
|
/<input[^>]*name=['"]?form_login['"]?/i.test(body) ||
|
|
/<input[^>]*name=['"]?form_password['"]?/i.test(body) ||
|
|
/piwik|matomo/i.test(body);
|
|
expect(
|
|
isMatomoSurface,
|
|
`biber probe to ${expectedMatomoBaseUrl}/ returned 200 but the body is neither matomo's login form nor a recognisable matomo / piwik surface.`,
|
|
).toBe(true);
|
|
return;
|
|
}
|
|
expect(
|
|
false,
|
|
`biber probe to ${expectedMatomoBaseUrl}/ returned unexpected status ${status}.`,
|
|
).toBe(true);
|
|
} finally {
|
|
await biberContext.close().catch(() => {});
|
|
}
|
|
});
|