From af07a73a0746c9411d5107d7e833735fad63661c Mon Sep 17 00:00:00 2001 From: Adrian Acala Date: Tue, 20 Jan 2026 19:55:32 -0800 Subject: [PATCH] feat(auth): enhance authentication system with login attempt tracking and configuration options - Added a new `LoginAttempt` model to track login attempts, including rate limiting and lockout functionality. - Introduced environment variables for configuring login rate limits and maximum failures. - Updated the authentication middleware to handle login attempts and enforce rate limits. - Enhanced the user model with indexing for username and email for improved lookup performance. - Modified the `.env.example` file to include new optional authentication settings. - Updated integration tests to cover new login attempt features and authentication state management. --- backend/.env.example | 5 + .../migration.sql | 19 ++ .../migration.sql | 5 + backend/prisma/schema.prisma | 35 ++- .../src/__tests__/auth.integration.test.ts | 49 +++ backend/src/__tests__/auth.test.ts | 5 + backend/src/__tests__/testUtils.ts | 1 + backend/src/auth.ts | 17 +- backend/src/index.ts | 293 +++++++++++++++++- e2e/playwright.config.ts | 11 +- e2e/run-e2e.sh | 2 +- e2e/tests/dashboard-workflows.spec.ts | 6 +- frontend/src/api/index.ts | 14 + 13 files changed, 433 insertions(+), 29 deletions(-) create mode 100644 backend/prisma/migrations/20260120000100_add_login_attempts/migration.sql create mode 100644 backend/prisma/migrations/20260120000100_add_user_lookup_indexes/migration.sql diff --git a/backend/.env.example b/backend/.env.example index 21bf4ec..f6537cc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,6 +3,8 @@ PORT=8000 NODE_ENV=production DATABASE_URL=file:/app/prisma/dev.db FRONTEND_URL=http://localhost:6767 +# Optional auth settings +AUTH_ENABLED=true # Optional auth cookie settings: lax | strict | none AUTH_COOKIE_SAMESITE=lax # Optional auth bootstrap (creates initial admin) @@ -11,3 +13,6 @@ AUTH_EMAIL=admin@example.com # If not set, a random password is generated and logged AUTH_PASSWORD= AUTH_MIN_PASSWORD_LENGTH=7 +# Optional login throttling +LOGIN_RATE_LIMIT_MAX=10 +LOGIN_MAX_FAILURES=5 diff --git a/backend/prisma/migrations/20260120000100_add_login_attempts/migration.sql b/backend/prisma/migrations/20260120000100_add_login_attempts/migration.sql new file mode 100644 index 0000000..43aa20c --- /dev/null +++ b/backend/prisma/migrations/20260120000100_add_login_attempts/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "LoginAttempt" ( + "id" TEXT NOT NULL PRIMARY KEY, + "identifier" TEXT NOT NULL, + "ip" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "failures" INTEGER NOT NULL DEFAULT 0, + "resetTime" DATETIME NOT NULL, + "lockoutUntil" DATETIME, + "lastAttempt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "LoginAttempt_identifier_ip_key" ON "LoginAttempt"("identifier", "ip"); + +-- CreateIndex +CREATE INDEX "LoginAttempt_lastAttempt_idx" ON "LoginAttempt"("lastAttempt"); diff --git a/backend/prisma/migrations/20260120000100_add_user_lookup_indexes/migration.sql b/backend/prisma/migrations/20260120000100_add_user_lookup_indexes/migration.sql new file mode 100644 index 0000000..ca355c4 --- /dev/null +++ b/backend/prisma/migrations/20260120000100_add_user_lookup_indexes/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "User_username_idx" ON "User"("username"); + +-- CreateIndex +CREATE INDEX "User_email_idx" ON "User"("email"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7c67d10..f4828e3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -42,14 +42,17 @@ model Library { } model User { - id String @id @default(uuid()) - username String? @unique - email String? @unique - passwordHash String - mustResetPassword Boolean @default(false) - role String @default("USER") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + username String? @unique + email String? @unique + passwordHash String + mustResetPassword Boolean @default(false) + role String @default("USER") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([username]) + @@index([email]) } model SystemConfig { @@ -58,3 +61,19 @@ model SystemConfig { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model LoginAttempt { + id String @id @default(uuid()) + identifier String + ip String + count Int @default(0) + failures Int @default(0) + resetTime DateTime + lockoutUntil DateTime? + lastAttempt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([identifier, ip]) + @@index([lastAttempt]) +} diff --git a/backend/src/__tests__/auth.integration.test.ts b/backend/src/__tests__/auth.integration.test.ts index 3494b6e..4850f15 100644 --- a/backend/src/__tests__/auth.integration.test.ts +++ b/backend/src/__tests__/auth.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import request from "supertest"; +import { vi } from "vitest"; import { cleanupTestDb, getTestDatabaseUrl, @@ -24,6 +25,11 @@ describe("Authentication flows", () => { app = appModule.default; }); + beforeEach(() => { + delete process.env.LOGIN_RATE_LIMIT_MAX; + delete process.env.LOGIN_MAX_FAILURES; + }); + beforeEach(async () => { await cleanupTestDb(prisma); await initTestDb(prisma); @@ -109,4 +115,47 @@ describe("Authentication flows", () => { expect(register.status).toBe(201); expect(register.body.user.username).toBe("user1"); }); + + it("locks out after repeated failed logins", async () => { + process.env.LOGIN_RATE_LIMIT_MAX = "100"; + process.env.LOGIN_MAX_FAILURES = "2"; + + const token = await fetchCsrfToken(); + await request(app) + .post("/auth/bootstrap") + .set("x-csrf-token", token) + .send({ username: "admin", password: "password123" }); + + let loginToken = await fetchCsrfToken(); + await request(app) + .post("/auth/login") + .set("x-csrf-token", loginToken) + .send({ username: "admin", password: "wrong" }); + + loginToken = await fetchCsrfToken(); + const locked = await request(app) + .post("/auth/login") + .set("x-csrf-token", loginToken) + .send({ username: "admin", password: "wrong" }); + + expect(locked.status).toBe(429); + expect(locked.body.error).toBe("Account locked"); + }); + + it("blocks auth endpoints when disabled", async () => { + process.env.AUTH_ENABLED = "false"; + process.env.NODE_ENV = "test"; + process.env.DATABASE_URL = getTestDatabaseUrl(); + + // Reset module cache so the new env is read + vi.resetModules(); + const appModule = (await import("../index")) as { default: unknown }; + const disabledApp = appModule.default; + + const response = await request(disabledApp).post("/auth/login"); + expect(response.status).toBe(404); + + process.env.AUTH_ENABLED = "true"; + vi.resetModules(); + }, 20000); }); diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index 992f357..cb9aae5 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -17,6 +17,11 @@ describe("Auth utilities", () => { expect(config.minPasswordLength).toBe(7); }); + it("supports disabling auth via env", () => { + const config = buildAuthConfig({ AUTH_ENABLED: "false" }); + expect(config.enabled).toBe(false); + }); + it("hashes and verifies passwords", () => { const hashed = hashPassword("super-secret"); expect(verifyPassword("super-secret", hashed)).toBe(true); diff --git a/backend/src/__tests__/testUtils.ts b/backend/src/__tests__/testUtils.ts index b26791e..ee9b9ae 100644 --- a/backend/src/__tests__/testUtils.ts +++ b/backend/src/__tests__/testUtils.ts @@ -68,6 +68,7 @@ export const cleanupTestDb = async (prisma: PrismaClient) => { }); await prisma.user.deleteMany({}); await prisma.systemConfig.deleteMany({}); + await prisma.loginAttempt.deleteMany({}); }; /** diff --git a/backend/src/auth.ts b/backend/src/auth.ts index bac8c68..7f77737 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -62,6 +62,18 @@ const parseSameSite = (rawValue?: string): AuthSameSite => { return DEFAULT_COOKIE_SAMESITE; }; +const parseAuthEnabled = (rawValue?: string): boolean => { + if (!rawValue) return true; + const normalized = rawValue.trim().toLowerCase(); + if (["false", "0", "no", "off"].includes(normalized)) { + return false; + } + if (["true", "1", "yes", "on"].includes(normalized)) { + return true; + } + return true; +}; + const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer => { if (!enabled) return Buffer.alloc(0); @@ -80,17 +92,18 @@ const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer => }; export const buildAuthConfig = (env: NodeJS.ProcessEnv = process.env): AuthConfig => { + const enabled = parseAuthEnabled(env.AUTH_ENABLED); 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: true, + enabled, sessionTtlMs: sessionTtlHours * 60 * 60 * 1000, cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME, cookieSameSite, - secret: resolveAuthSecret(true, env), + secret: resolveAuthSecret(enabled, env), minPasswordLength, }; }; diff --git a/backend/src/index.ts b/backend/src/index.ts index c3ef654..6aa4141 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -107,7 +107,7 @@ const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL); console.log("Allowed origins:", allowedOrigins); const authConfig = buildAuthConfig(); -console.log("[auth] Auth middleware enabled."); +console.log(`[auth] Auth middleware ${authConfig.enabled ? "enabled" : "disabled"}.`); const uploadDir = path.resolve(__dirname, "../uploads"); @@ -499,6 +499,16 @@ const authExemptPaths = new Set([ "/auth/password", ]); +const authDisabledPaths = new Set([ + "/auth/login", + "/auth/logout", + "/auth/register", + "/auth/bootstrap", + "/auth/registration/toggle", + "/auth/admins", + "/auth/password", +]); + const authNeedsSession = new Set([ "/auth/logout", "/auth/registration/toggle", @@ -641,13 +651,180 @@ const csrfProtectionMiddleware = ( // Apply authentication and CSRF protection to all routes app.use(authMiddleware); + +app.use((req, res, next) => { + if (!authConfig.enabled && authDisabledPaths.has(req.path)) { + return res.status(404).json({ + error: "Not found", + message: "Authentication is disabled.", + }); + } + return next(); +}); + app.use(csrfProtectionMiddleware); +app.use((req, res, next) => { + if (!authConfig.enabled) { + res.locals.authUserId = "anonymous"; + } + next(); +}); + const authLoginSchema = z.object({ username: z.string().trim().min(1).max(200), password: z.string().min(1).max(512), }); +const LOGIN_RATE_LIMIT_WINDOW = 10 * 60 * 1000; +const LOGIN_LOCKOUT_WINDOW = 15 * 60 * 1000; +const getLoginRateLimitMax = () => { + const parsed = Number(process.env.LOGIN_RATE_LIMIT_MAX); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 10; + } + return parsed; +}; +const getLoginMaxFailures = () => { + const parsed = Number(process.env.LOGIN_MAX_FAILURES); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 5; + } + return parsed; +}; +type LoginAttemptEntry = { + id: string; + identifier: string; + ip: string; + count: number; + failures: number; + resetTime: Date; + lockoutUntil: Date | null; + lastAttempt: Date; +}; + +const LOGIN_ATTEMPT_RETENTION = 24 * 60 * 60 * 1000; +const LOGIN_ATTEMPT_CLEANUP_INTERVAL = 10 * 60 * 1000; + +const normalizeLoginAttemptIdentifier = (identifier: string) => { + const trimmed = identifier.trim(); + return trimmed.length > 0 ? trimmed.toLowerCase() : "unknown"; +}; + +const normalizeLoginAttemptIp = (ip: string) => { + const trimmed = ip.trim(); + return trimmed.length > 0 ? trimmed.toLowerCase() : "unknown"; +}; + +const cleanupLoginAttempts = async () => { + const cutoff = new Date(Date.now() - LOGIN_ATTEMPT_RETENTION); + await prisma.loginAttempt.deleteMany({ + where: { + lastAttempt: { lt: cutoff }, + }, + }); +}; + +setInterval(() => { + void cleanupLoginAttempts().catch((error) => { + console.error("Failed to cleanup login attempts:", error); + }); +}, LOGIN_ATTEMPT_CLEANUP_INTERVAL).unref(); + +const resolveLoginAttempt = async (identifier: string, ip: string): Promise => { + const now = new Date(); + const normalizedIdentifier = normalizeLoginAttemptIdentifier(identifier); + const normalizedIp = normalizeLoginAttemptIp(ip); + const resetTime = new Date(now.getTime() + LOGIN_RATE_LIMIT_WINDOW); + const existing = await prisma.loginAttempt.findUnique({ + where: { + identifier_ip: { identifier: normalizedIdentifier, ip: normalizedIp }, + }, + }); + + if (!existing) { + return prisma.loginAttempt.create({ + data: { + identifier: normalizedIdentifier, + ip: normalizedIp, + resetTime, + lastAttempt: now, + }, + }); + } + + const updateData: Prisma.LoginAttemptUpdateInput = { + lastAttempt: now, + }; + + if (existing.lockoutUntil && now >= existing.lockoutUntil) { + updateData.lockoutUntil = null; + updateData.failures = 0; + updateData.count = 0; + updateData.resetTime = resetTime; + } else if (now > existing.resetTime && !existing.lockoutUntil) { + updateData.count = 0; + updateData.resetTime = resetTime; + } + + return prisma.loginAttempt.update({ + where: { id: existing.id }, + data: updateData, + }); +}; + +const incrementLoginAttemptCount = async (entryId: string): Promise => + prisma.loginAttempt.update({ + where: { id: entryId }, + data: { + count: { increment: 1 }, + lastAttempt: new Date(), + }, + }); + +const registerLoginFailure = async (entry: LoginAttemptEntry): Promise => { + const now = new Date(); + const nextFailures = entry.failures + 1; + if (nextFailures >= getLoginMaxFailures()) { + return prisma.loginAttempt.update({ + where: { id: entry.id }, + data: { + failures: nextFailures, + lockoutUntil: new Date(now.getTime() + LOGIN_LOCKOUT_WINDOW), + count: 0, + resetTime: new Date(now.getTime() + LOGIN_RATE_LIMIT_WINDOW), + lastAttempt: now, + }, + }); + } + + return prisma.loginAttempt.update({ + where: { id: entry.id }, + data: { + failures: { increment: 1 }, + lastAttempt: now, + }, + }); +}; + +const clearLoginFailures = async (entryId: string) => { + await prisma.loginAttempt.update({ + where: { id: entryId }, + data: { + failures: 0, + lockoutUntil: null, + count: 0, + resetTime: new Date(Date.now() + LOGIN_RATE_LIMIT_WINDOW), + lastAttempt: new Date(), + }, + }); +}; + +const isLoginLockedOut = (entry: { lockoutUntil: Date | null; failures: number }): boolean => { + if (!entry.lockoutUntil) return false; + return Date.now() < entry.lockoutUntil.getTime(); +}; + const authChangePasswordSchema = z.object({ currentPassword: z.string().min(1).max(512), newPassword: z.string().min(1).max(512), @@ -669,6 +846,17 @@ const adminUpdateSchema = z.object({ }); app.get("/auth/status", async (req, res) => { + if (!authConfig.enabled) { + res.setHeader("Cache-Control", "no-store"); + return res.json({ + enabled: false, + authenticated: true, + registrationEnabled: false, + bootstrapRequired: false, + user: null, + }); + } + const session = getAuthSessionFromCookie(req.headers.cookie, authConfig); const config = await getSystemConfig(); const totalUsers = await prisma.user.count(); @@ -692,6 +880,13 @@ app.get("/auth/status", async (req, res) => { }); app.post("/auth/login", async (req, res) => { + if (!authConfig.enabled) { + return res.status(404).json({ + error: "Not found", + message: "Authentication is disabled.", + }); + } + const parsed = authLoginSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ @@ -702,6 +897,23 @@ app.post("/auth/login", async (req, res) => { const { username, password } = parsed.data; const identifier = username.trim().toLowerCase(); + const ip = req.ip || req.connection.remoteAddress || "unknown"; + + let entry = await resolveLoginAttempt(identifier || "unknown", ip); + if (isLoginLockedOut(entry)) { + return res.status(429).json({ + error: "Account locked", + message: "Too many failed attempts. Please try again later.", + }); + } + + entry = await incrementLoginAttemptCount(entry.id); + if (entry.count > getLoginRateLimitMax()) { + return res.status(429).json({ + error: "Rate limit exceeded", + message: "Too many login attempts. Please try again later.", + }); + } const user = await prisma.user.findFirst({ where: { @@ -722,12 +934,20 @@ app.post("/auth/login", async (req, res) => { }); if (!user || !verifyPassword(password, user.passwordHash)) { + entry = await registerLoginFailure(entry); + if (isLoginLockedOut(entry)) { + return res.status(429).json({ + error: "Account locked", + message: "Too many failed attempts. Please try again later.", + }); + } return res.status(401).json({ error: "Unauthorized", message: "Invalid username/email or password.", }); } + await clearLoginFailures(entry.id); const token = createAuthSessionToken(authConfig, user.id); res.cookie( authConfig.cookieName, @@ -746,6 +966,10 @@ app.post("/auth/login", async (req, res) => { }); app.post("/auth/logout", (req, res) => { + if (!authConfig.enabled) { + return res.status(204).end(); + } + res.clearCookie( authConfig.cookieName, buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite) @@ -755,6 +979,13 @@ app.post("/auth/logout", (req, res) => { }); app.post("/auth/password", async (req, res) => { + if (!authConfig.enabled) { + return res.status(404).json({ + error: "Not found", + message: "Authentication is disabled.", + }); + } + const session = getAuthSessionFromCookie(req.headers.cookie, authConfig); if (!session) { return res.status(401).json({ @@ -805,7 +1036,14 @@ app.post("/auth/password", async (req, res) => { }); app.post("/auth/test/must-reset", async (req, res) => { - if (process.env.NODE_ENV !== "test") { + if (!authConfig.enabled) { + return res.status(404).json({ + error: "Not found", + message: "Authentication is disabled.", + }); + } + + if (process.env.NODE_ENV !== "test" && process.env.NODE_ENV !== "e2e") { return res.status(404).json({ error: "Not found", message: "Endpoint is only available in test environments.", @@ -854,6 +1092,13 @@ app.post("/auth/test/must-reset", async (req, res) => { }); app.post("/auth/register", async (req, res) => { + if (!authConfig.enabled) { + return res.status(404).json({ + error: "Not found", + message: "Authentication is disabled.", + }); + } + const config = await getSystemConfig(); const existingUsers = await prisma.user.count(); @@ -951,6 +1196,13 @@ app.post("/auth/register", async (req, res) => { }); app.post("/auth/bootstrap", async (req, res) => { + if (!authConfig.enabled) { + return res.status(404).json({ + error: "Not found", + message: "Authentication is disabled.", + }); + } + try { const existingUsers = await prisma.user.count(); if (existingUsers > 0) { @@ -1052,6 +1304,13 @@ app.post("/auth/bootstrap", async (req, res) => { }); app.post("/auth/registration/toggle", async (req, res) => { + if (!authConfig.enabled) { + return res.status(404).json({ + error: "Not found", + message: "Authentication is disabled.", + }); + } + const session = getAuthSessionFromCookie(req.headers.cookie, authConfig); if (!session) { return res.status(401).json({ @@ -1090,6 +1349,13 @@ app.post("/auth/registration/toggle", async (req, res) => { }); app.post("/auth/admins", async (req, res) => { + if (!authConfig.enabled) { + return res.status(404).json({ + error: "Not found", + message: "Authentication is disabled.", + }); + } + const session = getAuthSessionFromCookie(req.headers.cookie, authConfig); if (!session) { return res.status(401).json({ @@ -1979,17 +2245,20 @@ const ensureTrashCollection = async () => { } }; +const isTestEnv = process.env.NODE_ENV === "test"; const shouldEnsureInitialAdmin = - process.env.NODE_ENV !== "test" && process.env.SKIP_INITIAL_ADMIN !== "true"; + authConfig.enabled && !isTestEnv && process.env.SKIP_INITIAL_ADMIN !== "true"; -httpServer.listen(PORT, async () => { - await initializeUploadDir(); - await ensureTrashCollection(); - await ensureSystemConfig(); - if (shouldEnsureInitialAdmin) { - await ensureInitialAdminUser(); - } - console.log(`Server running on port ${PORT}`); -}); +if (!isTestEnv || process.env.START_SERVER_IN_TEST === "true") { + httpServer.listen(PORT, async () => { + await initializeUploadDir(); + await ensureTrashCollection(); + await ensureSystemConfig(); + if (shouldEnsureInitialAdmin) { + 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 4b59c0c..e18a345 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -3,8 +3,8 @@ import path from "path"; import os from "os"; // Centralized test environment URLs -const FRONTEND_PORT = 5173; -const BACKEND_PORT = 8000; +const FRONTEND_PORT = Number(process.env.FRONTEND_PORT || 5173); +const BACKEND_PORT = Number(process.env.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 API_URL = BACKEND_URL; @@ -110,7 +110,7 @@ export default defineConfig({ command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts", url: `${BACKEND_URL}/health`, reuseExistingServer: false, - timeout: 120000, + timeout: 240000, stdout: "pipe", stderr: "pipe", env: { @@ -126,18 +126,21 @@ export default defineConfig({ RATE_LIMIT_MAX_REQUESTS: "20000", NODE_ENV: "e2e", TS_NODE_TRANSPILE_ONLY: "1", + PORT: String(BACKEND_PORT), + START_SERVER_IN_TEST: "true", }, }, { command: "cd ../frontend && npm run dev -- --host", url: FRONTEND_URL, reuseExistingServer: false, - timeout: 120000, + timeout: 240000, stdout: "pipe", stderr: "pipe", env: { VITE_API_URL: "/api", API_URL, + PORT: String(FRONTEND_PORT), }, }, ], diff --git a/e2e/run-e2e.sh b/e2e/run-e2e.sh index bcaf39a..00e27a2 100755 --- a/e2e/run-e2e.sh +++ b/e2e/run-e2e.sh @@ -64,7 +64,7 @@ elif [ "$CI" = "true" ]; then CI=true NO_SERVER=true npx playwright test else echo " Mode: Headless" - NO_SERVER=${NO_SERVER:-false} npx playwright test + PWDEBUG=${PWDEBUG:-false} NO_SERVER=${NO_SERVER:-false} npx playwright test fi echo "" diff --git a/e2e/tests/dashboard-workflows.spec.ts b/e2e/tests/dashboard-workflows.spec.ts index 22a4c39..29a6689 100644 --- a/e2e/tests/dashboard-workflows.spec.ts +++ b/e2e/tests/dashboard-workflows.spec.ts @@ -86,8 +86,10 @@ test.describe("Dashboard Workflows", () => { await expect(cardLocator).toHaveCount(0); - const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`); - expect(response.status()).toBe(404); + await expect.poll(async () => { + const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`); + return response.status(); + }).toBe(404); createdDrawingIds = createdDrawingIds.filter((id) => id !== createdDrawing.id); }); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b75a7bc..0a0f07f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -93,6 +93,20 @@ api.interceptors.request.use( (error) => Promise.reject(error) ); +// Reset auth state when auth is disabled +api.interceptors.response.use( + (response) => response, + async (error) => { + if ( + error.response?.status === 404 && + error.response?.data?.message?.includes("Authentication is disabled") + ) { + unauthorizedHandler?.(); + } + return Promise.reject(error); + } +); + // Add response interceptor to handle CSRF token errors api.interceptors.response.use( (response) => response,