diff --git a/README.md b/README.md index 8f508af..94e3603 100644 --- a/README.md +++ b/README.md @@ -156,24 +156,32 @@ Without this, each container generates its own ephemeral CSRF secret, causing to ### Optional Authentication -ExcaliDash can enforce a single username/password to protect the dashboard and API. -Set these backend environment variables to enable it: +ExcaliDash supports multi-user authentication with role-based administration. +The first admin user can be seeded via environment variables, or created in the UI +when no users exist. Set these backend environment variables to bootstrap an admin: ```bash +# Optional (defaults to "admin") AUTH_USERNAME=admin +# Optional (defaults to empty) +AUTH_EMAIL=admin@example.com +# Optional (if omitted, a secure random password is generated and logged) AUTH_PASSWORD=change-me # Recommended: keep sessions stable across restarts AUTH_SESSION_SECRET=your-random-secret # Optional (default: 168 hours) AUTH_SESSION_TTL_HOURS=168 +# Optional (default: 7) +AUTH_MIN_PASSWORD_LENGTH=7 # Optional (default: excalidash_auth) AUTH_COOKIE_NAME=excalidash_auth # Optional: lax | strict | none (use "none" for cross-site hosting) AUTH_COOKIE_SAMESITE=lax ``` -When enabled, the UI prompts for a login before accessing any drawings, -and all API/WebSocket traffic requires the session cookie. +Once logged in, admins can toggle user registration and grant other admins from +Settings. If no admin credentials are provided, the UI will prompt to create the +first admin account. # Development diff --git a/backend/.env.example b/backend/.env.example index 360ac6f..21bf4ec 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,3 +5,9 @@ DATABASE_URL=file:/app/prisma/dev.db FRONTEND_URL=http://localhost:6767 # Optional auth cookie settings: lax | strict | none AUTH_COOKIE_SAMESITE=lax +# Optional auth bootstrap (creates initial admin) +AUTH_USERNAME=admin +AUTH_EMAIL=admin@example.com +# If not set, a random password is generated and logged +AUTH_PASSWORD= +AUTH_MIN_PASSWORD_LENGTH=7 diff --git a/backend/prisma/migrations/20260117000100_add_users_and_system_config/migration.sql b/backend/prisma/migrations/20260117000100_add_users_and_system_config/migration.sql new file mode 100644 index 0000000..e3f8ea7 --- /dev/null +++ b/backend/prisma/migrations/20260117000100_add_users_and_system_config/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT, + "email" TEXT, + "passwordHash" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'USER', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "SystemConfig" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default', + "registrationEnabled" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 23da027..f2f0b82 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -40,3 +40,20 @@ model Library { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model User { + id String @id @default(uuid()) + username String? @unique + email String? @unique + passwordHash String + role String @default("USER") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model SystemConfig { + id String @id @default("default") + registrationEnabled Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/backend/src/__tests__/auth.integration.test.ts b/backend/src/__tests__/auth.integration.test.ts new file mode 100644 index 0000000..103213f --- /dev/null +++ b/backend/src/__tests__/auth.integration.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import request from "supertest"; +import { + cleanupTestDb, + getTestDatabaseUrl, + getTestPrisma, + initTestDb, + setupTestDb, +} from "./testUtils"; + +let prisma = getTestPrisma(); + +describe("Authentication flows", () => { + let app: any; + + beforeAll(async () => { + process.env.DATABASE_URL = getTestDatabaseUrl(); + process.env.AUTH_SESSION_SECRET = "test-secret"; + process.env.NODE_ENV = "test"; + setupTestDb(); + prisma = getTestPrisma(); + await initTestDb(prisma); + const appModule = await import("../index"); + app = appModule.default || appModule.app || appModule; + }); + + beforeEach(async () => { + await cleanupTestDb(prisma); + await initTestDb(prisma); + }); + + const fetchCsrfToken = async () => { + const csrf = await request(app).get("/csrf-token"); + return csrf.body?.token as string; + }; + + const createAdminSession = async () => { + let token = await fetchCsrfToken(); + const bootstrap = await request(app) + .post("/auth/bootstrap") + .set("x-csrf-token", token) + .send({ username: "admin", password: "password123" }); + + if (bootstrap.status !== 201) { + throw new Error(`Bootstrap failed: ${bootstrap.status} ${JSON.stringify(bootstrap.body)}`); + } + + token = await fetchCsrfToken(); + const login = await request(app) + .post("/auth/login") + .set("x-csrf-token", token) + .send({ username: "admin", password: "password123" }); + + return login.headers["set-cookie"] as string[] | undefined; + }; + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("requires bootstrap before registration", async () => { + const token = await fetchCsrfToken(); + const response = await request(app) + .post("/auth/register") + .set("x-csrf-token", token) + .send({ username: "user1", password: "password123" }); + expect(response.status).toBe(409); + }); + + it("bootstraps first admin and logs in", async () => { + const cookie = await createAdminSession(); + expect(cookie).toBeTruthy(); + }); + + it("toggles registration when admin", async () => { + const cookie = await createAdminSession(); + expect(cookie).toBeTruthy(); + + const token = await fetchCsrfToken(); + const toggle = await request(app) + .post("/auth/registration/toggle") + .set("Cookie", cookie) + .set("x-csrf-token", token) + .send({ enabled: true }); + + expect(toggle.status).toBe(200); + expect(toggle.body.registrationEnabled).toBe(true); + }); + + it("registers a new user when enabled", async () => { + const cookie = await createAdminSession(); + expect(cookie).toBeTruthy(); + + let token = await fetchCsrfToken(); + await request(app) + .post("/auth/registration/toggle") + .set("Cookie", cookie) + .set("x-csrf-token", token) + .send({ enabled: true }); + + token = await fetchCsrfToken(); + const register = await request(app) + .post("/auth/register") + .set("x-csrf-token", token) + .send({ username: "user1", password: "password123" }); + + expect(register.status).toBe(201); + expect(register.body.user.username).toBe("user1"); + }); +}); diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index 17ecc8e..992f357 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -2,31 +2,36 @@ import { describe, it, expect, vi } from "vitest"; import { buildAuthConfig, createAuthSessionToken, + generateRandomPassword, getAuthSessionFromCookie, + hashPassword, + isPasswordValid, validateAuthSessionToken, - verifyCredentials, + verifyPassword, } from "../auth"; describe("Auth utilities", () => { - it("disables auth when credentials are missing", () => { + it("builds auth config defaults", () => { const config = buildAuthConfig({}); - expect(config.enabled).toBe(false); + expect(config.enabled).toBe(true); + expect(config.minPasswordLength).toBe(7); }); - it("verifies credentials and validates issued session tokens", () => { + it("hashes and verifies passwords", () => { + const hashed = hashPassword("super-secret"); + expect(verifyPassword("super-secret", hashed)).toBe(true); + expect(verifyPassword("wrong", hashed)).toBe(false); + }); + + it("validates issued session tokens", () => { const config = buildAuthConfig({ - AUTH_USERNAME: "admin", - AUTH_PASSWORD: "super-secret", AUTH_SESSION_SECRET: "test-secret", }); - expect(verifyCredentials(config, "admin", "super-secret")).toBe(true); - expect(verifyCredentials(config, "admin", "wrong")).toBe(false); - - const token = createAuthSessionToken(config, "admin"); + const token = createAuthSessionToken(config, "user-123"); const session = validateAuthSessionToken(config, token); expect(session).not.toBeNull(); - expect(session?.username).toBe("admin"); + expect(session?.userId).toBe("user-123"); }); it("rejects expired session tokens", () => { @@ -34,13 +39,11 @@ describe("Auth utilities", () => { vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z")); const config = buildAuthConfig({ - AUTH_USERNAME: "admin", - AUTH_PASSWORD: "secret", AUTH_SESSION_SECRET: "test-secret", AUTH_SESSION_TTL_HOURS: "0.001", // ~3.6 seconds }); - const token = createAuthSessionToken(config, "admin"); + const token = createAuthSessionToken(config, "user-123"); vi.setSystemTime(new Date("2025-01-01T00:00:10.000Z")); expect(validateAuthSessionToken(config, token)).toBeNull(); @@ -49,14 +52,23 @@ describe("Auth utilities", () => { it("extracts session tokens from cookies", () => { const config = buildAuthConfig({ - AUTH_USERNAME: "admin", - AUTH_PASSWORD: "secret", AUTH_SESSION_SECRET: "test-secret", }); - const token = createAuthSessionToken(config, "admin"); + const token = createAuthSessionToken(config, "user-123"); const cookieHeader = `${config.cookieName}=${encodeURIComponent(token)}; theme=dark`; const session = getAuthSessionFromCookie(cookieHeader, config); - expect(session?.username).toBe("admin"); + expect(session?.userId).toBe("user-123"); + }); + + it("validates password length", () => { + const config = buildAuthConfig({ AUTH_MIN_PASSWORD_LENGTH: "9" }); + expect(isPasswordValid(config, "12345678")).toBe(false); + expect(isPasswordValid(config, "123456789")).toBe(true); + }); + + it("generates random passwords", () => { + const password = generateRandomPassword(32); + expect(password).toHaveLength(32); }); }); diff --git a/backend/src/__tests__/drawings.integration.ts b/backend/src/__tests__/drawings.integration.ts index e14d005..d868809 100644 --- a/backend/src/__tests__/drawings.integration.ts +++ b/backend/src/__tests__/drawings.integration.ts @@ -314,10 +314,11 @@ describe("Security Sanitization - Image Data URLs", () => { // Database integration tests describe("Drawing API - Database Round-Trip", () => { - const prisma = getTestPrisma(); + let prisma: ReturnType; beforeAll(async () => { setupTestDb(); + prisma = getTestPrisma(); await initTestDb(prisma); }); diff --git a/backend/src/__tests__/testUtils.ts b/backend/src/__tests__/testUtils.ts index ee9dbc7..b26791e 100644 --- a/backend/src/__tests__/testUtils.ts +++ b/backend/src/__tests__/testUtils.ts @@ -5,19 +5,24 @@ import { PrismaClient } from "../generated/client"; import path from "path"; import { execSync } from "child_process"; +const testDbSuffix = + process.env.VITEST_POOL_ID || process.env.VITEST_WORKER_ID || String(process.pid); + // Use a separate test database -const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db"); +const TEST_DB_PATH = path.resolve(__dirname, `../../prisma/test-${testDbSuffix}.db`); +const TEST_DATABASE_URL = `file:${TEST_DB_PATH}`; + +export const getTestDatabaseUrl = () => TEST_DATABASE_URL; /** * Get a test Prisma client pointing to the test database */ export const getTestPrisma = () => { - const databaseUrl = `file:${TEST_DB_PATH}`; - process.env.DATABASE_URL = databaseUrl; + process.env.DATABASE_URL = TEST_DATABASE_URL; return new PrismaClient({ datasources: { db: { - url: databaseUrl, + url: TEST_DATABASE_URL, }, }, }); @@ -27,14 +32,23 @@ export const getTestPrisma = () => { * Setup the test database by running migrations */ export const setupTestDb = () => { - const databaseUrl = `file:${TEST_DB_PATH}`; - process.env.DATABASE_URL = databaseUrl; + process.env.DATABASE_URL = TEST_DATABASE_URL; + + // Remove existing DB to avoid locked state in parallel runs + try { + execSync(`rm -f "${TEST_DB_PATH}"`, { + cwd: path.resolve(__dirname, "../../"), + stdio: "pipe", + }); + } catch { + // ignore cleanup failures + } // Run Prisma migrations to create the test database try { execSync("npx prisma db push --skip-generate", { cwd: path.resolve(__dirname, "../../"), - env: { ...process.env, DATABASE_URL: databaseUrl }, + env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL }, stdio: "pipe", }); } catch (error) { @@ -52,6 +66,8 @@ export const cleanupTestDb = async (prisma: PrismaClient) => { await prisma.collection.deleteMany({ where: { id: { not: "trash" } }, }); + await prisma.user.deleteMany({}); + await prisma.systemConfig.deleteMany({}); }; /** @@ -67,6 +83,12 @@ export const initTestDb = async (prisma: PrismaClient) => { data: { id: "trash", name: "Trash" }, }); } + + await prisma.systemConfig.upsert({ + where: { id: "default" }, + update: {}, + create: { id: "default", registrationEnabled: false }, + }); }; /** diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 0325b4f..bac8c68 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -4,16 +4,15 @@ export type AuthSameSite = "lax" | "strict" | "none"; export type AuthConfig = { enabled: boolean; - username: string; - password: string; sessionTtlMs: number; cookieName: string; cookieSameSite: AuthSameSite; secret: Buffer; + minPasswordLength: number; }; export type AuthSession = { - username: string; + userId: string; iat: number; exp: number; }; @@ -46,6 +45,14 @@ const parseSessionTtlHours = (rawValue?: string): number => { return parsed; }; +const parseMinPasswordLength = (rawValue?: string): number => { + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 7; + } + return Math.floor(parsed); +}; + const parseSameSite = (rawValue?: string): AuthSameSite => { if (!rawValue) return DEFAULT_COOKIE_SAMESITE; const normalized = rawValue.trim().toLowerCase(); @@ -73,50 +80,83 @@ const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer => }; export const buildAuthConfig = (env: NodeJS.ProcessEnv = process.env): AuthConfig => { - const username = (env.AUTH_USERNAME || "").trim(); - const password = env.AUTH_PASSWORD || ""; - const enabled = username.length > 0 && password.length > 0; const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS); const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim(); const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE); + const minPasswordLength = parseMinPasswordLength(env.AUTH_MIN_PASSWORD_LENGTH); return { - enabled, - username, - password, + enabled: true, sessionTtlMs: sessionTtlHours * 60 * 60 * 1000, cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME, cookieSameSite, - secret: resolveAuthSecret(enabled, env), + secret: resolveAuthSecret(true, env), + minPasswordLength, }; }; const signToken = (secret: Buffer, payloadB64: string): Buffer => crypto.createHmac("sha256", secret).update(payloadB64, "utf8").digest(); -const safeCompare = (left: string, right: string): boolean => { - const leftHash = crypto.createHash("sha256").update(left, "utf8").digest(); - const rightHash = crypto.createHash("sha256").update(right, "utf8").digest(); - return crypto.timingSafeEqual(leftHash, rightHash); +const PASSWORD_SALT_BYTES = 16; +const PASSWORD_HASH_BYTES = 64; +const PASSWORD_SCRYPT_OPTIONS = { + N: 16384, + r: 8, + p: 1, + maxmem: 64 * 1024 * 1024, }; -export const verifyCredentials = ( - config: AuthConfig, - inputUsername: string, - inputPassword: string -): boolean => { - if (!config.enabled) return false; - return safeCompare(config.username, inputUsername) && safeCompare(config.password, inputPassword); +export const hashPassword = (password: string): string => { + const salt = crypto.randomBytes(PASSWORD_SALT_BYTES); + const derived = crypto.scryptSync(password, salt, PASSWORD_HASH_BYTES, PASSWORD_SCRYPT_OPTIONS); + return `${salt.toString("hex")}:${derived.toString("hex")}`; }; -export const createAuthSessionToken = (config: AuthConfig, username: string): string => { +export const verifyPassword = (password: string, storedHash: string): boolean => { + const [saltHex, derivedHex] = storedHash.split(":"); + if (!saltHex || !derivedHex) return false; + const salt = Buffer.from(saltHex, "hex"); + const derived = crypto.scryptSync(password, salt, PASSWORD_HASH_BYTES, PASSWORD_SCRYPT_OPTIONS); + const expected = Buffer.from(derivedHex, "hex"); + if (expected.length !== derived.length) return false; + return crypto.timingSafeEqual(expected, derived); +}; + +export const isPasswordValid = (config: AuthConfig, password: string): boolean => { + if (typeof password !== "string") return false; + return password.trim().length >= config.minPasswordLength; +}; + +export const isEmailValid = (value: string | null | undefined): boolean => { + if (!value) return false; + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); +}; + +export const isUsernameValid = (value: string | null | undefined): boolean => { + if (!value) return false; + return /^[a-zA-Z0-9._-]+$/.test(value.trim()); +}; + +export const generateRandomPassword = (length: number = 32): string => { + const chars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}|;:,.<>?"; + const bytes = crypto.randomBytes(length); + let result = ""; + for (let i = 0; i < length; i += 1) { + result += chars[bytes[i] % chars.length]; + } + return result; +}; + +export const createAuthSessionToken = (config: AuthConfig, userId: string): string => { if (!config.enabled) { throw new Error("Authentication is not enabled."); } const issuedAt = Date.now(); const payload: AuthSession = { - username, + userId, iat: issuedAt, exp: issuedAt + config.sessionTtlMs, }; @@ -151,7 +191,7 @@ export const validateAuthSessionToken = ( const payloadJson = base64UrlDecode(payloadB64).toString("utf8"); const payload = JSON.parse(payloadJson) as Partial; if ( - typeof payload.username !== "string" || + typeof payload.userId !== "string" || typeof payload.iat !== "number" || typeof payload.exp !== "number" ) { @@ -190,6 +230,9 @@ export const getAuthSessionFromCookie = ( return validateAuthSessionToken(config, token); }; +export const buildAuthIdentifier = (user: { username?: string | null; email?: string | null }) => + user.username || user.email || ""; + export const buildAuthCookieOptions = ( secure: boolean, sameSite: AuthSameSite, diff --git a/backend/src/index.ts b/backend/src/index.ts index 6d65121..d2f6544 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,9 +26,15 @@ import { import { buildAuthConfig, buildAuthCookieOptions, + buildAuthIdentifier, createAuthSessionToken, + generateRandomPassword, getAuthSessionFromCookie, - verifyCredentials, + hashPassword, + isEmailValid, + isPasswordValid, + isUsernameValid, + verifyPassword, } from "./auth"; dotenv.config(); @@ -48,19 +54,16 @@ const resolveDatabaseUrl = (rawUrl?: string) => { // Prisma treats relative SQLite paths as relative to the schema directory // (i.e. `backend/prisma/schema.prisma`). Historically this project used - // `file:./prisma/dev.db`, which Prisma interprets as `prisma/prisma/dev.db`. - // To keep runtime and migrations aligned: - // - Prefer resolving relative paths against `backend/prisma` - // - But if the path already includes a leading `prisma/`, resolve from repo root + // `file:./dev.db`, which Prisma interprets as `backend/prisma/dev.db`. + // To keep runtime and migrations aligned, resolve relative paths against + // `backend/prisma` and strip any leading `prisma/` prefix. const prismaDir = path.resolve(backendRoot, "prisma"); const normalizedRelative = filePath.replace(/^\.\/?/, ""); - const hasLeadingPrismaDir = - normalizedRelative === "prisma" || - normalizedRelative.startsWith("prisma/"); + const sanitizedRelative = normalizedRelative.replace(/^prisma\/?/, ""); const absolutePath = path.isAbsolute(filePath) ? filePath - : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative); + : path.resolve(prismaDir, sanitizedRelative); return `file:${absolutePath}`; }; @@ -104,11 +107,7 @@ const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL); console.log("Allowed origins:", allowedOrigins); const authConfig = buildAuthConfig(); -if (authConfig.enabled) { - console.log(`[auth] Enabled for user "${authConfig.username}".`); -} else { - console.log("[auth] Disabled (AUTH_USERNAME/AUTH_PASSWORD not set)."); -} +console.log("[auth] Auth middleware enabled."); const uploadDir = path.resolve(__dirname, "../uploads"); @@ -152,6 +151,89 @@ const io = new Server(httpServer, { maxHttpBufferSize: 1e8, }); const prisma = new PrismaClient(); + +const DEFAULT_SYSTEM_CONFIG_ID = "default"; +const DEFAULT_ADMIN_USERNAME = "admin"; + +type AuthenticatedUser = { + id: string; + username: string | null; + email: string | null; + role: string; +}; + +const toAuthUser = (user: AuthenticatedUser) => ({ + id: user.id, + username: user.username, + email: user.email, + role: user.role, +}); + +const ensureSystemConfig = async () => { + await prisma.systemConfig.upsert({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + update: {}, + create: { id: DEFAULT_SYSTEM_CONFIG_ID, registrationEnabled: false }, + }); +}; + +const getSystemConfig = async () => + prisma.systemConfig.findUnique({ where: { id: DEFAULT_SYSTEM_CONFIG_ID } }); + +const resolveInitialAdminUser = () => { + const rawUsername = (process.env.AUTH_USERNAME || "").trim(); + const rawEmail = (process.env.AUTH_EMAIL || "").trim(); + const username = rawUsername.length > 0 ? rawUsername : DEFAULT_ADMIN_USERNAME; + const email = rawEmail.length > 0 ? rawEmail : null; + let password = process.env.AUTH_PASSWORD || ""; + let generatedPassword = false; + + if (!password || password.trim().length < authConfig.minPasswordLength) { + password = generateRandomPassword(32); + generatedPassword = true; + } + + return { + username, + email, + password, + generatedPassword, + }; +}; + +const ensureInitialAdminUser = async () => { + const existingUsers = await prisma.user.count(); + if (existingUsers > 0) return; + + const resolved = resolveInitialAdminUser(); + if (resolved.username && !isUsernameValid(resolved.username)) { + throw new Error("Invalid AUTH_USERNAME value."); + } + if (resolved.email && !isEmailValid(resolved.email)) { + throw new Error("Invalid AUTH_EMAIL value."); + } + + const passwordHash = hashPassword(resolved.password); + + const created = await prisma.user.create({ + data: { + username: resolved.username, + email: resolved.email, + passwordHash, + role: "ADMIN", + }, + }); + + const identifier = buildAuthIdentifier(created) || "admin"; + if (resolved.generatedPassword) { + console.warn( + `[auth] Generated admin password for ${identifier}: ${resolved.password}` + ); + } else { + console.log(`[auth] Initial admin user "${identifier}" created from env.`); + } +}; + const parseJsonField = ( rawValue: string | null | undefined, fallback: T @@ -403,35 +485,85 @@ const authExemptPaths = new Set([ "/auth/status", "/auth/login", "/auth/logout", + "/auth/register", + "/auth/bootstrap", + "/auth/registration/toggle", + "/auth/admins", ]); -const authMiddleware = ( +const authNeedsSession = new Set([ + "/auth/logout", + "/auth/registration/toggle", + "/auth/admins", +]); + +const fetchSessionUser = async ( + session: ReturnType +): Promise => { + if (!session) return null; + return prisma.user.findUnique({ + where: { id: session.userId }, + select: { id: true, username: true, email: true, role: true }, + }); +}; + +const authMiddleware = async ( req: express.Request, res: express.Response, next: express.NextFunction ) => { - if (!authConfig.enabled) { - return next(); - } + try { + if (!authConfig.enabled) { + return next(); + } - if (req.method === "OPTIONS") { - return next(); - } + if (req.method === "OPTIONS") { + return next(); + } - if (authExemptPaths.has(req.path)) { - return next(); - } + const session = getAuthSessionFromCookie(req.headers.cookie, authConfig); + const sessionUser = await fetchSessionUser(session); - const session = getAuthSessionFromCookie(req.headers.cookie, authConfig); - if (!session) { - return res.status(401).json({ - error: "Unauthorized", - message: "Authentication required", - }); - } + if (authExemptPaths.has(req.path)) { + if (session && !sessionUser) { + res.clearCookie( + authConfig.cookieName, + buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite) + ); + } - res.locals.authUser = session.username; - next(); + if (authNeedsSession.has(req.path) && !sessionUser) { + return res.status(401).json({ + error: "Unauthorized", + message: "Authentication required", + }); + } + + if (sessionUser) { + res.locals.authUserId = sessionUser.id; + } + + return next(); + } + + if (!sessionUser) { + if (session) { + res.clearCookie( + authConfig.cookieName, + buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite) + ); + } + return res.status(401).json({ + error: "Unauthorized", + message: "Authentication required", + }); + } + + res.locals.authUserId = sessionUser.id; + next(); + } catch (error) { + next(error); + } }; // CSRF validation middleware for state-changing requests @@ -507,41 +639,70 @@ const authLoginSchema = z.object({ password: z.string().min(1).max(512), }); -app.get("/auth/status", (req, res) => { +const authRegisterSchema = z.object({ + username: z.string().trim().min(1).max(200).optional(), + email: z.string().trim().email().max(200).optional(), + password: z.string().min(1).max(512), +}); + +const adminUpdateSchema = z.object({ + identifier: z.string().trim().min(1).max(200), + role: z.enum(["ADMIN", "USER"]), +}); + +app.get("/auth/status", async (req, res) => { const session = getAuthSessionFromCookie(req.headers.cookie, authConfig); + const config = await getSystemConfig(); + const totalUsers = await prisma.user.count(); + const user = await fetchSessionUser(session); + + if (session && !user) { + res.clearCookie( + authConfig.cookieName, + buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite) + ); + } + res.setHeader("Cache-Control", "no-store"); res.json({ enabled: authConfig.enabled, - authenticated: Boolean(session), - user: session ? { username: session.username } : null, + authenticated: Boolean(user), + registrationEnabled: Boolean(config?.registrationEnabled), + bootstrapRequired: totalUsers === 0, + user: user ? toAuthUser(user) : null, }); }); -app.post("/auth/login", (req, res) => { - if (!authConfig.enabled) { - return res.status(404).json({ - error: "Authentication disabled", - message: "Authentication is not enabled on this server.", - }); - } - +app.post("/auth/login", async (req, res) => { const parsed = authLoginSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "Invalid payload", - message: "Username and password are required.", + message: "Username or email and password are required.", }); } const { username, password } = parsed.data; - if (!verifyCredentials(authConfig, username, password)) { + const identifier = username.trim().toLowerCase(); + + const user = await prisma.user.findFirst({ + where: { + OR: [ + { username: identifier }, + { username: username.trim() }, + { email: identifier }, + ], + }, + }); + + if (!user || !verifyPassword(password, user.passwordHash)) { return res.status(401).json({ error: "Unauthorized", - message: "Invalid username or password.", + message: "Invalid username/email or password.", }); } - const token = createAuthSessionToken(authConfig, authConfig.username); + const token = createAuthSessionToken(authConfig, user.id); res.cookie( authConfig.cookieName, token, @@ -554,7 +715,7 @@ app.post("/auth/login", (req, res) => { res.setHeader("Cache-Control", "no-store"); return res.json({ authenticated: true, - user: { username: authConfig.username }, + user: toAuthUser(user), }); }); @@ -567,6 +728,292 @@ app.post("/auth/logout", (req, res) => { return res.json({ authenticated: false }); }); +app.post("/auth/register", async (req, res) => { + const config = await getSystemConfig(); + const existingUsers = await prisma.user.count(); + + if (existingUsers === 0) { + return res.status(409).json({ + error: "Bootstrap required", + message: "Create the first admin user before registering others.", + }); + } + + if (!config?.registrationEnabled) { + return res.status(403).json({ + error: "Registration disabled", + message: "User registration is disabled.", + }); + } + + const parsed = authRegisterSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid payload", + message: "Username or email plus password are required.", + }); + } + + const { username, email, password } = parsed.data; + const normalizedUsername = username?.trim() || null; + const normalizedEmail = email?.trim().toLowerCase() || null; + + if (!normalizedUsername && !normalizedEmail) { + return res.status(400).json({ + error: "Missing identifier", + message: "Provide at least a username or email address.", + }); + } + + if (normalizedUsername && !isUsernameValid(normalizedUsername)) { + return res.status(400).json({ + error: "Invalid username", + message: "Username may contain letters, numbers, dots, underscores, and dashes.", + }); + } + + if (normalizedEmail && !isEmailValid(normalizedEmail)) { + return res.status(400).json({ + error: "Invalid email", + message: "Please provide a valid email address.", + }); + } + + if (!isPasswordValid(authConfig, password)) { + return res.status(400).json({ + error: "Weak password", + message: `Password must be at least ${authConfig.minPasswordLength} characters.`, + }); + } + + if (normalizedUsername) { + const existingUsername = await prisma.user.findUnique({ + where: { username: normalizedUsername }, + }); + if (existingUsername) { + return res.status(409).json({ + error: "Username taken", + message: "That username is already in use.", + }); + } + } + + if (normalizedEmail) { + const existingEmail = await prisma.user.findUnique({ + where: { email: normalizedEmail }, + }); + if (existingEmail) { + return res.status(409).json({ + error: "Email taken", + message: "That email is already in use.", + }); + } + } + + const user = await prisma.user.create({ + data: { + username: normalizedUsername, + email: normalizedEmail, + passwordHash: hashPassword(password), + role: "USER", + }, + }); + + return res.status(201).json({ + user: toAuthUser(user), + }); +}); + +app.post("/auth/bootstrap", async (req, res) => { + try { + const existingUsers = await prisma.user.count(); + if (existingUsers > 0) { + return res.status(409).json({ + error: "Already initialized", + message: "Users already exist.", + }); + } + + const parsed = authRegisterSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid payload", + message: "Username or email plus password are required.", + }); + } + + const { username, email, password } = parsed.data; + const normalizedUsername = username?.trim() || DEFAULT_ADMIN_USERNAME; + const normalizedEmail = email?.trim().toLowerCase() || null; + + if (normalizedUsername && !isUsernameValid(normalizedUsername)) { + return res.status(400).json({ + error: "Invalid username", + message: "Username may contain letters, numbers, dots, underscores, and dashes.", + }); + } + + if (normalizedEmail && !isEmailValid(normalizedEmail)) { + return res.status(400).json({ + error: "Invalid email", + message: "Please provide a valid email address.", + }); + } + + if (!isPasswordValid(authConfig, password)) { + return res.status(400).json({ + error: "Weak password", + message: `Password must be at least ${authConfig.minPasswordLength} characters.`, + }); + } + + if (normalizedUsername) { + const existingUsername = await prisma.user.findUnique({ + where: { username: normalizedUsername }, + }); + if (existingUsername) { + return res.status(409).json({ + error: "Username taken", + message: "That username is already in use.", + }); + } + } + + if (normalizedEmail) { + const existingEmail = await prisma.user.findUnique({ + where: { email: normalizedEmail }, + }); + if (existingEmail) { + return res.status(409).json({ + error: "Email taken", + message: "That email is already in use.", + }); + } + } + + const user = await prisma.user.create({ + data: { + username: normalizedUsername, + email: normalizedEmail, + passwordHash: hashPassword(password), + role: "ADMIN", + }, + }); + + const token = createAuthSessionToken(authConfig, user.id); + res.cookie( + authConfig.cookieName, + token, + buildAuthCookieOptions( + isRequestSecure(req), + authConfig.cookieSameSite, + authConfig.sessionTtlMs + ) + ); + + res.setHeader("Cache-Control", "no-store"); + return res.status(201).json({ + authenticated: true, + user: toAuthUser(user), + }); + } catch (error) { + console.error("Bootstrap failed:", error); + return res.status(500).json({ + error: "Bootstrap failed", + message: "Unable to bootstrap admin user.", + }); + } +}); + +app.post("/auth/registration/toggle", async (req, res) => { + const session = getAuthSessionFromCookie(req.headers.cookie, authConfig); + if (!session) { + return res.status(401).json({ + error: "Unauthorized", + message: "Authentication required", + }); + } + + const currentUser = await prisma.user.findUnique({ + where: { id: session.userId }, + }); + + if (!currentUser || currentUser.role !== "ADMIN") { + return res.status(403).json({ + error: "Forbidden", + message: "Admin privileges required.", + }); + } + + const toggleSchema = z.object({ enabled: z.boolean() }); + const parsed = toggleSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid payload", + message: "Expected { enabled: boolean }.", + }); + } + + const updated = await prisma.systemConfig.upsert({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + update: { registrationEnabled: parsed.data.enabled }, + create: { id: DEFAULT_SYSTEM_CONFIG_ID, registrationEnabled: parsed.data.enabled }, + }); + + return res.json({ registrationEnabled: updated.registrationEnabled }); +}); + +app.post("/auth/admins", async (req, res) => { + const session = getAuthSessionFromCookie(req.headers.cookie, authConfig); + if (!session) { + return res.status(401).json({ + error: "Unauthorized", + message: "Authentication required", + }); + } + + const currentUser = await prisma.user.findUnique({ + where: { id: session.userId }, + }); + + if (!currentUser || currentUser.role !== "ADMIN") { + return res.status(403).json({ + error: "Forbidden", + message: "Admin privileges required.", + }); + } + + const parsed = adminUpdateSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid payload", + message: "Expected { identifier, role }.", + }); + } + + const target = await prisma.user.findFirst({ + where: { + OR: [{ username: parsed.data.identifier }, { email: parsed.data.identifier }], + }, + }); + + if (!target) { + return res.status(404).json({ + error: "User not found", + message: "No user matches that identifier.", + }); + } + + const updated = await prisma.user.update({ + where: { id: target.id }, + data: { role: parsed.data.role }, + }); + + return res.json({ user: toAuthUser(updated) }); +}); + const filesFieldSchema = z .union([z.record(z.string(), z.any()), z.null()]) .optional() @@ -765,7 +1212,7 @@ if (authConfig.enabled) { if (!session) { return next(new Error("Unauthorized")); } - (socket as { authUser?: string }).authUser = session.username; + (socket as { authUserId?: string }).authUserId = session.userId; return next(); }); } @@ -1409,5 +1856,9 @@ const ensureTrashCollection = async () => { httpServer.listen(PORT, async () => { await initializeUploadDir(); await ensureTrashCollection(); + await ensureSystemConfig(); + await ensureInitialAdminUser(); console.log(`Server running on port ${PORT}`); }); + +export default app; diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index fcbe5f1..140338f 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -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"), }); + diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index dfb36ec..7cb1fa2 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -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(); }); }); diff --git a/e2e/tests/dashboard-workflows.spec.ts b/e2e/tests/dashboard-workflows.spec.ts index 92e3b9f..22a4c39 100644 --- a/e2e/tests/dashboard-workflows.spec.ts +++ b/e2e/tests/dashboard-workflows.spec.ts @@ -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); diff --git a/e2e/tests/drag-and-drop.spec.ts b/e2e/tests/drag-and-drop.spec.ts index 8c76545..82d94b4 100644 --- a/e2e/tests/drag-and-drop.spec.ts +++ b/e2e/tests/drag-and-drop.spec.ts @@ -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}`); diff --git a/e2e/tests/export-import.spec.ts b/e2e/tests/export-import.spec.ts index 1de49e9..7b525e1 100644 --- a/e2e/tests/export-import.spec.ts +++ b/e2e/tests/export-import.spec.ts @@ -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", diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts index f73bb1e..187ecdb 100644 --- a/e2e/tests/fixtures.ts +++ b/e2e/tests/fixtures.ts @@ -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({ + skipAuth: [false, { option: true }], }); +test.beforeEach(async ({ page, skipAuth }) => { + if (skipAuth) { + return; + } + + await ensurePageAuthenticated(page); + + let authCheckInFlight: Promise | 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 }; + diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts new file mode 100644 index 0000000..227b293 --- /dev/null +++ b/e2e/tests/global-setup.ts @@ -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; diff --git a/e2e/tests/global-teardown.ts b/e2e/tests/global-teardown.ts new file mode 100644 index 0000000..838fb2f --- /dev/null +++ b/e2e/tests/global-teardown.ts @@ -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; diff --git a/e2e/tests/helpers/api.ts b/e2e/tests/helpers/api.ts index 3266c10..976bc3b 100644 --- a/e2e/tests/helpers/api.ts +++ b/e2e/tests/helpers/api.ts @@ -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(); @@ -20,11 +22,18 @@ export async function ensureAuthenticated(request: APIRequestContext): Promise => ({ + 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(); const csrfFetchByRequest = new WeakMap>(); const fetchCsrfInfo = async (request: APIRequestContext): Promise => { - 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 => return promise; }; +export const refreshCsrfToken = async (request: APIRequestContext): Promise => { + authenticatedContexts.delete(request); + await refreshCsrfInfo(request); +}; + export async function getCsrfHeaders( request: APIRequestContext ): Promise> { @@ -138,6 +192,7 @@ const withCsrfHeaders = async ( request: APIRequestContext, headers: Record = {} ): Promise> => ({ + ...buildBaseHeaders(request), ...headers, ...(await getCsrfHeaders(request)), }); @@ -190,7 +245,7 @@ export async function createDrawing( overrides: CreateDrawingOptions = {} ): Promise { 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 { 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 { 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 { 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 { 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. + } } diff --git a/e2e/tests/helpers/auth.ts b/e2e/tests/helpers/auth.ts index 1e7effe..60ecd97 100644 --- a/e2e/tests/helpers/auth.ts +++ b/e2e/tests/helpers/auth.ts @@ -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(); +const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123"; const fetchAuthStatus = async (request: APIRequestContext): Promise => { - 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 }); }; diff --git a/e2e/tests/image-persistence.spec.ts b/e2e/tests/image-persistence.spec.ts index 637db7f..a80ef0b 100644 --- a/e2e/tests/image-persistence.spec.ts +++ b/e2e/tests/image-persistence.spec.ts @@ -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("