184 lines
7.3 KiB
TypeScript
184 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 page.getByRole("button", { name: "Log out", exact: true }).click();
|
|
await expect(page.getByRole("heading", { name: "Sign in to ThothII" })).toBeVisible();
|
|
stack.setOidcIdentity("admin");
|
|
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);
|
|
});
|