test: stabilize e2e auth and rate limits

This commit is contained in:
Adrian Acala
2026-01-18 21:22:03 -08:00
parent 15ac634d15
commit 260a898e3e
13 changed files with 250 additions and 79 deletions
+5 -1
View File
@@ -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
+2
View File
@@ -9,3 +9,5 @@ dist
*.log
prisma/dev.db
prisma/dev.db-journal
prisma/*.db
prisma/*.db-*
+9
View File
@@ -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
@@ -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 () => {
+55 -1
View File
@@ -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}`);
});
+2 -1
View File
@@ -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:
+19 -5
View File
@@ -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 });
});
});
+10 -3
View File
@@ -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);
+26 -33
View File
@@ -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 });
});
});
+52 -24
View File
@@ -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<CsrfInfo> => {
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);
+35 -4
View File
@@ -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<void>;
uploadFiles: (files: File[], targetCollectionId: string | null) => Promise<ImportResult>;
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]);
+24 -4
View File
@@ -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;
+6
View File
@@ -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,