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.
This commit is contained in:
Adrian Acala
2026-01-20 19:55:32 -08:00
parent 260a898e3e
commit af07a73a07
13 changed files with 433 additions and 29 deletions
+5
View File
@@ -3,6 +3,8 @@ PORT=8000
NODE_ENV=production NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767 FRONTEND_URL=http://localhost:6767
# Optional auth settings
AUTH_ENABLED=true
# Optional auth cookie settings: lax | strict | none # Optional auth cookie settings: lax | strict | none
AUTH_COOKIE_SAMESITE=lax AUTH_COOKIE_SAMESITE=lax
# Optional auth bootstrap (creates initial admin) # 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 # If not set, a random password is generated and logged
AUTH_PASSWORD= AUTH_PASSWORD=
AUTH_MIN_PASSWORD_LENGTH=7 AUTH_MIN_PASSWORD_LENGTH=7
# Optional login throttling
LOGIN_RATE_LIMIT_MAX=10
LOGIN_MAX_FAILURES=5
@@ -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");
@@ -0,0 +1,5 @@
-- CreateIndex
CREATE INDEX "User_username_idx" ON "User"("username");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
+27 -8
View File
@@ -42,14 +42,17 @@ model Library {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
username String? @unique username String? @unique
email String? @unique email String? @unique
passwordHash String passwordHash String
mustResetPassword Boolean @default(false) mustResetPassword Boolean @default(false)
role String @default("USER") role String @default("USER")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([username])
@@index([email])
} }
model SystemConfig { model SystemConfig {
@@ -58,3 +61,19 @@ model SystemConfig {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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])
}
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import request from "supertest"; import request from "supertest";
import { vi } from "vitest";
import { import {
cleanupTestDb, cleanupTestDb,
getTestDatabaseUrl, getTestDatabaseUrl,
@@ -24,6 +25,11 @@ describe("Authentication flows", () => {
app = appModule.default; app = appModule.default;
}); });
beforeEach(() => {
delete process.env.LOGIN_RATE_LIMIT_MAX;
delete process.env.LOGIN_MAX_FAILURES;
});
beforeEach(async () => { beforeEach(async () => {
await cleanupTestDb(prisma); await cleanupTestDb(prisma);
await initTestDb(prisma); await initTestDb(prisma);
@@ -109,4 +115,47 @@ describe("Authentication flows", () => {
expect(register.status).toBe(201); expect(register.status).toBe(201);
expect(register.body.user.username).toBe("user1"); 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);
}); });
+5
View File
@@ -17,6 +17,11 @@ describe("Auth utilities", () => {
expect(config.minPasswordLength).toBe(7); 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", () => { it("hashes and verifies passwords", () => {
const hashed = hashPassword("super-secret"); const hashed = hashPassword("super-secret");
expect(verifyPassword("super-secret", hashed)).toBe(true); expect(verifyPassword("super-secret", hashed)).toBe(true);
+1
View File
@@ -68,6 +68,7 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
}); });
await prisma.user.deleteMany({}); await prisma.user.deleteMany({});
await prisma.systemConfig.deleteMany({}); await prisma.systemConfig.deleteMany({});
await prisma.loginAttempt.deleteMany({});
}; };
/** /**
+15 -2
View File
@@ -62,6 +62,18 @@ const parseSameSite = (rawValue?: string): AuthSameSite => {
return DEFAULT_COOKIE_SAMESITE; 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 => { const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer => {
if (!enabled) return Buffer.alloc(0); 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 => { export const buildAuthConfig = (env: NodeJS.ProcessEnv = process.env): AuthConfig => {
const enabled = parseAuthEnabled(env.AUTH_ENABLED);
const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS); const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS);
const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim(); const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim();
const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE); const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE);
const minPasswordLength = parseMinPasswordLength(env.AUTH_MIN_PASSWORD_LENGTH); const minPasswordLength = parseMinPasswordLength(env.AUTH_MIN_PASSWORD_LENGTH);
return { return {
enabled: true, enabled,
sessionTtlMs: sessionTtlHours * 60 * 60 * 1000, sessionTtlMs: sessionTtlHours * 60 * 60 * 1000,
cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME, cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME,
cookieSameSite, cookieSameSite,
secret: resolveAuthSecret(true, env), secret: resolveAuthSecret(enabled, env),
minPasswordLength, minPasswordLength,
}; };
}; };
+281 -12
View File
@@ -107,7 +107,7 @@ const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
console.log("Allowed origins:", allowedOrigins); console.log("Allowed origins:", allowedOrigins);
const authConfig = buildAuthConfig(); const authConfig = buildAuthConfig();
console.log("[auth] Auth middleware enabled."); console.log(`[auth] Auth middleware ${authConfig.enabled ? "enabled" : "disabled"}.`);
const uploadDir = path.resolve(__dirname, "../uploads"); const uploadDir = path.resolve(__dirname, "../uploads");
@@ -499,6 +499,16 @@ const authExemptPaths = new Set([
"/auth/password", "/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([ const authNeedsSession = new Set([
"/auth/logout", "/auth/logout",
"/auth/registration/toggle", "/auth/registration/toggle",
@@ -641,13 +651,180 @@ const csrfProtectionMiddleware = (
// Apply authentication and CSRF protection to all routes // Apply authentication and CSRF protection to all routes
app.use(authMiddleware); 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(csrfProtectionMiddleware);
app.use((req, res, next) => {
if (!authConfig.enabled) {
res.locals.authUserId = "anonymous";
}
next();
});
const authLoginSchema = z.object({ const authLoginSchema = z.object({
username: z.string().trim().min(1).max(200), username: z.string().trim().min(1).max(200),
password: z.string().min(1).max(512), 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<LoginAttemptEntry> => {
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<LoginAttemptEntry> =>
prisma.loginAttempt.update({
where: { id: entryId },
data: {
count: { increment: 1 },
lastAttempt: new Date(),
},
});
const registerLoginFailure = async (entry: LoginAttemptEntry): Promise<LoginAttemptEntry> => {
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({ const authChangePasswordSchema = z.object({
currentPassword: z.string().min(1).max(512), currentPassword: z.string().min(1).max(512),
newPassword: 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) => { 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 session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
const config = await getSystemConfig(); const config = await getSystemConfig();
const totalUsers = await prisma.user.count(); const totalUsers = await prisma.user.count();
@@ -692,6 +880,13 @@ app.get("/auth/status", async (req, res) => {
}); });
app.post("/auth/login", 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); const parsed = authLoginSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ return res.status(400).json({
@@ -702,6 +897,23 @@ app.post("/auth/login", async (req, res) => {
const { username, password } = parsed.data; const { username, password } = parsed.data;
const identifier = username.trim().toLowerCase(); 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({ const user = await prisma.user.findFirst({
where: { where: {
@@ -722,12 +934,20 @@ app.post("/auth/login", async (req, res) => {
}); });
if (!user || !verifyPassword(password, user.passwordHash)) { 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({ return res.status(401).json({
error: "Unauthorized", error: "Unauthorized",
message: "Invalid username/email or password.", message: "Invalid username/email or password.",
}); });
} }
await clearLoginFailures(entry.id);
const token = createAuthSessionToken(authConfig, user.id); const token = createAuthSessionToken(authConfig, user.id);
res.cookie( res.cookie(
authConfig.cookieName, authConfig.cookieName,
@@ -746,6 +966,10 @@ app.post("/auth/login", async (req, res) => {
}); });
app.post("/auth/logout", (req, res) => { app.post("/auth/logout", (req, res) => {
if (!authConfig.enabled) {
return res.status(204).end();
}
res.clearCookie( res.clearCookie(
authConfig.cookieName, authConfig.cookieName,
buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite) buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite)
@@ -755,6 +979,13 @@ app.post("/auth/logout", (req, res) => {
}); });
app.post("/auth/password", async (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); const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
if (!session) { if (!session) {
return res.status(401).json({ 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) => { 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({ return res.status(404).json({
error: "Not found", error: "Not found",
message: "Endpoint is only available in test environments.", 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) => { 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 config = await getSystemConfig();
const existingUsers = await prisma.user.count(); const existingUsers = await prisma.user.count();
@@ -951,6 +1196,13 @@ app.post("/auth/register", async (req, res) => {
}); });
app.post("/auth/bootstrap", 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 { try {
const existingUsers = await prisma.user.count(); const existingUsers = await prisma.user.count();
if (existingUsers > 0) { if (existingUsers > 0) {
@@ -1052,6 +1304,13 @@ app.post("/auth/bootstrap", async (req, res) => {
}); });
app.post("/auth/registration/toggle", 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); const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
if (!session) { if (!session) {
return res.status(401).json({ return res.status(401).json({
@@ -1090,6 +1349,13 @@ app.post("/auth/registration/toggle", async (req, res) => {
}); });
app.post("/auth/admins", 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); const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
if (!session) { if (!session) {
return res.status(401).json({ return res.status(401).json({
@@ -1979,17 +2245,20 @@ const ensureTrashCollection = async () => {
} }
}; };
const isTestEnv = process.env.NODE_ENV === "test";
const shouldEnsureInitialAdmin = 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 () => { if (!isTestEnv || process.env.START_SERVER_IN_TEST === "true") {
await initializeUploadDir(); httpServer.listen(PORT, async () => {
await ensureTrashCollection(); await initializeUploadDir();
await ensureSystemConfig(); await ensureTrashCollection();
if (shouldEnsureInitialAdmin) { await ensureSystemConfig();
await ensureInitialAdminUser(); if (shouldEnsureInitialAdmin) {
} await ensureInitialAdminUser();
console.log(`Server running on port ${PORT}`); }
}); console.log(`Server running on port ${PORT}`);
});
}
export default app; export default app;
+7 -4
View File
@@ -3,8 +3,8 @@ import path from "path";
import os from "os"; import os from "os";
// Centralized test environment URLs // Centralized test environment URLs
const FRONTEND_PORT = 5173; const FRONTEND_PORT = Number(process.env.FRONTEND_PORT || 5173);
const BACKEND_PORT = 8000; const BACKEND_PORT = Number(process.env.BACKEND_PORT || 8000);
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`; 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 API_URL = BACKEND_URL;
@@ -110,7 +110,7 @@ export default defineConfig({
command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts", command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts",
url: `${BACKEND_URL}/health`, url: `${BACKEND_URL}/health`,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120000, timeout: 240000,
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
env: { env: {
@@ -126,18 +126,21 @@ export default defineConfig({
RATE_LIMIT_MAX_REQUESTS: "20000", RATE_LIMIT_MAX_REQUESTS: "20000",
NODE_ENV: "e2e", NODE_ENV: "e2e",
TS_NODE_TRANSPILE_ONLY: "1", TS_NODE_TRANSPILE_ONLY: "1",
PORT: String(BACKEND_PORT),
START_SERVER_IN_TEST: "true",
}, },
}, },
{ {
command: "cd ../frontend && npm run dev -- --host", command: "cd ../frontend && npm run dev -- --host",
url: FRONTEND_URL, url: FRONTEND_URL,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120000, timeout: 240000,
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
env: { env: {
VITE_API_URL: "/api", VITE_API_URL: "/api",
API_URL, API_URL,
PORT: String(FRONTEND_PORT),
}, },
}, },
], ],
+1 -1
View File
@@ -64,7 +64,7 @@ elif [ "$CI" = "true" ]; then
CI=true NO_SERVER=true npx playwright test CI=true NO_SERVER=true npx playwright test
else else
echo " Mode: Headless" echo " Mode: Headless"
NO_SERVER=${NO_SERVER:-false} npx playwright test PWDEBUG=${PWDEBUG:-false} NO_SERVER=${NO_SERVER:-false} npx playwright test
fi fi
echo "" echo ""
+4 -2
View File
@@ -86,8 +86,10 @@ test.describe("Dashboard Workflows", () => {
await expect(cardLocator).toHaveCount(0); await expect(cardLocator).toHaveCount(0);
const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`); await expect.poll(async () => {
expect(response.status()).toBe(404); const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`);
return response.status();
}).toBe(404);
createdDrawingIds = createdDrawingIds.filter((id) => id !== createdDrawing.id); createdDrawingIds = createdDrawingIds.filter((id) => id !== createdDrawing.id);
}); });
+14
View File
@@ -93,6 +93,20 @@ api.interceptors.request.use(
(error) => Promise.reject(error) (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 // Add response interceptor to handle CSRF token errors
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,