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:
+59
-32
@@ -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"),
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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,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),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user