Files
ThothII/frontend/e2e/auth.spec.ts
T

185 lines
7.3 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { createAuthenticationStack } from "./fixtures/auth-stack.mjs";
test.describe.configure({ mode: "serial" });
// The only HTTPS navigation in this file is the test-scoped loopback provider.
test.use({ ignoreHTTPSErrors: true });
let stack: Awaited<ReturnType<typeof createAuthenticationStack>>;
test.beforeAll(async () => {
stack = await createAuthenticationStack();
});
test.afterAll(async () => {
await stack?.close();
});
test("the loopback fixture exposes signed OIDC discovery, device authorization, and AuthentiK group lookup", async () => {
await expect(stack.providerSurface()).resolves.toEqual({
discovery: true,
jwks: true,
deviceAuthorization: true,
deviceToken: true,
groupList: true,
runtimeCredential: process.env.THT_TASK15_SENTINEL !== undefined,
});
});
test("the production OIDC client rejects a wrong registration and wrong mounted client secret", async ({ page }) => {
await stack.useOidcMode("ordinary", "wrong-client-id");
await page.goto(stack.publicUrl);
await signInWithOidc(page);
await expect(page.locator("body")).toContainText("invalid_request");
await expect(page.getByTestId("app-shell")).toHaveCount(0);
await stack.useOidcMode("ordinary", "wrong-client-secret");
await page.goto(stack.publicUrl);
await signInWithOidc(page);
await expectOidcCallbackDenied(page);
});
test("production diagnostics reject a wrong group token and pass with the correct mounted secrets", async () => {
await stack.useOidcMode("ordinary", "wrong-api-token");
await expect(stack.authDiagnostics()).resolves.toMatchObject({
status: 1,
report: { ready: false, checks: [{ code: "oidc_group_catalog_unauthorized" }] },
});
await stack.useOidcMode("ordinary", "correct");
await expect(stack.authDiagnostics()).resolves.toMatchObject({
status: 0,
report: { ready: true, checks: [{ code: "auth_ready" }] },
});
});
async function expectShell(page: Page): Promise<void> {
await expect(page.getByTestId("app-shell")).toBeVisible({ timeout: 30_000 });
}
async function signInLocally(page: Page, account: "ordinary" | "admin", remember = false): Promise<void> {
await page.getByLabel("Username").fill(stack.localAccount(account).username);
await page.getByLabel("Password").fill(stack.localAccount(account).password);
const rememberControl = page.getByRole("checkbox", { name: /remember me/i });
if (remember) await rememberControl.check();
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expectShell(page);
}
async function browserSession(page: Page): Promise<{ status: number; body: Record<string, unknown> }> {
return page.evaluate(async () => {
const response = await fetch("/api/me", { credentials: "same-origin" });
return { status: response.status, body: await response.json() as Record<string, unknown> };
});
}
async function expectNoWebStorageTokens(page: Page): Promise<void> {
const entries = await page.evaluate(() => {
const values = (storage: Storage) => Array.from({ length: storage.length }, (_unused, index) => {
const key = storage.key(index) ?? "";
return [key, storage.getItem(key) ?? ""];
});
return [...values(localStorage), ...values(sessionStorage)];
});
expect(entries.filter(([key, value]) => /(?:access|refresh|id)?[_-]?token|bearer|jwt/i.test(`${key}\n${value}`))).toEqual([]);
}
async function signInWithOidc(page: Page): Promise<void> {
await page.getByRole("button", { name: /continue with single sign-on/i }).click();
}
async function expectOidcCallbackDenied(page: Page): Promise<void> {
await expect(page.locator("body")).toContainText("OIDC sign-in could not be completed", { timeout: 30_000 });
await expect(page.getByTestId("app-shell")).toHaveCount(0);
await expectNoWebStorageTokens(page);
}
test("local ordinary and remembered sessions survive restart, logout, and keep tokens out of Web Storage", async ({ page }) => {
await stack.useLocalMode();
await page.goto(stack.publicUrl);
await expect(page.getByRole("heading", { name: "Sign in to ThothII" })).toBeVisible();
await signInLocally(page, "ordinary");
expect((await browserSession(page)).body.roles).toEqual(["user"]);
await expectNoWebStorageTokens(page);
await stack.restartBackend();
await page.reload();
await expectShell(page);
await page.getByRole("button", { name: "Log out", exact: true }).click();
await expect(page.getByRole("heading", { name: "Sign in to ThothII" })).toBeVisible();
await signInLocally(page, "ordinary", true);
const remembered = (await page.context().cookies(stack.publicUrl)).find((cookie) => cookie.name === "thothii_session");
expect(remembered?.httpOnly).toBe(true);
expect(remembered?.expires ?? -1).toBeGreaterThan(Date.now() / 1_000);
await stack.restartBackend();
await page.reload();
await expectShell(page);
await expectNoWebStorageTokens(page);
await page.getByRole("button", { name: "Log out", exact: true }).click();
await expect(page.getByRole("heading", { name: "Sign in to ThothII" })).toBeVisible();
expect((await page.context().cookies(stack.publicUrl)).some((cookie) => cookie.name === "thothii_session")).toBe(false);
});
test("local administrator receives the administrator role", async ({ page }) => {
await stack.useLocalMode();
await page.goto(stack.publicUrl);
await signInLocally(page, "admin");
expect((await browserSession(page)).body.roles).toEqual(["admin"]);
await expectNoWebStorageTokens(page);
});
test("OIDC Authorization Code plus PKCE redirects back and maps ordinary and administrator groups", async ({ page }) => {
await stack.useOidcMode("ordinary");
await page.goto(stack.publicUrl);
await signInWithOidc(page);
await expectShell(page);
expect((await browserSession(page)).body.roles).toEqual(["user"]);
expect(stack.lastAuthorization()).toMatchObject({ codeChallengeMethod: "S256", pkceVerified: true });
await expectNoWebStorageTokens(page);
await expect(page.getByRole("button", { name: "Log out", exact: true })).toHaveCount(0);
await page.context().clearCookies();
stack.setOidcIdentity("admin");
await page.goto(stack.publicUrl);
await signInWithOidc(page);
await expectShell(page);
expect((await browserSession(page)).body.roles).toEqual(["admin"]);
await expectNoWebStorageTokens(page);
});
test("OIDC unmapped, missing, and malformed groups fail closed; an expired token can recover", async ({ page }) => {
await stack.useOidcMode("unmapped");
await page.goto(stack.publicUrl);
await signInWithOidc(page);
await expect(page.getByRole("heading", { name: "Access not permitted" })).toBeVisible({ timeout: 30_000 });
await expectNoWebStorageTokens(page);
await page.context().clearCookies();
stack.setOidcIdentity("missing-groups");
await page.goto(stack.publicUrl);
await signInWithOidc(page);
await expectOidcCallbackDenied(page);
stack.setOidcIdentity("malformed-groups");
await page.goto(stack.publicUrl);
await signInWithOidc(page);
await expectOidcCallbackDenied(page);
stack.setOidcIdentity("expired");
await page.goto(stack.publicUrl);
await signInWithOidc(page);
await expectOidcCallbackDenied(page);
stack.setOidcIdentity("ordinary");
await page.goto(stack.publicUrl);
await signInWithOidc(page);
await expectShell(page);
expect((await browserSession(page)).body.roles).toEqual(["user"]);
await expectNoWebStorageTokens(page);
});