feat(auth): enhance authentication system with multi-user support and admin role management

- Implemented multi-user authentication with role-based access control.
- Added environment variables for initial admin user setup.
- Updated README and example environment file with new authentication options.
- Introduced user and system configuration models in the database schema.
- Enhanced authentication middleware to support user registration and role management.
- Updated frontend to handle new authentication flows, including admin user creation and role updates.
This commit is contained in:
Adrian Acala
2026-01-18 09:43:32 -08:00
parent 20ef4ee295
commit 1a52fe80f3
27 changed files with 1692 additions and 237 deletions
+59 -32
View File
@@ -1,17 +1,27 @@
import { defineConfig, devices } from "@playwright/test";
import path from "path";
import os from "os";
// Centralized test environment URLs
const FRONTEND_PORT = 5173;
const BACKEND_PORT = 8000;
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`;
const BACKEND_URL = process.env.API_URL || http://localhost:${BACKEND_PORT}`;
const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
const API_URL = BACKEND_URL;
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
const AUTH_SESSION_SECRET = process.env.AUTH_SESSION_SECRET || "e2e-auth-secret";
const E2E_DB_NAME = process.env.E2E_DB_NAME || `e2e-${Date.now()}.db`;
const DATABASE_URL = process.env.DATABASE_URL || `file:${path.join(os.tmpdir(), E2E_DB_NAME)}`;
process.env.AUTH_USERNAME = AUTH_USERNAME;
process.env.AUTH_PASSWORD = AUTH_PASSWORD;
process.env.AUTH_SESSION_SECRET = AUTH_SESSION_SECRET;
process.env.AUTH_EMAIL = process.env.AUTH_EMAIL || "admin@example.com";
process.env.AUTH_MIN_PASSWORD_LENGTH = process.env.AUTH_MIN_PASSWORD_LENGTH || "7";
process.env.E2E_DB_NAME = E2E_DB_NAME;
process.env.DATABASE_URL = DATABASE_URL;
process.env.VITE_API_URL = process.env.VITE_API_URL || "/api";
/**
* Playwright configuration for E2E browser testing
@@ -26,7 +36,7 @@ export default defineConfig({
testDir: "./tests",
// Run tests in parallel
fullyParallel: true,
fullyParallel: false,
// Fail the build on test.only() in CI
forbidOnly: !!process.env.CI,
@@ -35,7 +45,7 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
// Limit parallel workers in CI
workers: process.env.CI ? 1 : undefined,
workers: process.env.CI ? 1 : 1,
// Reporter configuration
reporter: [
@@ -65,6 +75,9 @@ export default defineConfig({
// Base URL for page.goto()
baseURL: FRONTEND_URL,
// Load shared auth state
storageState: path.resolve(__dirname, "tests/.auth/storageState.json"),
// Collect trace on first retry
trace: "on-first-retry",
@@ -90,32 +103,46 @@ export default defineConfig({
],
// Run local dev servers before tests (skip if NO_SERVER or CI)
webServer: (process.env.CI || process.env.NO_SERVER === "true") ? undefined : [
{
command: "cd ../backend && npm run dev",
url: `${BACKEND_URL}/health`,
reuseExistingServer: true,
timeout: 120000,
stdout: "pipe",
stderr: "pipe",
env: {
// Prisma resolves relative SQLite paths from the schema directory (backend/prisma).
// Using `file:./dev.db` avoids accidentally creating `prisma/prisma/dev.db`.
DATABASE_URL: "file:./dev.db",
FRONTEND_URL,
CSRF_MAX_REQUESTS: "1000",
AUTH_USERNAME,
AUTH_PASSWORD,
AUTH_SESSION_SECRET,
},
},
{
command: "cd ../frontend && npm run dev -- --host",
url: FRONTEND_URL,
reuseExistingServer: true,
timeout: 120000,
stdout: "pipe",
stderr: "pipe",
},
],
webServer: (process.env.CI || process.env.NO_SERVER === "true")
? undefined
: [
{
command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts",
url: `${BACKEND_URL}/health`,
reuseExistingServer: true,
timeout: 120000,
stdout: "pipe",
stderr: "pipe",
env: {
// Prisma resolves relative SQLite paths from the schema directory (backend/prisma).
DATABASE_URL,
FRONTEND_URL,
CSRF_MAX_REQUESTS: "10000",
AUTH_USERNAME,
AUTH_PASSWORD,
AUTH_MIN_PASSWORD_LENGTH: "7",
AUTH_SESSION_SECRET,
AUTH_SESSION_TTL_HOURS: "4",
RATE_LIMIT_MAX_REQUESTS: "20000",
NODE_ENV: "e2e",
TS_NODE_TRANSPILE_ONLY: "1",
},
},
{
command: "cd ../frontend && npm run dev -- --host",
url: FRONTEND_URL,
reuseExistingServer: true,
timeout: 120000,
stdout: "pipe",
stderr: "pipe",
env: {
VITE_API_URL: "/api",
API_URL,
},
},
],
globalSetup: require.resolve("./tests/global-setup"),
globalTeardown: require.resolve("./tests/global-teardown"),
});
+9 -4
View File
@@ -1,16 +1,19 @@
import { test, expect } from "./fixtures";
test.describe("Authentication", () => {
test.use({ skipAuth: true });
test("should require login and allow logout", async ({ page }) => {
const username = process.env.AUTH_USERNAME || "admin";
const password = process.env.AUTH_PASSWORD || "admin";
const password = process.env.AUTH_PASSWORD || "admin123";
await page.context().clearCookies();
await page.goto("/");
await expect(page.getByText("Sign in to access your drawings")).toBeVisible();
const signInPrompt = page.getByText("Sign in to access your drawings");
await expect(signInPrompt).toBeVisible();
await page.getByLabel("Username").fill(username);
await page.getByLabel("Username or Email").fill(username);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
@@ -22,6 +25,8 @@ test.describe("Authentication", () => {
await expect(logoutButton).toBeVisible();
await logoutButton.click();
await expect(page.getByText("Sign in to access your drawings")).toBeVisible();
await expect(signInPrompt).toBeVisible();
await page.context().clearCookies();
});
});
+4 -3
View File
@@ -70,20 +70,21 @@ test.describe("Dashboard Workflows", () => {
await page.waitForLoadState("networkidle");
await applyDashboardSearch(page, drawingName);
const cardLocator = await ensureCardVisible(page, createdDrawing.id);
let cardLocator = await ensureCardVisible(page, createdDrawing.id);
await ensureCardSelected(page, createdDrawing.id);
await page.getByTitle("Move to Trash").click();
await expect(cardLocator).toHaveCount(0);
await page.getByRole("button", { name: /^Trash$/ }).click();
const trashCard = await ensureCardVisible(page, createdDrawing.id);
await applyDashboardSearch(page, drawingName);
cardLocator = await ensureCardVisible(page, createdDrawing.id);
await ensureCardSelected(page, createdDrawing.id);
await page.getByTitle("Delete Permanently").click();
await page.getByRole("button", { name: /Delete \d+ Drawings/ }).click();
await expect(trashCard).toHaveCount(0);
await expect(cardLocator).toHaveCount(0);
const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`);
expect(response.status()).toBe(404);
+2
View File
@@ -52,6 +52,8 @@ test.describe("Drag and Drop - Collections", () => {
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.getByPlaceholder("Search drawings...").fill(drawing.name);
await page.waitForTimeout(500);
// Find the drawing card
const card = page.locator(`#drawing-card-${drawing.id}`);
+2 -1
View File
@@ -3,6 +3,7 @@ import {
API_URL,
createDrawing,
deleteDrawing,
ensureAuthenticated,
getCsrfHeaders,
listDrawings,
deleteCollection,
@@ -381,9 +382,9 @@ test.describe("Database Import Verification", () => {
test("should verify SQLite import endpoint exists", async ({ request }) => {
// Test that the verification endpoint responds
// We don't actually import a database as that would affect the test environment
await ensureAuthenticated(request);
const response = await request.post(`${API_URL}/import/sqlite/verify`, {
headers: await getCsrfHeaders(request),
// Send empty form data to test endpoint exists
multipart: {
db: {
name: "test.sqlite",
+57 -18
View File
@@ -1,24 +1,63 @@
import { test as base, expect } from "@playwright/test";
import { ensurePageAuthenticated } from "./helpers/auth";
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
type Fixtures = {
skipAuth: boolean;
};
export const test = base;
test.beforeEach(async ({ page }) => {
// Navigate to root to check if we need to login
await page.goto("/");
await page.waitForLoadState("domcontentloaded");
// If we see the login page, perform login
const loginText = page.getByText("Sign in to access your drawings");
if (await loginText.isVisible({ timeout: 2000 }).catch(() => false)) {
await page.getByLabel("Username").fill(AUTH_USERNAME);
await page.getByLabel("Password").fill(AUTH_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
// Wait for dashboard to load
await page.getByPlaceholder("Search drawings...").waitFor({ state: "visible", timeout: 15000 });
}
export const test = base.extend<Fixtures>({
skipAuth: [false, { option: true }],
});
test.beforeEach(async ({ page, skipAuth }) => {
if (skipAuth) {
return;
}
await ensurePageAuthenticated(page);
let authCheckInFlight: Promise<void> | null = null;
const maybeReauthenticate = async () => {
if (authCheckInFlight) {
return authCheckInFlight;
}
authCheckInFlight = (async () => {
const loginPrompt = page.getByText("Sign in to access your drawings");
if (await loginPrompt.isVisible({ timeout: 3000 }).catch(() => false)) {
await ensurePageAuthenticated(page, { skipNavigation: true });
}
})().finally(() => {
authCheckInFlight = null;
});
return authCheckInFlight;
};
page.on("framenavigated", async (frame) => {
if (frame !== page.mainFrame()) {
return;
}
await maybeReauthenticate();
});
page.on("response", async (response) => {
if (!response.url().includes("/auth/status")) {
return;
}
try {
const status = (await response.json()) as { authenticated?: boolean };
if (status && status.authenticated === false) {
await maybeReauthenticate();
}
} catch {
// Ignore parse errors to avoid flakiness.
}
});
});
export { expect };
+50
View File
@@ -0,0 +1,50 @@
import { promises as fs } from "fs";
import path from "path";
import { request } from "@playwright/test";
import { ensureApiAuthenticated } from "./helpers/auth";
const AUTH_STATE_PATH = path.resolve(__dirname, ".auth/storageState.json");
const waitForServer = async (baseURL: string) => {
const apiRequest = await request.newContext({ baseURL });
const timeoutMs = 60000;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const response = await apiRequest.get("/health");
if (response.ok()) {
await apiRequest.dispose();
return;
}
} catch {
// Ignore connection errors while server boots.
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await apiRequest.dispose();
throw new Error(`Backend did not become ready within ${timeoutMs}ms`);
};
const globalSetup = async () => {
const baseURL = process.env.API_URL || "http://localhost:8000";
await waitForServer(baseURL);
const apiRequest = await request.newContext({
baseURL,
extraHTTPHeaders: {
Connection: "close",
},
});
try {
await ensureApiAuthenticated(apiRequest);
await fs.mkdir(path.dirname(AUTH_STATE_PATH), { recursive: true });
await apiRequest.storageState({ path: AUTH_STATE_PATH });
} finally {
await apiRequest.dispose();
}
};
export default globalSetup;
+14
View File
@@ -0,0 +1,14 @@
import { promises as fs } from "fs";
import path from "path";
const AUTH_STATE_PATH = path.resolve(__dirname, ".auth/storageState.json");
const globalTeardown = async () => {
try {
await fs.unlink(AUTH_STATE_PATH);
} catch {
// Ignore missing auth state file.
}
};
export default globalTeardown;
+201 -26
View File
@@ -1,4 +1,6 @@
import { APIRequestContext, expect } from "@playwright/test";
import { APIRequestContext } from "@playwright/test";
import { expect } from "@playwright/test";
// Default ports match the Playwright config
const DEFAULT_BACKEND_PORT = 8000;
@@ -6,7 +8,7 @@ const DEFAULT_BACKEND_PORT = 8000;
export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`;
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
// Track authenticated API contexts
const authenticatedContexts = new WeakSet<APIRequestContext>();
@@ -20,11 +22,18 @@ export async function ensureAuthenticated(request: APIRequestContext): Promise<v
// Check current auth status
const statusResp = await request.get(`${API_URL}/auth/status`);
if (!statusResp.ok()) {
if (statusResp.status() === 401) {
authenticatedContexts.delete(request);
}
throw new Error(`Failed to check auth status: ${statusResp.status()}`);
}
const status = (await statusResp.json()) as { enabled: boolean; authenticated: boolean };
const status = (await statusResp.json()) as {
enabled: boolean;
authenticated: boolean;
bootstrapRequired?: boolean;
};
if (!status.enabled) {
// Auth is disabled, mark as "authenticated"
authenticatedContexts.add(request);
@@ -36,19 +45,53 @@ export async function ensureAuthenticated(request: APIRequestContext): Promise<v
return;
}
// Need to login
const csrfHeaders = await getCsrfHeaders(request);
const loginResp = await request.post(`${API_URL}/auth/login`, {
headers: {
if (status.bootstrapRequired) {
const bootstrapHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
...csrfHeaders,
},
});
const bootstrapResp = await request.post(`${API_URL}/auth/bootstrap`, {
headers: bootstrapHeaders,
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
if (!bootstrapResp.ok()) {
const text = await bootstrapResp.text();
throw new Error(`API bootstrap failed: ${bootstrapResp.status()} ${text}`);
}
authenticatedContexts.add(request);
return;
}
// Need to login
let loginHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
let loginResp = await request.post(`${API_URL}/auth/login`, {
headers: loginHeaders,
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
if (!loginResp.ok() && loginResp.status() === 403) {
await refreshCsrfInfo(request);
loginHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
loginResp = await request.post(`${API_URL}/auth/login`, {
headers: loginHeaders,
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
}
if (!loginResp.ok()) {
const text = await loginResp.text();
throw new Error(`API authentication failed: ${loginResp.status()} ${text}`);
@@ -67,12 +110,18 @@ type CsrfInfo = {
headerName: string;
};
const buildBaseHeaders = (_request: APIRequestContext): Record<string, string> => ({
origin: process.env.BASE_URL || "http://localhost:5173",
});
// Cache CSRF tokens per Playwright request context so parallel tests don't race.
const csrfInfoByRequest = new WeakMap<APIRequestContext, CsrfInfo>();
const csrfFetchByRequest = new WeakMap<APIRequestContext, Promise<CsrfInfo>>();
const fetchCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const response = await request.get(`${API_URL}/csrf-token`);
const response = await request.get(`${API_URL}/csrf-token`, {
headers: buildBaseHeaders(request),
});
if (!response.ok()) {
const text = await response.text();
throw new Error(
@@ -127,6 +176,11 @@ const refreshCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> =>
return promise;
};
export const refreshCsrfToken = async (request: APIRequestContext): Promise<void> => {
authenticatedContexts.delete(request);
await refreshCsrfInfo(request);
};
export async function getCsrfHeaders(
request: APIRequestContext
): Promise<Record<string, string>> {
@@ -138,6 +192,7 @@ const withCsrfHeaders = async (
request: APIRequestContext,
headers: Record<string, string> = {}
): Promise<Record<string, string>> => ({
...buildBaseHeaders(request),
...headers,
...(await getCsrfHeaders(request)),
});
@@ -190,7 +245,7 @@ export async function createDrawing(
overrides: CreateDrawingOptions = {}
): Promise<DrawingRecord> {
await ensureAuthenticated(request);
const payload = { ...defaultDrawingPayload(), ...overrides };
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
@@ -199,6 +254,26 @@ export async function createDrawing(
data: payload,
});
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
const retryHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
response = await request.post(`${API_URL}/drawings`, {
headers: retryHeaders,
data: payload,
});
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.post(`${API_URL}/drawings`, {
headers,
data: payload,
});
}
// Retry once with a fresh token in case it expired or the cache was primed under
// a different clientId (rare, but can happen under parallelism / CI proxies).
if (!response.ok() && response.status() === 403) {
@@ -216,7 +291,14 @@ export async function createDrawing(
const text = await response.text();
throw new Error(`Failed to create drawing: ${response.status()} ${text}`);
}
return (await response.json()) as DrawingRecord;
const created = (await response.json()) as DrawingRecord;
try {
await request.get(`${API_URL}/drawings/${created.id}`);
} catch {
// Ignore warm-up failures to keep tests resilient.
}
return created;
}
export async function getDrawing(
@@ -224,7 +306,20 @@ export async function getDrawing(
id: string
): Promise<DrawingRecord> {
await ensureAuthenticated(request);
const response = await request.get(`${API_URL}/drawings/${id}`);
let response = await request.get(`${API_URL}/drawings/${id}`);
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
response = await request.get(`${API_URL}/drawings/${id}`);
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.get(`${API_URL}/drawings/${id}`);
}
expect(response.ok()).toBe(true);
return (await response.json()) as DrawingRecord;
}
@@ -234,15 +329,25 @@ export async function deleteDrawing(
id: string
): Promise<void> {
await ensureAuthenticated(request);
const headers = await withCsrfHeaders(request);
let headers = await withCsrfHeaders(request);
let response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
headers = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
}
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/drawings/${id}`, {
headers: retryHeaders,
});
headers = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
}
if (!response.ok()) {
@@ -252,6 +357,12 @@ export async function deleteDrawing(
throw new Error(`Failed to delete drawing ${id}: ${response.status()} ${text}`);
}
}
try {
await request.get(`${API_URL}/drawings/${id}`);
} catch {
// Ignore cache warm-up failures.
}
}
export async function listDrawings(
@@ -270,9 +381,25 @@ export async function listDrawings(
if (options.includeData) params.set("includeData", "true");
const query = params.toString();
const response = await request.get(
let response = await request.get(
`${API_URL}/drawings${query ? `?${query}` : ""}`
);
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
response = await request.get(
`${API_URL}/drawings${query ? `?${query}` : ""}`
);
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.get(
`${API_URL}/drawings${query ? `?${query}` : ""}`
);
}
expect(response.ok()).toBe(true);
return (await response.json()) as DrawingRecord[];
}
@@ -289,6 +416,26 @@ export async function createCollection(
data: { name },
});
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
const retryHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
response = await request.post(`${API_URL}/collections`, {
headers: retryHeaders,
data: { name },
});
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.post(`${API_URL}/collections`, {
headers,
data: { name },
});
}
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request, {
@@ -308,7 +455,19 @@ export async function listCollections(
request: APIRequestContext
): Promise<CollectionRecord[]> {
await ensureAuthenticated(request);
const response = await request.get(`${API_URL}/collections`);
let response = await request.get(`${API_URL}/collections`);
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
response = await request.get(`${API_URL}/collections`);
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.get(`${API_URL}/collections`);
}
expect(response.ok()).toBe(true);
return (await response.json()) as CollectionRecord[];
}
@@ -318,15 +477,25 @@ export async function deleteCollection(
id: string
): Promise<void> {
await ensureAuthenticated(request);
const headers = await withCsrfHeaders(request);
let headers = await withCsrfHeaders(request);
let response = await request.delete(`${API_URL}/collections/${id}`, { headers });
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
headers = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/collections/${id}`, { headers });
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.delete(`${API_URL}/collections/${id}`, { headers });
}
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/collections/${id}`, {
headers: retryHeaders,
});
headers = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/collections/${id}`, { headers });
}
if (!response.ok()) {
@@ -335,4 +504,10 @@ export async function deleteCollection(
throw new Error(`Failed to delete collection ${id}: ${response.status()} ${text}`);
}
}
try {
await request.get(`${API_URL}/collections`);
} catch {
// Ignore cache warm-up failures.
}
}
+146 -24
View File
@@ -1,34 +1,44 @@
import { APIRequestContext, Page } from "@playwright/test";
import { API_URL, getCsrfHeaders } from "./api";
import { API_URL, getCsrfHeaders, refreshCsrfToken } from "./api";
const BASE_URL = process.env.API_URL || API_URL;
type AuthStatus = {
enabled: boolean;
authenticated: boolean;
bootstrapRequired?: boolean;
};
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
const authStatusCache = new WeakMap<APIRequestContext, AuthStatus>();
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
const fetchAuthStatus = async (request: APIRequestContext): Promise<AuthStatus> => {
const cached = authStatusCache.get(request);
// Only use cache if we're already authenticated
if (cached?.authenticated) return cached;
const maxAttempts = 3;
const response = await request.get(`${API_URL}/auth/status`);
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to fetch auth status: ${response.status()} ${text}`);
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await request.get(`${BASE_URL}/auth/status`);
if (response.ok()) {
return (await response.json()) as AuthStatus;
}
const text = await response.text();
if (response.status() === 429 && attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 1000 + attempt * 500));
continue;
}
throw new Error(`Failed to fetch auth status: ${response.status()} ${text}`);
} catch (error) {
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 500 + attempt * 250));
continue;
}
throw error;
}
}
const data = (await response.json()) as AuthStatus;
authStatusCache.set(request, data);
return data;
};
const setAuthStatus = (request: APIRequestContext, status: AuthStatus) => {
authStatusCache.set(request, status);
throw new Error("Failed to fetch auth status");
};
export const ensureApiAuthenticated = async (request: APIRequestContext) => {
@@ -37,11 +47,44 @@ export const ensureApiAuthenticated = async (request: APIRequestContext) => {
return;
}
const headers = await getCsrfHeaders(request);
const response = await request.post(`${API_URL}/auth/login`, {
if (status.bootstrapRequired) {
let response = await request.post(`${BASE_URL}/auth/bootstrap`, {
headers: {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
},
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
if (!response.ok() && response.status() === 403) {
await refreshCsrfToken(request);
response = await request.post(`${BASE_URL}/auth/bootstrap`, {
headers: {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
},
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
}
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to bootstrap test session: ${response.status()} ${text}`);
}
return;
}
let response = await request.post(`${BASE_URL}/auth/login`, {
headers: {
"Content-Type": "application/json",
...headers,
...(await getCsrfHeaders(request)),
},
data: {
username: AUTH_USERNAME,
@@ -49,14 +92,93 @@ export const ensureApiAuthenticated = async (request: APIRequestContext) => {
},
});
if (!response.ok() && response.status() === 403) {
await refreshCsrfToken(request);
const freshHeaders = {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
};
response = await request.post(`${BASE_URL}/auth/login`, {
headers: freshHeaders,
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
}
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to authenticate test session: ${response.status()} ${text}`);
}
setAuthStatus(request, { enabled: true, authenticated: true });
};
export const ensurePageAuthenticated = async (page: Page) => {
type EnsureAuthOptions = {
skipNavigation?: boolean;
};
export const ensurePageAuthenticated = async (
page: Page,
{ skipNavigation = false }: EnsureAuthOptions = {}
) => {
await ensureApiAuthenticated(page.request);
const storageState = await page.request.storageState();
if (storageState.cookies.length > 0) {
await page.context().addCookies(
storageState.cookies.filter((cookie) => cookie.name && cookie.value)
);
}
if (!skipNavigation) {
await page.goto("/", { waitUntil: "domcontentloaded" });
}
const dashboardReady = page.getByPlaceholder("Search drawings...");
const identifierField = page.getByLabel("Username or Email");
const passwordField = page.getByLabel("Password");
if (skipNavigation) {
if (await identifierField.isVisible({ timeout: 1000 }).catch(() => false)) {
await identifierField.fill(AUTH_USERNAME);
await passwordField.fill(AUTH_PASSWORD);
const confirmPasswordField = page.getByLabel("Confirm Password");
if (await confirmPasswordField.isVisible().catch(() => false)) {
await confirmPasswordField.fill(AUTH_PASSWORD);
}
await page
.getByRole("button", { name: /sign in|create admin|create account/i })
.click();
await dashboardReady.waitFor({ state: "visible", timeout: 30000 });
}
return;
}
await Promise.race([
dashboardReady.waitFor({ state: "visible", timeout: 15000 }),
identifierField.waitFor({ state: "visible", timeout: 15000 }),
]);
if (await dashboardReady.isVisible().catch(() => false)) {
return;
}
if (await identifierField.isVisible().catch(() => false)) {
await identifierField.fill(AUTH_USERNAME);
await passwordField.fill(AUTH_PASSWORD);
const confirmPasswordField = page.getByLabel("Confirm Password");
if (await confirmPasswordField.isVisible().catch(() => false)) {
await confirmPasswordField.fill(AUTH_PASSWORD);
}
await page
.getByRole("button", { name: /sign in|create admin|create account/i })
.click();
await dashboardReady.waitFor({ state: "visible", timeout: 30000 });
return;
}
await dashboardReady.waitFor({ state: "visible", timeout: 15000 });
};
+5
View File
@@ -5,6 +5,7 @@ import {
API_URL,
createDrawing,
deleteDrawing,
ensureAuthenticated,
getCsrfHeaders,
getDrawing,
} from "./helpers/api";
@@ -199,6 +200,7 @@ test.describe("Security - Malicious Content Blocking", () => {
},
};
await ensureAuthenticated(request);
const response = await request.post(`${API_URL}/drawings`, {
headers: {
"Content-Type": "application/json",
@@ -225,6 +227,7 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:");
// Cleanup
await ensureAuthenticated(request);
await request.delete(`${API_URL}/drawings/${drawing.id}`, {
headers: await getCsrfHeaders(request),
});
@@ -240,6 +243,7 @@ test.describe("Security - Malicious Content Blocking", () => {
},
};
await ensureAuthenticated(request);
const response = await request.post(`${API_URL}/drawings`, {
headers: {
"Content-Type": "application/json",
@@ -266,6 +270,7 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(savedFiles["malicious-image"].dataURL).not.toContain("<script>");
// Cleanup
await ensureAuthenticated(request);
await request.delete(`${API_URL}/drawings/${drawing.id}`, {
headers: await getCsrfHeaders(request),
});
+45
View File
@@ -0,0 +1,45 @@
import { test, expect } from "./fixtures";
import { API_URL } from "./helpers/api";
const registerUser = async (request: any, payload: any) => {
const csrfResponse = await request.get(`${API_URL}/csrf-token`);
if (!csrfResponse.ok()) {
throw new Error(`Failed to get CSRF token: ${csrfResponse.status()}`);
}
const data = (await csrfResponse.json()) as { token: string; header?: string };
const headerName = data.header || "x-csrf-token";
return request.post(`${API_URL}/auth/register`, {
headers: {
"Content-Type": "application/json",
[headerName]: data.token,
},
data: payload,
});
};
test.describe("Registration", () => {
test("allows admin to enable registration and create user", async ({ page, request }) => {
await page.goto("/settings");
await page.getByRole("button", { name: /Enable registration/i }).click();
await expect(page.getByText(/Registration is enabled/i)).toBeVisible();
const registerResponse = await registerUser(request, {
username: `newuser-${Date.now()}`,
password: "password123",
});
expect(registerResponse.status()).toBe(201);
});
test("blocks registration when disabled", async ({ page, request }) => {
await page.goto("/settings");
await page.getByRole("button", { name: /Disable registration/i }).click();
await expect(page.getByText(/Registration is disabled/i)).toBeVisible();
const registerResponse = await registerUser(request, {
username: "blocked",
password: "password123",
});
expect(registerResponse.status()).toBe(403);
});
});