From 260a898e3e299b6cf578b7491508416f8487f73a Mon Sep 17 00:00:00 2001 From: Adrian Acala Date: Sun, 18 Jan 2026 21:22:03 -0800 Subject: [PATCH] test: stabilize e2e auth and rate limits --- .github/workflows/test.yml | 6 +- backend/.dockerignore | 2 + backend/docker-entrypoint.sh | 9 +++ .../src/__tests__/auth.integration.test.ts | 8 +- backend/src/index.ts | 56 +++++++++++++- e2e/docker-compose.e2e.yml | 3 +- e2e/tests/drag-and-drop.spec.ts | 24 ++++-- e2e/tests/drawing-crud.spec.ts | 13 +++- e2e/tests/export-import.spec.ts | 59 +++++++------- e2e/tests/password-reset.spec.ts | 76 +++++++++++++------ frontend/src/context/UploadContext.tsx | 39 +++++++++- frontend/src/pages/Dashboard.tsx | 28 ++++++- frontend/src/utils/importUtils.ts | 6 ++ 13 files changed, 250 insertions(+), 79 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c7b9ddd..e24709a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -108,7 +108,7 @@ jobs: run: | # Start backend server in background cd backend - DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev & + NODE_ENV="test" DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev & BACKEND_PID=$! cd .. @@ -150,7 +150,11 @@ jobs: exit $TEST_EXIT_CODE env: + AUTH_USERNAME: admin + AUTH_PASSWORD: admin123 DATABASE_URL: file:${{ github.workspace }}/backend/prisma/e2e-test.db + CSRF_MAX_REQUESTS: "10000" + RATE_LIMIT_MAX_REQUESTS: "20000" - name: Upload Playwright report uses: actions/upload-artifact@v4 diff --git a/backend/.dockerignore b/backend/.dockerignore index 567bf5c..42d3798 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -9,3 +9,5 @@ dist *.log prisma/dev.db prisma/dev.db-journal +prisma/*.db +prisma/*.db-* diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 07889f6..adaa315 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -25,6 +25,15 @@ if [ -f "/app/prisma/dev.db" ]; then chmod 666 /app/prisma/dev.db fi +# Optionally reset the database (used for E2E runs) +if [ "${RESET_DB_ON_START}" = "true" ]; then + DB_PATH="${DATABASE_URL#file:}" + if [ "$DB_PATH" != "$DATABASE_URL" ]; then + echo "Resetting database at ${DB_PATH}..." + rm -f "${DB_PATH}" "${DB_PATH}-journal" "${DB_PATH}-wal" "${DB_PATH}-shm" + fi +fi + # 3. Run Migrations (Drop privileges to nodejs) echo "Running database migrations..." su-exec nodejs npx prisma migrate deploy diff --git a/backend/src/__tests__/auth.integration.test.ts b/backend/src/__tests__/auth.integration.test.ts index 103213f..3494b6e 100644 --- a/backend/src/__tests__/auth.integration.test.ts +++ b/backend/src/__tests__/auth.integration.test.ts @@ -20,8 +20,8 @@ describe("Authentication flows", () => { setupTestDb(); prisma = getTestPrisma(); await initTestDb(prisma); - const appModule = await import("../index"); - app = appModule.default || appModule.app || appModule; + const appModule = (await import("../index")) as { default: unknown }; + app = appModule.default; }); beforeEach(async () => { @@ -51,7 +51,9 @@ describe("Authentication flows", () => { .set("x-csrf-token", token) .send({ username: "admin", password: "password123" }); - return login.headers["set-cookie"] as string[] | undefined; + const cookies = login.headers["set-cookie"]; + if (!cookies) return undefined; + return Array.isArray(cookies) ? cookies : [cookies]; }; afterAll(async () => { diff --git a/backend/src/index.ts b/backend/src/index.ts index 8befa86..c3ef654 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -804,6 +804,55 @@ app.post("/auth/password", async (req, res) => { return res.json(authChangePasswordResponse(updated)); }); +app.post("/auth/test/must-reset", async (req, res) => { + if (process.env.NODE_ENV !== "test") { + return res.status(404).json({ + error: "Not found", + message: "Endpoint is only available in test environments.", + }); + } + + 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 }, + select: { id: true, role: true }, + }); + + if (!currentUser || currentUser.role !== "ADMIN") { + return res.status(403).json({ + error: "Forbidden", + message: "Admin privileges required.", + }); + } + + const payloadSchema = z.object({ enabled: z.boolean() }); + const parsed = payloadSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid payload", + message: "Expected { enabled: boolean }.", + }); + } + + const updated = await prisma.user.update({ + where: { id: currentUser.id }, + data: { mustResetPassword: parsed.data.enabled }, + select: { id: true, username: true, email: true, role: true, mustResetPassword: true }, + }); + + res.setHeader("Cache-Control", "no-store"); + return res.json({ + user: toAuthUserWithResetFlag(updated as AuthenticatedUser & { mustResetPassword: boolean }), + }); +}); + app.post("/auth/register", async (req, res) => { const config = await getSystemConfig(); const existingUsers = await prisma.user.count(); @@ -1930,11 +1979,16 @@ const ensureTrashCollection = async () => { } }; +const shouldEnsureInitialAdmin = + process.env.NODE_ENV !== "test" && process.env.SKIP_INITIAL_ADMIN !== "true"; + httpServer.listen(PORT, async () => { await initializeUploadDir(); await ensureTrashCollection(); await ensureSystemConfig(); - await ensureInitialAdminUser(); + if (shouldEnsureInitialAdmin) { + await ensureInitialAdminUser(); + } console.log(`Server running on port ${PORT}`); }); diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 7d88fda..0431a65 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -22,6 +22,7 @@ services: - DATABASE_URL=file:/app/prisma/e2e-test.db - PORT=8000 - NODE_ENV=test + - RESET_DB_ON_START=true # Include both with and without :80 because browsers omit default ports in Origin. - FRONTEND_URL=http://frontend,http://frontend:80,http://localhost:5173 ports: @@ -70,7 +71,7 @@ services: condition: service_healthy environment: - BASE_URL=http://frontend:80 - - API_URL=http://backend:8000 + - API_URL=http://frontend:80/api - NO_SERVER=true - CI=true volumes: diff --git a/e2e/tests/drag-and-drop.spec.ts b/e2e/tests/drag-and-drop.spec.ts index 82d94b4..1e1a78a 100644 --- a/e2e/tests/drag-and-drop.spec.ts +++ b/e2e/tests/drag-and-drop.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures"; +import type { Page } from "@playwright/test"; import * as path from "path"; import * as fs from "fs"; import { @@ -18,6 +19,21 @@ import { * - Drag multiple selected drawings */ +const waitForDrawingImports = async (page: Page, count: number) => { + const waiters = Array.from({ length: count }, () => + page.waitForResponse( + (response) => + response.url().includes("/api/drawings") && + response.request().method() === "POST" + ) + ); + + const responses = await Promise.all(waiters); + for (const response of responses) { + expect(response.ok()).toBeTruthy(); + } +}; + test.describe("Drag and Drop - Collections", () => { let createdDrawingIds: string[] = []; let createdCollectionIds: string[] = []; @@ -272,17 +288,15 @@ test.describe("Drag and Drop - File Import", () => { // Find the hidden file input and upload the file const fileInput = page.locator("#dashboard-import"); + const importResponses = waitForDrawingImports(page, 1); await fileInput.setInputFiles(fixturePath); - - // Wait for upload to complete - the UploadStatus component shows "Done" when finished - await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 }); + await importResponses; // Search for the imported drawing (it uses the filename as name) await page.getByPlaceholder("Search drawings...").fill("small-image"); - await page.waitForTimeout(500); // Verify at least one drawing was imported const importedCards = page.locator("[id^='drawing-card-']"); - await expect(importedCards.first()).toBeVisible(); + await expect(importedCards.first()).toBeVisible({ timeout: 30000 }); }); }); diff --git a/e2e/tests/drawing-crud.spec.ts b/e2e/tests/drawing-crud.spec.ts index 431bba8..bed35e1 100644 --- a/e2e/tests/drawing-crud.spec.ts +++ b/e2e/tests/drawing-crud.spec.ts @@ -388,9 +388,16 @@ test.describe("Drawing Deletion", () => { // Card should be gone await expect(card).not.toBeVisible(); - // Verify via API that drawing is deleted - const response = await request.get(`${API_URL}/drawings/${drawing.id}`); - expect(response.status()).toBe(404); + // Verify via API that drawing is deleted (allow a short window for backend completion) + await expect + .poll( + async () => { + const response = await request.get(`${API_URL}/drawings/${drawing.id}`); + return response.status(); + }, + { timeout: 5000 } + ) + .toBe(404); // Remove from cleanup list since it's already deleted createdDrawingIds = createdDrawingIds.filter(id => id !== drawing.id); diff --git a/e2e/tests/export-import.spec.ts b/e2e/tests/export-import.spec.ts index 7b525e1..25fc8ed 100644 --- a/e2e/tests/export-import.spec.ts +++ b/e2e/tests/export-import.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures"; +import type { Page } from "@playwright/test"; import { API_URL, createDrawing, @@ -20,6 +21,21 @@ import { * - Import database backup */ +const waitForDrawingImports = async (page: Page, count: number) => { + const waiters = Array.from({ length: count }, () => + page.waitForResponse( + (response) => + response.url().includes("/api/drawings") && + response.request().method() === "POST" + ) + ); + + const responses = await Promise.all(waiters); + for (const response of responses) { + expect(response.ok()).toBeTruthy(); + } +}; + test.describe("Export Functionality", () => { let createdDrawingIds: string[] = []; let createdCollectionIds: string[] = []; @@ -214,24 +230,18 @@ test.describe.serial("Import Functionality", () => { const fileInput = page.locator("#dashboard-import"); // Create a buffer from the fixture content + const importResponses = waitForDrawingImports(page, 1); await fileInput.setInputFiles({ name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`, mimeType: "application/json", buffer: Buffer.from(fixtureContent), }); - - // Wait for upload to complete - the UploadStatus component shows "Done" when finished - await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 }); - - // Reload to ensure dashboard state reflects the newly imported drawing - await page.reload({ waitUntil: "networkidle" }); + await importResponses; // Verify the drawing was imported - the drawing name is the filename without extension await page.getByPlaceholder("Search drawings...").fill("Import_ExcalidrawTest"); - await page.waitForTimeout(1000); - const importedCards = page.locator("[id^='drawing-card-']"); - await expect(importedCards.first()).toBeVisible({ timeout: 10000 }); + await expect(importedCards.first()).toBeVisible({ timeout: 30000 }); }); test("should import JSON drawing file from Dashboard", async ({ page }) => { @@ -281,31 +291,18 @@ test.describe.serial("Import Functionality", () => { const fileInput = page.locator("#dashboard-import"); + const importResponses = waitForDrawingImports(page, 1); await fileInput.setInputFiles({ name: `${testName}.json`, mimeType: "application/json", buffer: Buffer.from(jsonContent), }); - - // Wait for upload to complete - the UploadStatus component shows "Done" when finished - await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 15000 }); - - // Check if upload failed (shows "Failed" text in the upload status) - const failedIndicator = page.getByText("Failed"); - if (await failedIndicator.isVisible()) { - console.log("Import failed - skipping rest of test"); - return; - } - - // Reload to force a fresh fetch of drawings after import - await page.reload({ waitUntil: "networkidle" }); + await importResponses; // Clear any existing search and search for the imported drawing const searchInput = page.getByPlaceholder("Search drawings..."); await searchInput.clear(); await searchInput.fill(testName); - await page.waitForTimeout(1500); - // Wait for the card to appear - the drawing should be visible in the UI const importedCards = page.locator("[id^='drawing-card-']"); await expect(importedCards.first()).toBeVisible({ timeout: 15000 }); @@ -326,10 +323,8 @@ test.describe.serial("Import Functionality", () => { buffer: Buffer.from(invalidContent), }); - // Wait for upload to complete and check for failure indicator - await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 }); - // Should show "Failed" status in the upload status component - await expect(page.getByText("Failed")).toBeVisible(); + // Should show error modal for invalid file + await expect(page.getByText("Import Failed")).toBeVisible({ timeout: 10000 }); }); test("should import multiple drawings at once", async ({ page }) => { @@ -364,17 +359,15 @@ test.describe.serial("Import Functionality", () => { ]; const fileInput = page.locator("#dashboard-import"); + const importResponses = waitForDrawingImports(page, files.length); await fileInput.setInputFiles(files); - - // Wait for upload to complete - the UploadStatus component shows "Done" when finished - await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 }); + await importResponses; // Verify both were imported by searching for the unique prefix await page.getByPlaceholder("Search drawings...").fill(searchPrefix); - await page.waitForTimeout(500); const importedCards = page.locator("[id^='drawing-card-']"); - await expect(importedCards).toHaveCount(2); + await expect(importedCards).toHaveCount(2, { timeout: 30000 }); }); }); diff --git a/e2e/tests/password-reset.spec.ts b/e2e/tests/password-reset.spec.ts index d0079ca..6626cf5 100644 --- a/e2e/tests/password-reset.spec.ts +++ b/e2e/tests/password-reset.spec.ts @@ -1,10 +1,9 @@ import type { Page } from "@playwright/test"; -import { PrismaClient } from "../../backend/src/generated/client"; import { test, expect } from "./fixtures"; +const BASE_URL = process.env.BASE_URL || "http://localhost:5173"; const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin"; const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123"; -const DATABASE_URL = process.env.DATABASE_URL; const ensureLoggedOut = async (page: Page) => { await page.context().clearCookies(); @@ -57,33 +56,62 @@ const ensureDashboard = async (page: Page) => { await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 }); }; -const setMustResetPassword = async (enabled: boolean) => { - if (!DATABASE_URL) { - throw new Error("DATABASE_URL is not set for e2e test."); - } +type CsrfInfo = { + token: string; + headerName: string; +}; - const prisma = new PrismaClient({ - datasources: { - db: { url: DATABASE_URL }, - }, +const fetchCsrfInfo = async (page: Page): Promise => { + const response = await page.request.get(`${BASE_URL}/api/csrf-token`, { + headers: { origin: BASE_URL }, }); - try { - const admin = await prisma.user.findFirst({ - where: { username: AUTH_USERNAME }, - select: { id: true }, - }); + if (!response.ok()) { + const text = await response.text(); + throw new Error( + `Failed to fetch CSRF token: ${response.status()} ${text || "(empty response)"}` + ); + } - if (!admin) { - throw new Error(`Admin user ${AUTH_USERNAME} not found.`); - } + const data = (await response.json()) as { token: string; header?: string }; + if (!data || typeof data.token !== "string" || data.token.trim().length === 0) { + throw new Error("Failed to fetch CSRF token: missing token in response"); + } - await prisma.user.update({ - where: { id: admin.id }, - data: { mustResetPassword: enabled }, + const headerName = + typeof data.header === "string" && data.header.trim().length > 0 + ? data.header + : "x-csrf-token"; + + return { token: data.token, headerName }; +}; + +const setMustResetPassword = async (page: Page, enabled: boolean) => { + const csrfInfo = await fetchCsrfInfo(page); + let response = await page.request.post(`${BASE_URL}/api/auth/test/must-reset`, { + headers: { + origin: BASE_URL, + "Content-Type": "application/json", + [csrfInfo.headerName]: csrfInfo.token, + }, + data: { enabled }, + }); + + if (!response.ok() && response.status() === 403) { + const refreshed = await fetchCsrfInfo(page); + response = await page.request.post(`${BASE_URL}/api/auth/test/must-reset`, { + headers: { + origin: BASE_URL, + "Content-Type": "application/json", + [refreshed.headerName]: refreshed.token, + }, + data: { enabled }, }); - } finally { - await prisma.$disconnect(); + } + + if (!response.ok()) { + const text = await response.text(); + throw new Error(`Failed to toggle mustResetPassword: ${response.status()} ${text}`); } }; @@ -107,7 +135,7 @@ test.describe("Admin password reset", () => { await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 }); } - await setMustResetPassword(true); + await setMustResetPassword(page, true); await ensureLoggedOut(page); await login(page, AUTH_PASSWORD); diff --git a/frontend/src/context/UploadContext.tsx b/frontend/src/context/UploadContext.tsx index 91527f2..6cb09d4 100644 --- a/frontend/src/context/UploadContext.tsx +++ b/frontend/src/context/UploadContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; -import { importDrawings } from '../utils/importUtils'; +import { importDrawings, type ImportResult } from '../utils/importUtils'; export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'success' | 'error'; @@ -11,9 +11,35 @@ export interface UploadTask { error?: string; } +const generateUploadId = (): string => { + const cryptoObj: Crypto | undefined = + typeof globalThis !== "undefined" + ? globalThis.crypto || (globalThis as any).msCrypto + : undefined; + + if (cryptoObj?.randomUUID) { + return cryptoObj.randomUUID(); + } + + if (cryptoObj?.getRandomValues) { + const bytes = new Uint8Array(16); + cryptoObj.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // RFC 4122 version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex + .slice(6, 8) + .join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`; + } + + return `upload-${Date.now().toString(16)}-${Math.random() + .toString(16) + .slice(2)}`; +}; + interface UploadContextType { tasks: UploadTask[]; - uploadFiles: (files: File[], targetCollectionId: string | null) => Promise; + uploadFiles: (files: File[], targetCollectionId: string | null) => Promise; clearCompleted: () => void; removeTask: (id: string) => void; isUploading: boolean; @@ -48,7 +74,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => { const newTasks: UploadTask[] = files.map(f => ({ - id: crypto.randomUUID(), + id: generateUploadId(), fileName: f.name, status: 'pending', progress: 0 @@ -68,13 +94,18 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) }; try { - await importDrawings(files, targetCollectionId, undefined, handleProgress); + return await importDrawings(files, targetCollectionId, undefined, handleProgress); } catch (e) { console.error("Global upload error", e); // Mark all new tasks as error if something crashed completely newTasks.forEach(t => { updateTask(t.id, { status: 'error', error: 'Upload failed unexpectedly' }); }); + return { + success: 0, + failed: newTasks.length, + errors: ['Upload failed unexpectedly'], + }; } }, [updateTask]); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 20e1496..e578585 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -306,10 +306,20 @@ export const Dashboard: React.FC = () => { const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId; // Use the global upload context - uploadFiles(fileArray, targetCollectionId).finally(() => { + try { + const result = await uploadFiles(fileArray, targetCollectionId); + if (result.failed > 0) { + setShowImportError({ + isOpen: true, + message: result.errors.length > 0 + ? result.errors.join("\n") + : "Some files failed to import.", + }); + } + } finally { // Refresh after all uploads complete (success or failure) refreshData(); - }); + } }; const handleRenameDrawing = async (id: string, name: string) => { @@ -532,9 +542,19 @@ export const Dashboard: React.FC = () => { const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib')); if (drawingFiles.length > 0) { - uploadFiles(drawingFiles, targetCollectionId).finally(() => { + try { + const result = await uploadFiles(drawingFiles, targetCollectionId); + if (result.failed > 0) { + setShowImportError({ + isOpen: true, + message: result.errors.length > 0 + ? result.errors.join("\n") + : "Some files failed to import.", + }); + } + } finally { refreshData(); - }); + } } return; diff --git a/frontend/src/utils/importUtils.ts b/frontend/src/utils/importUtils.ts index 63c2f6f..b32f3b0 100644 --- a/frontend/src/utils/importUtils.ts +++ b/frontend/src/utils/importUtils.ts @@ -2,6 +2,12 @@ import { exportToSvg } from "@excalidraw/excalidraw"; import { api } from "../api"; import { type UploadStatus } from "../context/UploadContext"; +export type ImportResult = { + success: number; + failed: number; + errors: string[]; +}; + export const importDrawings = async ( files: File[], targetCollectionId: string | null,