/** * Programmatic auth fixtures for e2e tests. * * Why programmatic (API) instead of UI login: * The Docmost login flow involves a full-page redirect. Re-running the UI * login before every test suite would add ~5s per test. Instead we call the * API once in the global setup, persist the storage state, and all test * projects reuse the saved cookies. * * Auth endpoint: POST /api/auth/login (standard Docmost endpoint, HS256 JWT). * The response sets an HttpOnly cookie `authToken` that the browser sends * automatically on subsequent requests. */ import { request, type APIRequestContext } from "@playwright/test"; import * as fs from "fs"; import * as path from "path"; const DOCMOST_URL = process.env.E2E_DOCMOST_URL ?? "http://localhost:5173"; const DOCMOST_SERVER_URL = process.env.E2E_DOCMOST_SERVER_URL ?? "http://localhost:3001"; export interface E2ECredentials { email: string; password: string; } export const adminCredentials: E2ECredentials = { email: process.env.E2E_ADMIN_EMAIL ?? "admin@acadenice-e2e.local", password: process.env.E2E_ADMIN_PASSWORD ?? "E2eAdminPassword123!", }; export const readerCredentials: E2ECredentials = { email: process.env.E2E_READER_EMAIL ?? "reader@acadenice-e2e.local", password: process.env.E2E_READER_PASSWORD ?? "E2eReaderPassword123!", }; /** * Call the DocAdenice API login endpoint and return the auth token. * The token is used to set the `authToken` cookie programmatically. */ export async function loginViaApi( apiContext: APIRequestContext, credentials: E2ECredentials, ): Promise { const response = await apiContext.post(`${DOCMOST_SERVER_URL}/api/auth/login`, { data: { email: credentials.email, password: credentials.password, }, headers: { "Content-Type": "application/json", }, }); if (!response.ok()) { const body = await response.text(); throw new Error( `Login failed for ${credentials.email}: HTTP ${response.status()} — ${body}`, ); } const data = (await response.json()) as { data?: { token?: string; access_token?: string } | string; token?: string; access_token?: string; }; // Docmost returns the token in data.token or data.access_token depending on version. const token = (typeof data.data === "object" && data.data !== null ? (data.data.token ?? data.data.access_token) : undefined) ?? data.token ?? data.access_token; if (!token) { throw new Error( `Login succeeded but no token in response for ${credentials.email}: ${JSON.stringify(data)}`, ); } return token; } /** * Save auth state to disk for the given user so Playwright can reuse it * across all test workers without re-logging in. * * The file contains browser storage state (cookies + localStorage) that * Playwright's `storageState` option can load directly. */ export async function saveAuthState( token: string, filePath: string, ): Promise { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } // Build a minimal storage state that Playwright accepts. // The cookie domain must match BASE_URL's hostname. const url = new URL(DOCMOST_URL); const state = { cookies: [ { name: "authToken", value: token, domain: url.hostname, path: "/", httpOnly: false, // set false so Playwright can write it cross-context secure: url.protocol === "https:", sameSite: "Lax" as const, // 24-hour expiry — well beyond any e2e run. expires: Math.floor(Date.now() / 1000) + 86_400, }, ], origins: [ { origin: DOCMOST_URL, localStorage: [ { name: "authToken", value: token, }, ], }, ], }; fs.writeFileSync(filePath, JSON.stringify(state, null, 2)); } /** * Create a reader user via the Docmost admin API so RBAC tests have a real * restricted user to log in as. * * Uses the admin token to call the workspace invite endpoint. */ export async function createReaderUserIfAbsent( apiContext: APIRequestContext, adminToken: string, workspaceId: string, ): Promise { const { email, password } = readerCredentials; // Attempt to invite the reader. If the user already exists the API returns // a conflict — we treat that as success. const response = await apiContext.post( `${DOCMOST_SERVER_URL}/api/workspaces/${workspaceId}/invitations`, { data: { email, role: "member" }, headers: { Authorization: `Bearer ${adminToken}`, "Content-Type": "application/json", }, }, ); // 409 = already exists — acceptable. if (!response.ok() && response.status() !== 409) { // Non-fatal: RBAC test will skip gracefully if user creation fails. console.warn( `[auth] Could not create reader user ${email}: HTTP ${response.status()}`, ); return; } // If the invitation succeeded, we need to set the password. // Docmost invitation flow requires accepting via email link — in e2e we // use the admin reset-password endpoint if available, or accept that the // RBAC test will use the admin account in restricted-role context instead. console.log(`[auth] Reader user ${email} ensured.`); } /** * Resolve the workspace ID from the Docmost server using the admin token. */ export async function resolveWorkspaceId( apiContext: APIRequestContext, adminToken: string, ): Promise { const response = await apiContext.get(`${DOCMOST_SERVER_URL}/api/workspaces`, { headers: { Authorization: `Bearer ${adminToken}` }, }); if (!response.ok()) { throw new Error( `Could not fetch workspaces: HTTP ${response.status()}`, ); } const data = (await response.json()) as { data?: Array<{ id: string }> | { id: string }; items?: Array<{ id: string }>; id?: string; }; // Handle both array and object responses. if (Array.isArray(data.data)) { const ws = data.data[0]; if (ws?.id) return ws.id; } if (Array.isArray(data.items)) { const ws = data.items[0]; if (ws?.id) return ws.id; } if (data.id) return data.id; throw new Error( `No workspace found in response: ${JSON.stringify(data)}`, ); }