mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Run 34421944328 pihole#1 (README replay, sso off) never left
/admin/login: the native login was refused. The compose template put
the administrator password inline under environment:, and compose
interpolates every $name in it, so the container held the inventory
password with each $name removed (its sha256 prefix matches that
mangled value, not the real one). The environment moves into
templates/env.j2, which has_env wires as env_file, with the password
through dotenv_quote like every other role. Rendering the real template
with a password containing $name, ${...} and $$ and reading it back from a
compose v5.5.1 container returns it unchanged, while an unquoted RAW=S$x
control line in the same file comes back as "S". TZ now comes from the
shared sys-svc-container include, so PIHOLE_TIMEZONE goes.
pihole#0 (onion) timed out on "still handing off": oauth2-proxy serves
its 403 page for a user outside the allowed group on /oauth2/callback,
and the poll checked the callback URL before the page text. The CI
trace holds "You do not have permission to access this resource." in
that page's collapsed More Info block, which textContent reads. The
poll now judges the denial text first, then the Keycloak auth URL, and
only then treats the callback URL as a handoff in progress. The loose
"403" and "forbidden" matches go, since a reached admin page can
contain either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
99 lines
5.4 KiB
JavaScript
99 lines
5.4 KiB
JavaScript
// @ts-check
|
|
// Scenarios: SSO login for administrator and biber (Keycloak 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 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);
|
|
|
|
test.beforeEach(() => {
|
|
test.skip(!oidcIssuerUrl, "SSO not enabled — skipping OAuth2 tests");
|
|
expect(piholeBaseUrl, "PIHOLE_BASE_URL must be set").toBeTruthy();
|
|
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
|
|
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
|
|
expect(biberUsername, "BIBER_USERNAME must be set").toBeTruthy();
|
|
expect(biberPassword, "BIBER_PASSWORD must be set").toBeTruthy();
|
|
});
|
|
|
|
async function performOidcLogin(page, username, password) {
|
|
await page.getByRole("textbox", { name: /username|email/i }).waitFor({ state: "visible", timeout: resolveTimeout(60_000) });
|
|
await page.getByRole("textbox", { name: /username|email/i }).fill(username);
|
|
await page.getByRole("textbox", { name: /username|email/i }).press("Tab");
|
|
await page.getByRole("textbox", { name: "Password" }).fill(password);
|
|
await page.getByRole("button", { name: /sign in/i }).click({ timeout: resolveTimeout(30_000) });
|
|
}
|
|
|
|
test("administrator: can log in via SSO and access pihole", async ({ page }) => {
|
|
const expectedOidcAuthUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`;
|
|
const expectedPiholeBaseUrl = piholeBaseUrl.replace(/\/$/, "");
|
|
|
|
await gotoOnion(page, `${expectedPiholeBaseUrl}/`);
|
|
await expect.poll(() => page.url(), { timeout: resolveTimeout(30_000) }).toContain(expectedOidcAuthUrl);
|
|
await performOidcLogin(page, adminUsername, adminPassword);
|
|
await expect.poll(() => page.url(), { timeout: resolveTimeout(60_000) }).toContain(expectedPiholeBaseUrl);
|
|
await page.waitForURL(url => !url.toString().includes("/oauth2/callback"), { timeout: resolveTimeout(30_000) }).catch(() => {});
|
|
await page.waitForLoadState("networkidle", { timeout: resolveTimeout(60_000) }).catch(() => {});
|
|
expect(page.url()).not.toContain(expectedOidcAuthUrl);
|
|
expect(page.url()).not.toContain("/oauth2/callback");
|
|
});
|
|
|
|
test("biber: is denied access to pihole admin panel", async ({ page }) => {
|
|
const expectedOidcAuthUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`;
|
|
const expectedPiholeBaseUrl = piholeBaseUrl.replace(/\/$/, "");
|
|
|
|
await gotoOnion(page, `${expectedPiholeBaseUrl}/`);
|
|
await expect.poll(() => page.url(), { timeout: resolveTimeout(30_000) }).toContain(expectedOidcAuthUrl);
|
|
await performOidcLogin(page, biberUsername, biberPassword);
|
|
await page.waitForLoadState("networkidle", { timeout: resolveTimeout(30_000) }).catch(() => {});
|
|
|
|
const deniedNow = async () => {
|
|
const body = await page.locator("body").textContent({ timeout: resolveTimeout(15_000) }).catch(() => "");
|
|
const text = (body || "").toLowerCase();
|
|
const url = page.url();
|
|
if (text.includes("access denied") || text.includes("you do not have permission")) return "denied";
|
|
if (url.includes(expectedOidcAuthUrl)) return "denied";
|
|
if (url.includes("/oauth2/callback")) return "still handing off";
|
|
return `reached ${url}`;
|
|
};
|
|
|
|
await expect
|
|
.poll(deniedNow, {
|
|
timeout: resolveTimeout(30_000),
|
|
message: "biber must not reach the pihole admin panel",
|
|
})
|
|
.toBe("denied");
|
|
});
|
|
|
|
test("administrator: can log out via logout button", async ({ page }) => {
|
|
const expectedOidcAuthUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`;
|
|
const expectedPiholeBaseUrl = piholeBaseUrl.replace(/\/$/, "");
|
|
|
|
await gotoOnion(page, `${expectedPiholeBaseUrl}/admin/`);
|
|
await expect.poll(() => page.url(), { timeout: resolveTimeout(30_000) }).toContain(expectedOidcAuthUrl);
|
|
await performOidcLogin(page, adminUsername, adminPassword);
|
|
await expect.poll(() => page.url(), { timeout: resolveTimeout(60_000) }).toContain(expectedPiholeBaseUrl);
|
|
await page.waitForURL(url => !url.toString().includes("/oauth2/callback"), { timeout: resolveTimeout(30_000) }).catch(() => {});
|
|
await page.waitForLoadState("networkidle", { timeout: resolveTimeout(30_000) }).catch(() => {});
|
|
|
|
await gotoOnion(page, `${expectedPiholeBaseUrl}/oauth2/sign_out?rd=${encodeURIComponent(oidcIssuerUrl.replace(/\/$/, "").concat("/protocol/openid-connect/logout"))}`);
|
|
const confirmButton = page.locator("#kc-logout");
|
|
await confirmButton.waitFor({ state: "visible", timeout: resolveTimeout(30_000) });
|
|
await confirmButton.click({ timeout: resolveTimeout(30_000) });
|
|
await expect.poll(() => page.url(), { timeout: resolveTimeout(30_000) }).not.toContain(`${expectedPiholeBaseUrl}/admin`);
|
|
});
|