Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
E2E Playwright / Playwright e2e (chromium) (push) Waiting to run
7 scenarios covering the full bridge+DocAdenice+Baserow chain: auth login, database-view insert, inline edit persistence, SSE realtime update (no reload), RBAC write-denied, kanban drag-drop, calendar reschedule. Includes docker-compose.e2e.yml (Postgres+Redis+Baserow+bridge+DocAdenice), playwright.config.ts (3 projects: chromium/firefox/webkit), auth+baserow+cleanup fixtures, global setup (API login + Baserow seed), and GitHub Actions e2e.yml. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
213 lines
6.2 KiB
TypeScript
213 lines
6.2 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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)}`,
|
|
);
|
|
}
|