Files
computer-playbook/roles/web-app-pihole/files/playwright/native.spec.js
kevinveenbirkenbachandClaude Opus 5 b95d9a2f54 fix(pihole): make the login and denial checks able to fail
Three assertions in these specs passed whether or not the thing they name
happened.

The native login test navigates to `/admin/login` and then asserted that the URL
contains `/admin`, which it already did before anyone typed a password, next to
a check that `body` is visible, which is true the moment the document parses. A
failed login scored green. It now waits for the URL to leave `/login` and for
the password field it just filled to be gone.

The SSO login test asserted `page.url()` once, immediately after a settle whose
result is discarded, so whether it saw the finished redirect was luck. Its two
sibling tests in the same file poll for the Pi-hole base URL and wait out
`/oauth2/callback` first; this one never did. It does now, and it also rejects
being parked on the callback, which nothing checked before.

The denial test counted `/oauth2/callback` and the auth endpoint as evidence of
denial. Both appear mid-handoff on a login that is about to SUCCEED, so an
authorization regression could pass here unnoticed. The check becomes a poll
over three distinct states: still handing off keeps polling, back at the auth
endpoint or a forbidden body is denial, anything else fails and reports where
the browser actually landed. A transient no longer reads as a pass.

The obvious form of that fix — requiring the URL to leave `/oauth2/callback`
before judging — is avoided on purpose: oauth2-proxy may render its 403 on the
callback path itself, and the test would then time out on a correct denial.
Judging the state rather than the path does not care.

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

48 lines
2.1 KiB
JavaScript

// @ts-check
// Scenario: Native admin login (Keycloak not enabled)
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { gotoOnion } = require("./personas");
test.use({ ignoreHTTPSErrors: true });
function decodeDotenvQuotedValue(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, "$"); }
}
const oidcIssuerUrl = decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL);
const piholeBaseUrl = decodeDotenvQuotedValue(process.env.PIHOLE_BASE_URL);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
test.beforeEach(() => {
test.skip(!!oidcIssuerUrl, "SSO is enabled — skipping native login tests");
expect(piholeBaseUrl, "PIHOLE_BASE_URL must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
});
test("administrator: can log in natively to pihole", async ({ page }) => {
const expectedPiholeBaseUrl = piholeBaseUrl.replace(/\/$/, "");
await gotoOnion(page, `${expectedPiholeBaseUrl}/admin/login`);
await page.waitForLoadState("networkidle", { timeout: resolveTimeout(30_000) }).catch(() => {});
const passwordInput = page.getByRole("textbox", { name: /password/i });
await passwordInput.waitFor({ state: "visible", timeout: resolveTimeout(30_000) });
await passwordInput.fill(adminPassword);
// Pi-hole v6 login button text is "Log in (uses cookie)"
await page.getByRole("button", { name: /log in|sign in/i }).click({ timeout: resolveTimeout(30_000) });
await page.waitForLoadState("networkidle", { timeout: resolveTimeout(30_000) }).catch(() => {});
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(30_000),
message: `native login must leave the login form, still at ${page.url()}`,
})
.not.toContain("/login");
await expect(passwordInput).toBeHidden({ timeout: resolveTimeout(30_000) });
});