b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
/**
|
|
* E2E tests for MFA (TOTP) flows.
|
|
*
|
|
* Coverage:
|
|
* 1. MFA setup: generate secret, enter valid TOTP, confirm enabled
|
|
* 2. MFA login: sign in with password → TOTP prompt → enter code → dashboard
|
|
* 3. MFA login: wrong TOTP code → error message, stays on prompt
|
|
* 4. Login without MFA: no TOTP prompt for users without MFA enabled
|
|
*
|
|
* Design notes:
|
|
* - Uses the admin user from STORAGE_STATE to set up/tear down MFA via tRPC.
|
|
* - TOTP codes are generated in Node context using the `otpauth` package.
|
|
* - Each test that enables MFA cleans up via disableMfa() in afterEach so
|
|
* other test suites are not affected.
|
|
* - Tests run against the live dev server (playwright.dev.config.ts).
|
|
*/
|
|
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
import { TOTP, Secret } from "otpauth";
|
|
import { STORAGE_STATE } from "../../playwright.dev.config.js";
|
|
|
|
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
|
|
|
type TrpcResult = {
|
|
result?: { data?: unknown };
|
|
error?: { data?: { code?: string }; message?: string };
|
|
};
|
|
|
|
async function trpcMutation(
|
|
page: Page,
|
|
procedure: string,
|
|
input: unknown = null,
|
|
): Promise<TrpcResult> {
|
|
return page.evaluate(
|
|
async ({ procedure, input }) => {
|
|
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ "0": { json: input } }),
|
|
});
|
|
const body = (await res.json()) as TrpcResult[];
|
|
return body[0] ?? {};
|
|
},
|
|
{ procedure, input },
|
|
);
|
|
}
|
|
|
|
async function trpcQuery(
|
|
page: Page,
|
|
procedure: string,
|
|
input: unknown = null,
|
|
): Promise<TrpcResult> {
|
|
return page.evaluate(
|
|
async ({ procedure, input }) => {
|
|
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
|
const res = await fetch(`/api/trpc/${procedure}?batch=1&input=${encodedInput}`, {
|
|
credentials: "include",
|
|
});
|
|
const body = (await res.json()) as TrpcResult[];
|
|
return body[0] ?? {};
|
|
},
|
|
{ procedure, input },
|
|
);
|
|
}
|
|
|
|
// Enable MFA for the session user and return the TOTP instance for code generation.
|
|
async function enableMfaForSession(page: Page): Promise<TOTP> {
|
|
const genRes = await trpcMutation(page, "user.generateTotpSecret");
|
|
const data = (genRes.result?.data as { json?: { secret?: string; uri?: string } })?.json;
|
|
if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`);
|
|
|
|
const totp = new TOTP({
|
|
issuer: "Nexus",
|
|
algorithm: "SHA1",
|
|
digits: 6,
|
|
period: 30,
|
|
secret: Secret.fromBase32(data.secret),
|
|
});
|
|
|
|
const token = totp.generate();
|
|
const enableRes = await trpcMutation(page, "user.verifyAndEnableTotp", { token });
|
|
if (enableRes.error) throw new Error(`verifyAndEnableTotp failed: ${JSON.stringify(enableRes)}`);
|
|
|
|
return totp;
|
|
}
|
|
|
|
// Disable MFA for the session user. Fetches current user profile first to get the userId.
|
|
async function disableMfaForSession(page: Page): Promise<void> {
|
|
const profileRes = await trpcQuery(page, "user.getCurrentUserProfile");
|
|
const profile = (profileRes.result?.data as { json?: { id?: string } })?.json;
|
|
if (!profile?.id) throw new Error("Could not resolve current user id for MFA disable");
|
|
await trpcMutation(page, "user.disableTotp", { userId: profile.id });
|
|
}
|
|
|
|
// ─── test suite ───────────────────────────────────────────────────────────────
|
|
|
|
test.describe("MFA — setup flow (account/security page)", () => {
|
|
test.use({ storageState: STORAGE_STATE.admin });
|
|
|
|
let totp: TOTP | null = null;
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
// Clean up: disable MFA if a test enabled it
|
|
if (totp) {
|
|
await disableMfaForSession(page).catch(() => {
|
|
/* already disabled or admin override */
|
|
});
|
|
totp = null;
|
|
}
|
|
});
|
|
|
|
test("generates a TOTP secret and returns a valid otpauth URI", async ({ page }) => {
|
|
await page.goto("/account/security");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const genRes = await trpcMutation(page, "user.generateTotpSecret");
|
|
const data = (genRes.result?.data as { json?: { secret?: string; uri?: string } })?.json;
|
|
|
|
expect(data?.secret).toBeTruthy();
|
|
expect(data?.uri).toMatch(/^otpauth:\/\/totp\//);
|
|
expect(data?.uri).toContain("Nexus");
|
|
});
|
|
|
|
test("verifyAndEnableTotp accepts a valid code and enables MFA", async ({ page }) => {
|
|
await page.goto("/account/security");
|
|
|
|
totp = await enableMfaForSession(page);
|
|
|
|
// Verify status via tRPC
|
|
const statusRes = await trpcQuery(page, "user.getMfaStatus");
|
|
const status = (statusRes.result?.data as { json?: { totpEnabled?: boolean } })?.json;
|
|
expect(status?.totpEnabled).toBe(true);
|
|
});
|
|
|
|
test("verifyAndEnableTotp rejects an invalid 6-digit code", async ({ page }) => {
|
|
await page.goto("/account/security");
|
|
|
|
// First generate a secret
|
|
await trpcMutation(page, "user.generateTotpSecret");
|
|
|
|
// Submit obviously wrong code
|
|
const res = await trpcMutation(page, "user.verifyAndEnableTotp", { token: "000000" });
|
|
expect(res.error?.data?.code).toBe("BAD_REQUEST");
|
|
expect(res.error?.message).toMatch(/Invalid TOTP token/i);
|
|
});
|
|
|
|
test("setup UI shows QR code and secret fields after generating", async ({ page }) => {
|
|
await page.goto("/account/security");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Click the enable/setup button if MFA is not yet enabled
|
|
const setupBtn = page
|
|
.getByRole("button", { name: /set up/i })
|
|
.or(page.getByRole("button", { name: /enable.*mfa/i }));
|
|
|
|
if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await setupBtn.click();
|
|
// QR code image or canvas should appear
|
|
await expect(
|
|
page.locator('img[alt*="QR"], canvas, [data-testid="qr-code"]').first(),
|
|
).toBeVisible({ timeout: 5000 });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── MFA login flow ───────────────────────────────────────────────────────────
|
|
|
|
test.describe("MFA — login flow", () => {
|
|
// These tests need a fresh unauthenticated browser context
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
let totp: TOTP | null = null;
|
|
|
|
// Enable MFA on admin before each login test using a separate authenticated context
|
|
test.beforeEach(async ({ browser }) => {
|
|
const ctx = await browser.newContext({ storageState: STORAGE_STATE.admin });
|
|
const page = await ctx.newPage();
|
|
await page.goto("/dashboard");
|
|
totp = await enableMfaForSession(page);
|
|
await ctx.close();
|
|
});
|
|
|
|
test.afterEach(async ({ browser }) => {
|
|
if (!totp) return;
|
|
// Re-authenticate as admin (without MFA — use admin bypass via fresh session)
|
|
// Since we just enabled MFA on the admin account, we need to sign in with TOTP to disable
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
await page.goto("/auth/signin");
|
|
await page.fill('input[type="email"]', "admin@planarchy.dev");
|
|
await page.fill('input[type="password"]', "admin123");
|
|
await page.click('button[type="submit"]');
|
|
await page.waitForURL(/\/auth\/signin/, { timeout: 5000 }).catch(() => {});
|
|
|
|
// Enter TOTP if prompted
|
|
const totpInput = page.locator("#totp");
|
|
if (await totpInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await totpInput.fill(totp!.generate());
|
|
await page.click('button[type="submit"]');
|
|
await page.waitForURL(/\/(dashboard|resources)/, { timeout: 10000 }).catch(() => {});
|
|
}
|
|
|
|
// Disable MFA
|
|
await disableMfaForSession(page).catch(() => {});
|
|
await ctx.close();
|
|
totp = null;
|
|
});
|
|
|
|
test("login with MFA-enabled account: password step shows TOTP prompt", async ({ page }) => {
|
|
await page.goto("/auth/signin");
|
|
await page.fill('input[type="email"]', "admin@planarchy.dev");
|
|
await page.fill('input[type="password"]', "admin123");
|
|
await page.click('button[type="submit"]');
|
|
|
|
// Should NOT redirect to dashboard — should show TOTP input
|
|
await expect(page.locator("#totp")).toBeVisible({ timeout: 8000 });
|
|
await expect(page.getByText(/two-factor/i)).toBeVisible();
|
|
});
|
|
|
|
test("login with MFA: valid TOTP code completes sign-in", async ({ page }) => {
|
|
await page.goto("/auth/signin");
|
|
await page.fill('input[type="email"]', "admin@planarchy.dev");
|
|
await page.fill('input[type="password"]', "admin123");
|
|
await page.click('button[type="submit"]');
|
|
|
|
await expect(page.locator("#totp")).toBeVisible({ timeout: 8000 });
|
|
|
|
const code = totp!.generate();
|
|
await page.fill("#totp", code);
|
|
await page.click('button[type="submit"]');
|
|
|
|
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 12000 });
|
|
});
|
|
|
|
test("login with MFA: wrong TOTP code shows error and stays on MFA prompt", async ({ page }) => {
|
|
await page.goto("/auth/signin");
|
|
await page.fill('input[type="email"]', "admin@planarchy.dev");
|
|
await page.fill('input[type="password"]', "admin123");
|
|
await page.click('button[type="submit"]');
|
|
|
|
await expect(page.locator("#totp")).toBeVisible({ timeout: 8000 });
|
|
|
|
await page.fill("#totp", "000000");
|
|
await page.click('button[type="submit"]');
|
|
|
|
// Should show error and remain on TOTP step
|
|
await expect(
|
|
page
|
|
.getByText(/invalid.*code|incorrect.*token|try again/i)
|
|
.or(page.locator("[data-error]"))
|
|
.first(),
|
|
).toBeVisible({ timeout: 5000 });
|
|
|
|
// Should NOT have navigated away
|
|
expect(page.url()).not.toMatch(/\/(dashboard|resources)/);
|
|
});
|
|
});
|
|
|
|
// ─── Login without MFA ────────────────────────────────────────────────────────
|
|
|
|
test.describe("MFA — users without MFA enabled", () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/auth/signin");
|
|
await page.fill('input[type="email"]', "manager@planarchy.dev");
|
|
await page.fill('input[type="password"]', "manager123");
|
|
await page.click('button[type="submit"]');
|
|
|
|
// No TOTP step — straight redirect
|
|
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 12000 });
|
|
await expect(page.locator("#totp")).not.toBeVisible();
|
|
});
|
|
});
|