feat(security): implement CSRF protection

This commit is contained in:
AdrianAcala
2025-12-21 02:47:14 -08:00
parent e75b727a5a
commit 8a78b2bb2e
25 changed files with 1157 additions and 580 deletions
+2
View File
@@ -31,7 +31,9 @@ backend/dist/
# E2E Testing # E2E Testing
e2e/node_modules/ e2e/node_modules/
e2e/test-results/ e2e/test-results/
e2e/test-results-user/
e2e/playwright-report/ e2e/playwright-report/
e2e/playwright-report-user/
e2e/.playwright/ e2e/.playwright/
# Temporary files # Temporary files
+1 -1
View File
@@ -148,7 +148,7 @@ ExcaliDash/
**Backend (.env):** **Backend (.env):**
```bash ```bash
DATABASE_URL="file:./prisma/dev.db" DATABASE_URL="file:./dev.db"
PORT=8000 PORT=8000
NODE_ENV=development NODE_ENV=development
``` ```
+1 -1
View File
@@ -75,7 +75,7 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp
# Installation # Installation
> [!CAUTION] > [!CAUTION]
> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports. Currently lacking CSRF. > NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports.
> [!CAUTION] > [!CAUTION]
> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron). > ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
+197 -371
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -16,7 +16,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/jsdom": "^27.0.0", "@types/jsdom": "^21.1.7",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1", "@types/socket.io": "^3.0.1",
"archiver": "^7.0.1", "archiver": "^7.0.1",
@@ -25,7 +25,7 @@
"dompurify": "^3.3.0", "dompurify": "^3.3.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
"jsdom": "^27.2.0", "jsdom": "^22.1.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
@@ -42,4 +42,4 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.15" "vitest": "^4.0.15"
} }
} }
+168
View File
@@ -0,0 +1,168 @@
/**
* CSRF Tests - Horizontal Scaling (K8s) Validation
*
* PR #20 review concern:
* "Worried that in memory token store might not work on horizontal scaling"
*
* Fix:
* - CSRF tokens are now stateless and HMAC-signed using a shared `CSRF_SECRET`.
* - Any pod can validate any token as long as all pods share the same secret.
*
* These tests prove:
* - Tokens validate correctly for the issuing client id
* - Tokens do NOT validate for a different client id
* - Tokens expire after 24 hours
* - Tokens validate across separate module instances (simulated pods)
*/
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
const SHARED_SECRET = "test-shared-csrf-secret";
beforeAll(() => {
// Must be shared across instances/pods for horizontal scaling.
process.env.CSRF_SECRET = SHARED_SECRET;
});
afterEach(() => {
vi.useRealTimers();
});
describe("CSRF - stateless HMAC tokens", () => {
it("creates a token in payload.signature format and validates for same client id", async () => {
const { createCsrfToken, validateCsrfToken } = await import("../security");
const clientId = "test-client-1";
const token = createCsrfToken(clientId);
expect(typeof token).toBe("string");
// base64url(payload).base64url(signature)
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
expect(validateCsrfToken(clientId, token)).toBe(true);
});
it("rejects validation for a different client id (token binding)", async () => {
const { createCsrfToken, validateCsrfToken } = await import("../security");
const token = createCsrfToken("client-a");
expect(validateCsrfToken("client-b", token)).toBe(false);
});
it("rejects malformed tokens", async () => {
const { validateCsrfToken } = await import("../security");
expect(validateCsrfToken("client", "not-a-token")).toBe(false);
expect(validateCsrfToken("client", "a.b.c")).toBe(false);
expect(validateCsrfToken("client", "")).toBe(false);
});
it("revokeCsrfToken is a no-op for stateless tokens (does not break callers)", async () => {
const { createCsrfToken, validateCsrfToken, revokeCsrfToken } = await import(
"../security"
);
const clientId = "client-revoke";
const token = createCsrfToken(clientId);
expect(validateCsrfToken(clientId, token)).toBe(true);
revokeCsrfToken(clientId);
// Stateless token remains valid until expiry
expect(validateCsrfToken(clientId, token)).toBe(true);
});
it("expires tokens after 24 hours", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const { createCsrfToken, validateCsrfToken } = await import("../security");
const clientId = "client-expiry";
const token = createCsrfToken(clientId);
expect(validateCsrfToken(clientId, token)).toBe(true);
// 24h + 1ms later
vi.setSystemTime(new Date("2025-01-02T00:00:00.001Z"));
expect(validateCsrfToken(clientId, token)).toBe(false);
});
});
describe("CSRF - horizontal scaling (simulated pods)", () => {
it("validates across module instances (pod A issues, pod B validates)", async () => {
const clientId = "user-123";
vi.resetModules();
const podA = await import("../security");
const token = podA.createCsrfToken(clientId);
// Simulate a different pod (new Node.js process / fresh module state)
vi.resetModules();
const podB = await import("../security");
expect(podB.validateCsrfToken(clientId, token)).toBe(true);
});
it("has 0% failure rate under round-robin validation across 3 pods", async () => {
const clientId = "user-round-robin";
const pods: Array<{
createCsrfToken: (clientId: string) => string;
validateCsrfToken: (clientId: string, token: string) => boolean;
}> = [];
for (let i = 0; i < 3; i++) {
vi.resetModules();
pods.push(await import("../security"));
}
// Token issued on one pod
const token = pods[0].createCsrfToken(clientId);
// Validate on alternating pods (simulates a non-sticky load balancer)
const attempts = 60;
let failures = 0;
for (let i = 0; i < attempts; i++) {
const pod = pods[i % pods.length];
if (!pod.validateCsrfToken(clientId, token)) failures++;
}
expect(failures).toBe(0);
});
});
describe("CSRF - referer origin parsing", () => {
it("extracts exact origin from a referer URL", async () => {
const { getOriginFromReferer } = await import("../security");
expect(getOriginFromReferer("https://example.com/path?x=1")).toBe(
"https://example.com"
);
expect(getOriginFromReferer("http://localhost:5173/some/page")).toBe(
"http://localhost:5173"
);
});
it("does not allow prefix tricks (origin must be parsed)", async () => {
const { getOriginFromReferer } = await import("../security");
expect(
getOriginFromReferer("https://example.com.evil.com/anything")
).toBe("https://example.com.evil.com");
// `startsWith("https://example.com")` would incorrectly allow this.
expect(getOriginFromReferer("https://example.com@evil.com/anything")).toBe(
"https://evil.com"
);
});
it("returns null for invalid or non-http(s) referers", async () => {
const { getOriginFromReferer } = await import("../security");
expect(getOriginFromReferer("")).toBeNull();
expect(getOriginFromReferer("not a url")).toBeNull();
expect(getOriginFromReferer("file:///etc/passwd")).toBeNull();
expect(getOriginFromReferer(null)).toBeNull();
});
});
+162 -15
View File
@@ -18,6 +18,10 @@ import {
sanitizeSvg, sanitizeSvg,
elementSchema, elementSchema,
appStateSchema, appStateSchema,
createCsrfToken,
validateCsrfToken,
getCsrfTokenHeader,
getOriginFromReferer,
} from "./security"; } from "./security";
dotenv.config(); dotenv.config();
@@ -34,9 +38,22 @@ const resolveDatabaseUrl = (rawUrl?: string) => {
} }
const filePath = rawUrl.replace(/^file:/, ""); const filePath = rawUrl.replace(/^file:/, "");
// 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
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" ||
normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath) const absolutePath = path.isAbsolute(filePath)
? filePath ? filePath
: path.resolve(backendRoot, filePath); : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`; return `file:${absolutePath}`;
}; };
@@ -63,11 +80,15 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => {
const ensureProtocol = (origin: string) => const ensureProtocol = (origin: string) =>
/^https?:\/\//i.test(origin) ? origin : `http://${origin}`; /^https?:\/\//i.test(origin) ? origin : `http://${origin}`;
const removeTrailingSlash = (origin: string) =>
origin.endsWith("/") ? origin.slice(0, -1) : origin;
const parsed = rawOrigins const parsed = rawOrigins
.split(",") .split(",")
.map((origin) => origin.trim()) .map((origin) => origin.trim())
.filter((origin) => origin.length > 0) .filter((origin) => origin.length > 0)
.map(ensureProtocol); .map(ensureProtocol)
.map(removeTrailingSlash);
return parsed.length > 0 ? parsed : [fallback]; return parsed.length > 0 ? parsed : [fallback];
}; };
@@ -211,6 +232,8 @@ app.use(
cors({ cors({
origin: allowedOrigins, origin: allowedOrigins,
credentials: true, credentials: true,
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
exposedHeaders: ["x-csrf-token"],
}) })
); );
app.use(express.json({ limit: "50mb" })); app.use(express.json({ limit: "50mb" }));
@@ -244,12 +267,12 @@ app.use((req, res, next) => {
res.setHeader( res.setHeader(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; " + "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " + "font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: blob: https:; " + "img-src 'self' data: blob: https:; " +
"connect-src 'self' ws: wss:; " + "connect-src 'self' ws: wss:; " +
"frame-ancestors 'none';" "frame-ancestors 'none';"
); );
next(); next();
@@ -296,6 +319,132 @@ app.use((req, res, next) => {
next(); next();
}); });
// CSRF Protection Middleware
// Generates a unique client ID based on IP and User-Agent for token association
const getClientId = (req: express.Request): string => {
const ip = req.ip || req.connection.remoteAddress || "unknown";
const userAgent = req.headers["user-agent"] || "unknown";
// Create a simple hash for client identification
// In production, you might use a session ID instead
return `${ip}:${userAgent}`.slice(0, 256);
};
// Rate limiter specifically for CSRF token generation to prevent store exhaustion
const csrfRateLimit = new Map<string, { count: number; resetTime: number }>();
const CSRF_RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const CSRF_MAX_REQUESTS = (() => {
const parsed = Number(process.env.CSRF_MAX_REQUESTS);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 60; // 1 per second average
}
return parsed;
})();
// CSRF token endpoint - clients should call this to get a token
app.get("/csrf-token", (req, res) => {
const ip = req.ip || req.connection.remoteAddress || "unknown";
const now = Date.now();
const clientLimit = csrfRateLimit.get(ip);
if (clientLimit && now < clientLimit.resetTime) {
if (clientLimit.count >= CSRF_MAX_REQUESTS) {
return res.status(429).json({
error: "Rate limit exceeded",
message: "Too many CSRF token requests",
});
}
clientLimit.count++;
} else {
csrfRateLimit.set(ip, { count: 1, resetTime: now + CSRF_RATE_LIMIT_WINDOW });
}
// Cleanup old rate limit entries occasionally
if (Math.random() < 0.01) {
for (const [key, data] of csrfRateLimit.entries()) {
if (now > data.resetTime) csrfRateLimit.delete(key);
}
}
const clientId = getClientId(req);
const token = createCsrfToken(clientId);
res.json({
token,
header: getCsrfTokenHeader()
});
});
// CSRF validation middleware for state-changing requests
const csrfProtectionMiddleware = (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
// Skip CSRF validation for safe methods (GET, HEAD, OPTIONS)
const safeMethods = ["GET", "HEAD", "OPTIONS"];
if (safeMethods.includes(req.method)) {
return next();
}
// Skip CSRF for the CSRF token endpoint itself
if (req.path === "/csrf-token") {
return next();
}
// Origin/Referer check for defense in depth
const origin = req.headers["origin"];
const referer = req.headers["referer"];
// If Origin is present, it must match allowed origins
const originValue = Array.isArray(origin) ? origin[0] : origin;
const refererValue = Array.isArray(referer) ? referer[0] : referer;
if (originValue) {
if (!allowedOrigins.includes(originValue)) {
return res.status(403).json({
error: "CSRF origin mismatch",
message: "Origin not allowed",
});
}
} else if (refererValue) {
// If no Origin but Referer exists, validate its *origin* (avoid prefix bypass)
const refererOrigin = getOriginFromReferer(refererValue);
if (!refererOrigin || !allowedOrigins.includes(refererOrigin)) {
return res.status(403).json({
error: "CSRF referer mismatch",
message: "Referer not allowed",
});
}
}
// Note: If neither Origin nor Referer is present, we proceed to token check.
// Some legitimate clients/proxies might strip these, so we don't block strictly on their absence,
// but relying on the token is the primary defense.
const clientId = getClientId(req);
const headerName = getCsrfTokenHeader();
const tokenHeader = req.headers[headerName];
const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
if (!token) {
return res.status(403).json({
error: "CSRF token missing",
message: `Missing ${headerName} header`,
});
}
if (!validateCsrfToken(clientId, token)) {
return res.status(403).json({
error: "CSRF token invalid",
message: "Invalid or expired CSRF token. Please refresh and try again.",
});
}
next();
};
// Apply CSRF protection to all routes
app.use(csrfProtectionMiddleware);
const filesFieldSchema = z const filesFieldSchema = z
.union([z.record(z.string(), z.any()), z.null()]) .union([z.record(z.string(), z.any()), z.null()])
.optional() .optional()
@@ -922,8 +1071,7 @@ app.get("/export", async (req, res) => {
res.setHeader("Content-Type", "application/octet-stream"); res.setHeader("Content-Type", "application/octet-stream");
res.setHeader( res.setHeader(
"Content-Disposition", "Content-Disposition",
`attachment; filename="excalidash-db-${ `attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0]
new Date().toISOString().split("T")[0]
}.${extension}"` }.${extension}"`
); );
@@ -946,8 +1094,7 @@ app.get("/export/json", async (req, res) => {
res.setHeader("Content-Type", "application/zip"); res.setHeader("Content-Type", "application/zip");
res.setHeader( res.setHeader(
"Content-Disposition", "Content-Disposition",
`attachment; filename="excalidraw-drawings-${ `attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0]
new Date().toISOString().split("T")[0]
}.zip"` }.zip"`
); );
@@ -1012,8 +1159,8 @@ Total Drawings: ${drawings.length}
Collections: Collections:
${Object.entries(drawingsByCollection) ${Object.entries(drawingsByCollection)
.map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`) .map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`)
.join("\n")} .join("\n")}
`; `;
archive.append(readmeContent, { name: "README.txt" }); archive.append(readmeContent, { name: "README.txt" });
@@ -1085,7 +1232,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
try { try {
await fsPromises.access(dbPath); await fsPromises.access(dbPath);
await fsPromises.copyFile(dbPath, backupPath); await fsPromises.copyFile(dbPath, backupPath);
} catch {} } catch { }
await moveFile(stagedPath, dbPath); await moveFile(stagedPath, dbPath);
} catch (error) { } catch (error) {
+188
View File
@@ -1,6 +1,10 @@
/**
* Security utilities for XSS prevention, data sanitization, and CSRF protection
*/
import { z } from "zod"; import { z } from "zod";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import crypto from "crypto";
// Create a DOM environment for DOMPurify (Node.js compatibility) // Create a DOM environment for DOMPurify (Node.js compatibility)
const window = new JSDOM("").window; const window = new JSDOM("").window;
@@ -523,3 +527,187 @@ export const validateImportedDrawing = (data: any): boolean => {
return false; return false;
} }
}; };
// ============================================================================
// CSRF Protection
// ============================================================================
const CSRF_TOKEN_LENGTH = 32;
const CSRF_TOKEN_HEADER = "x-csrf-token";
const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const CSRF_TOKEN_FUTURE_SKEW_MS = 5 * 60 * 1000; // 5 minutes clock skew tolerance
const CSRF_NONCE_BYTES = 16;
const CSRF_TOKEN_MAX_LENGTH = 2048; // sanity limit against abuse
/**
* IMPORTANT (Horizontal Scaling / K8s)
* -----------------------------------
* CSRF tokens must validate across multiple stateless instances.
*
* The prior in-memory Map-based token store breaks under horizontal scaling
* because each pod has its own memory. This implementation is stateless:
*
* - Token payload: { ts, nonce }
* - Signature: HMAC_SHA256(secret, `${clientId}|${ts}|${nonce}`)
*
* As long as all pods share the same `CSRF_SECRET`, any pod can validate
* any token without shared state (works on Kubernetes).
*/
let cachedCsrfSecret: Buffer | null = null;
const getCsrfSecret = (): Buffer => {
if (cachedCsrfSecret) return cachedCsrfSecret;
const secretFromEnv = process.env.CSRF_SECRET;
if (secretFromEnv && secretFromEnv.trim().length > 0) {
cachedCsrfSecret = Buffer.from(secretFromEnv, "utf8");
return cachedCsrfSecret;
}
// If not configured, generate an ephemeral secret for this process.
// This keeps single-instance deployments working out of the box, but:
// - Horizontal scaling will BREAK unless CSRF_SECRET is set and shared.
cachedCsrfSecret = crypto.randomBytes(32);
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
console.warn(
`[security] CSRF_SECRET is not set${envLabel}. Using an ephemeral per-process secret. ` +
"For horizontal scaling (k8s), set CSRF_SECRET to the same value on all instances."
);
return cachedCsrfSecret;
};
const base64UrlEncode = (input: Buffer | string): string => {
const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
};
const base64UrlDecode = (input: string): Buffer => {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return Buffer.from(padded, "base64");
};
type CsrfTokenPayload = {
/** Issued-at timestamp (ms since epoch) */
ts: number;
/** Random nonce (base64url) */
nonce: string;
};
const signCsrfToken = (clientId: string, payload: CsrfTokenPayload): Buffer => {
const secret = getCsrfSecret();
const data = `${clientId}|${payload.ts}|${payload.nonce}`;
return crypto.createHmac("sha256", secret).update(data, "utf8").digest();
};
/**
* Generate a cryptographically secure CSRF token
*/
export const generateCsrfToken = (): string => {
return crypto.randomBytes(CSRF_TOKEN_LENGTH).toString("hex");
};
/**
* Create and store a new CSRF token for a client
* Returns the token to be sent to the client
*/
export const createCsrfToken = (clientId: string): string => {
const payload: CsrfTokenPayload = {
ts: Date.now(),
nonce: base64UrlEncode(crypto.randomBytes(CSRF_NONCE_BYTES)),
};
const payloadJson = JSON.stringify(payload);
const payloadB64 = base64UrlEncode(payloadJson);
const sigB64 = base64UrlEncode(signCsrfToken(clientId, payload));
return `${payloadB64}.${sigB64}`;
};
/**
* Validate a CSRF token for a client
* Uses timing-safe comparison to prevent timing attacks
*/
export const validateCsrfToken = (clientId: string, token: string): boolean => {
if (!token || typeof token !== "string") {
return false;
}
if (token.length > CSRF_TOKEN_MAX_LENGTH) {
return false;
}
try {
const parts = token.split(".");
if (parts.length !== 2) return false;
const [payloadB64, sigB64] = parts;
const payloadJson = base64UrlDecode(payloadB64).toString("utf8");
const payload = JSON.parse(payloadJson) as Partial<CsrfTokenPayload>;
if (
typeof payload.ts !== "number" ||
!Number.isFinite(payload.ts) ||
typeof payload.nonce !== "string" ||
payload.nonce.length < 8
) {
return false;
}
const now = Date.now();
// Expiry check
if (now - payload.ts > CSRF_TOKEN_EXPIRY_MS) return false;
// Future skew check (clock mismatch)
if (payload.ts - now > CSRF_TOKEN_FUTURE_SKEW_MS) return false;
const expectedSig = signCsrfToken(clientId, {
ts: payload.ts,
nonce: payload.nonce,
});
const providedSig = base64UrlDecode(sigB64);
if (providedSig.length !== expectedSig.length) return false;
return crypto.timingSafeEqual(providedSig, expectedSig);
} catch {
return false;
}
};
/**
* Revoke a CSRF token (e.g., on logout or token refresh)
*/
export const revokeCsrfToken = (clientId: string): void => {
// Stateless CSRF tokens cannot be selectively revoked without shared state.
// If revocation is required, implement token blacklisting in a shared store
// (e.g., Redis) or rotate CSRF_SECRET.
void clientId;
};
/**
* Get the CSRF token header name
*/
export const getCsrfTokenHeader = (): string => {
return CSRF_TOKEN_HEADER;
};
export const getOriginFromReferer = (referer: unknown): string | null => {
if (typeof referer !== "string" || referer.trim().length === 0) {
return null;
}
try {
const url = new URL(referer);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
};
+2 -4
View File
@@ -1,6 +1,4 @@
import { defineConfig } from "vitest/config"; export default {
export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: "node", environment: "node",
@@ -20,4 +18,4 @@ export default defineConfig({
}, },
}, },
}, },
}); };
+2
View File
@@ -6,6 +6,8 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db - DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000 - PORT=8000
- NODE_ENV=production - NODE_ENV=production
# Required for horizontal scaling (k8s): must be the same across all instances
- CSRF_SECRET=${CSRF_SECRET}
volumes: volumes:
- backend-data:/app/prisma - backend-data:/app/prisma
networks: networks:
+2
View File
@@ -8,6 +8,8 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db - DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000 - PORT=8000
- NODE_ENV=production - NODE_ENV=production
# Required for horizontal scaling (k8s): must be the same across all instances
- CSRF_SECRET=${CSRF_SECRET}
volumes: volumes:
- backend-data:/app/prisma - backend-data:/app/prisma
networks: networks:
+1 -1
View File
@@ -1,5 +1,5 @@
# Playwright E2E Test Runner # Playwright E2E Test Runner
FROM mcr.microsoft.com/playwright:v1.52.0-noble FROM mcr.microsoft.com/playwright:v1.57.0-noble
WORKDIR /app WORKDIR /app
+13 -8
View File
@@ -17,14 +17,18 @@ services:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
environment: environment:
- DATABASE_URL=file:./prisma/e2e-test.db # Use an absolute sqlite path so Prisma CLI + the running app always point
# at the same DB file (avoids schema being applied to a different relative path).
- DATABASE_URL=file:/app/prisma/e2e-test.db
- PORT=8000 - PORT=8000
- NODE_ENV=test - NODE_ENV=test
- FRONTEND_URL=http://frontend:80,http://localhost:5173 # Include both with and without :80 because browsers omit default ports in Origin.
- FRONTEND_URL=http://frontend,http://frontend:80,http://localhost:5173
ports: ports:
- "8000:8000" - "8000:8000"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health"] # Use IPv4 loopback explicitly to avoid IPv6 localhost resolution issues.
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8000/health"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -35,17 +39,18 @@ services:
# Frontend web server # Frontend web server
frontend: frontend:
build: build:
context: ../frontend # Use the repo root as build context because `frontend/Dockerfile` expects
dockerfile: Dockerfile # `frontend/...` paths (same as production `docker-compose.yml`).
args: context: ..
- VITE_API_URL=http://backend:8000 dockerfile: frontend/Dockerfile
ports: ports:
- "5173:80" - "5173:80"
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80"] # Use IPv4 loopback explicitly to avoid IPv6 localhost resolution issues.
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
+27 -17
View File
@@ -17,49 +17,56 @@ const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
*/ */
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
// Run tests in parallel // Run tests in parallel
fullyParallel: true, fullyParallel: true,
// Fail the build on test.only() in CI // Fail the build on test.only() in CI
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
// Retry on CI only // Retry on CI only
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
// Limit parallel workers in CI // Limit parallel workers in CI
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
// Reporter configuration // Reporter configuration
reporter: [ reporter: [
["list"], ["list"],
["html", { outputFolder: "playwright-report" }], [
"html",
{
// Useful when a previous Docker run produced root-owned artifacts.
// Allows local runs to redirect output without editing the config.
outputFolder: process.env.PLAYWRIGHT_REPORT_DIR || "playwright-report",
},
],
], ],
// Output folder for test artifacts // Output folder for test artifacts
outputDir: "test-results", outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR || "test-results",
// Global timeout for each test // Global timeout for each test
timeout: 60000, timeout: 60000,
// Expect timeout // Expect timeout
expect: { expect: {
timeout: 10000, timeout: 10000,
}, },
use: { use: {
// Base URL for page.goto() // Base URL for page.goto()
baseURL: FRONTEND_URL, baseURL: FRONTEND_URL,
// Collect trace on first retry // Collect trace on first retry
trace: "on-first-retry", trace: "on-first-retry",
// Screenshot on failure // Screenshot on failure
screenshot: "only-on-failure", screenshot: "only-on-failure",
// Video on failure // Video on failure
video: "on-first-retry", video: "on-first-retry",
// Headed mode based on env var // Headed mode based on env var
headless: process.env.HEADED !== "true", headless: process.env.HEADED !== "true",
}, },
@@ -67,7 +74,7 @@ export default defineConfig({
projects: [ projects: [
{ {
name: "chromium", name: "chromium",
use: { use: {
...devices["Desktop Chrome"], ...devices["Desktop Chrome"],
// Viewport for consistent screenshots // Viewport for consistent screenshots
viewport: { width: 1280, height: 720 }, viewport: { width: 1280, height: 720 },
@@ -85,8 +92,11 @@ export default defineConfig({
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
env: { env: {
DATABASE_URL: "file:./prisma/dev.db", // 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, FRONTEND_URL,
CSRF_MAX_REQUESTS: "1000",
}, },
}, },
{ {
+11 -12
View File
@@ -1,6 +1,5 @@
import { test, expect, type BrowserContext, type Page } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { import {
API_URL,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
getDrawing, getDrawing,
@@ -22,7 +21,7 @@ test.describe("Real-time Collaboration", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -63,7 +62,7 @@ test.describe("Real-time Collaboration", () => {
// At least one page should show the other user // At least one page should show the other user
const hasCollaborator1 = await collaboratorIndicator1.count(); const hasCollaborator1 = await collaboratorIndicator1.count();
const hasCollaborator2 = await collaboratorIndicator2.count(); const hasCollaborator2 = await collaboratorIndicator2.count();
// Socket.io presence should eventually show users // Socket.io presence should eventually show users
// This test validates the socket connection works // This test validates the socket connection works
expect(hasCollaborator1 + hasCollaborator2).toBeGreaterThanOrEqual(0); expect(hasCollaborator1 + hasCollaborator2).toBeGreaterThanOrEqual(0);
@@ -75,7 +74,7 @@ test.describe("Real-time Collaboration", () => {
test("should sync drawing changes between two users", async ({ browser, request }) => { test("should sync drawing changes between two users", async ({ browser, request }) => {
// Create a test drawing // Create a test drawing
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `Collab_Sync_${Date.now()}`, name: `Collab_Sync_${Date.now()}`,
elements: [], elements: [],
}); });
@@ -121,10 +120,10 @@ test.describe("Real-time Collaboration", () => {
// Verify the drawing was saved (via API) // Verify the drawing was saved (via API)
const updatedDrawing = await getDrawing(request, drawing.id); const updatedDrawing = await getDrawing(request, drawing.id);
// The drawing should have elements now // The drawing should have elements now
const elements = updatedDrawing.elements || []; const elements = updatedDrawing.elements || [];
// Element sync happens via socket and periodic save // Element sync happens via socket and periodic save
// The test validates the drawing flow works end-to-end // The test validates the drawing flow works end-to-end
expect(elements).toBeDefined(); expect(elements).toBeDefined();
@@ -136,7 +135,7 @@ test.describe("Real-time Collaboration", () => {
test("should persist drawing changes across page reload", async ({ page, request }) => { test("should persist drawing changes across page reload", async ({ page, request }) => {
// Create a test drawing // Create a test drawing
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `Collab_Persist_${Date.now()}`, name: `Collab_Persist_${Date.now()}`,
elements: [], elements: [],
}); });
@@ -149,7 +148,7 @@ test.describe("Real-time Collaboration", () => {
// Draw something - use the interactive canvas layer // Draw something - use the interactive canvas layer
const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const canvas = page.locator("canvas.excalidraw__canvas.interactive");
// Select rectangle tool // Select rectangle tool
await page.keyboard.press("r"); await page.keyboard.press("r");
await page.waitForTimeout(200); await page.waitForTimeout(200);
@@ -157,7 +156,7 @@ test.describe("Real-time Collaboration", () => {
// Draw a rectangle - click on the interactive canvas // Draw a rectangle - click on the interactive canvas
const box = await canvas.boundingBox(); const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found"); if (!box) throw new Error("Canvas not found");
await page.mouse.move(box.x + 150, box.y + 150); await page.mouse.move(box.x + 150, box.y + 150);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(box.x + 350, box.y + 250, { steps: 5 }); await page.mouse.move(box.x + 350, box.y + 250, { steps: 5 });
@@ -205,7 +204,7 @@ test.describe("Real-time Collaboration", () => {
const canvas1 = page1.locator("canvas.excalidraw__canvas.interactive"); const canvas1 = page1.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas1.boundingBox(); const box = await canvas1.boundingBox();
if (!box) throw new Error("Canvas not found"); if (!box) throw new Error("Canvas not found");
await page1.mouse.move(box.x + 300, box.y + 300); await page1.mouse.move(box.x + 300, box.y + 300);
await page1.waitForTimeout(500); await page1.waitForTimeout(500);
await page1.mouse.move(box.x + 400, box.y + 400); await page1.mouse.move(box.x + 400, box.y + 400);
@@ -214,7 +213,7 @@ test.describe("Real-time Collaboration", () => {
// The cursor position should be broadcasted to page2 // The cursor position should be broadcasted to page2
// Excalidraw shows collaborator cursors with names // Excalidraw shows collaborator cursors with names
// This test validates the socket connection for cursor sync // This test validates the socket connection for cursor sync
// Wait for potential cursor updates // Wait for potential cursor updates
await page2.waitForTimeout(1000); await page2.waitForTimeout(1000);
+2 -2
View File
@@ -45,7 +45,7 @@ test.describe("Dashboard Workflows", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (error) { } catch {
// Ignore cleanup failures to keep tests resilient // Ignore cleanup failures to keep tests resilient
} }
} }
@@ -54,7 +54,7 @@ test.describe("Dashboard Workflows", () => {
for (const id of createdCollectionIds) { for (const id of createdCollectionIds) {
try { try {
await deleteCollection(request, id); await deleteCollection(request, id);
} catch (error) { } catch {
// Ignore cleanup failures to keep tests resilient // Ignore cleanup failures to keep tests resilient
} }
} }
+16 -17
View File
@@ -2,7 +2,6 @@ import { test, expect } from "@playwright/test";
import * as path from "path"; import * as path from "path";
import * as fs from "fs"; import * as fs from "fs";
import { import {
API_URL,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
listDrawings, listDrawings,
@@ -27,7 +26,7 @@ test.describe("Drag and Drop - Collections", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -36,7 +35,7 @@ test.describe("Drag and Drop - Collections", () => {
for (const id of createdCollectionIds) { for (const id of createdCollectionIds) {
try { try {
await deleteCollection(request, id); await deleteCollection(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -61,7 +60,7 @@ test.describe("Drag and Drop - Collections", () => {
// Hover to reveal the collection picker // Hover to reveal the collection picker
await card.hover(); await card.hover();
// Click the collection picker button on the card // Click the collection picker button on the card
const collectionPicker = card.locator(`[data-testid="collection-picker-${drawing.id}"]`); const collectionPicker = card.locator(`[data-testid="collection-picker-${drawing.id}"]`);
await collectionPicker.click(); await collectionPicker.click();
@@ -76,7 +75,7 @@ test.describe("Drag and Drop - Collections", () => {
// Navigate to the collection and verify drawing is there // Navigate to the collection and verify drawing is there
await page.getByRole("navigation").getByRole("button", { name: collection.name }).click(); await page.getByRole("navigation").getByRole("button", { name: collection.name }).click();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(card).toBeVisible(); await expect(card).toBeVisible();
}); });
@@ -85,9 +84,9 @@ test.describe("Drag and Drop - Collections", () => {
const collection = await createCollection(request, `UnorgTest_Collection_${Date.now()}`); const collection = await createCollection(request, `UnorgTest_Collection_${Date.now()}`);
createdCollectionIds.push(collection.id); createdCollectionIds.push(collection.id);
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `UnorgTest_Drawing_${Date.now()}`, name: `UnorgTest_Drawing_${Date.now()}`,
collectionId: collection.id collectionId: collection.id
}); });
createdDrawingIds.push(drawing.id); createdDrawingIds.push(drawing.id);
@@ -119,7 +118,7 @@ test.describe("Drag and Drop - Collections", () => {
// Navigate to Unorganized and verify drawing is there // Navigate to Unorganized and verify drawing is there
await page.getByRole("navigation").getByRole("button", { name: "Unorganized" }).click(); await page.getByRole("navigation").getByRole("button", { name: "Unorganized" }).click();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.locator(`#drawing-card-${drawing.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing.id}`)).toBeVisible();
}); });
@@ -146,7 +145,7 @@ test.describe("Drag and Drop - Collections", () => {
// Select both drawings // Select both drawings
const card1 = page.locator(`#drawing-card-${drawing1.id}`); const card1 = page.locator(`#drawing-card-${drawing1.id}`);
const card2 = page.locator(`#drawing-card-${drawing2.id}`); const card2 = page.locator(`#drawing-card-${drawing2.id}`);
await card1.hover(); await card1.hover();
const toggle1 = card1.locator(`[data-testid="select-drawing-${drawing1.id}"]`); const toggle1 = card1.locator(`[data-testid="select-drawing-${drawing1.id}"]`);
await toggle1.click(); await toggle1.click();
@@ -186,7 +185,7 @@ test.describe("Drag and Drop - File Import", () => {
for (const drawing of drawings) { for (const drawing of drawings) {
try { try {
await deleteDrawing(request, drawing.id); await deleteDrawing(request, drawing.id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -194,7 +193,7 @@ test.describe("Drag and Drop - File Import", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -205,7 +204,7 @@ test.describe("Drag and Drop - File Import", () => {
// Note: Simulating drag events with files is unreliable in Playwright // Note: Simulating drag events with files is unreliable in Playwright
// because the DataTransfer API has security restrictions. // because the DataTransfer API has security restrictions.
// This test verifies the drop zone UI exists and can be triggered. // This test verifies the drop zone UI exists and can be triggered.
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
@@ -218,13 +217,13 @@ test.describe("Drag and Drop - File Import", () => {
try { try {
const dt = new DataTransfer(); const dt = new DataTransfer();
dt.items.add(new File(['test'], 'test.excalidraw', { type: 'application/json' })); dt.items.add(new File(['test'], 'test.excalidraw', { type: 'application/json' }));
const event = new DragEvent('dragenter', { const event = new DragEvent('dragenter', {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
dataTransfer: dt, dataTransfer: dt,
}); });
// Find the main content area and dispatch the event // Find the main content area and dispatch the event
const main = document.querySelector('main'); const main = document.querySelector('main');
if (main) { if (main) {
@@ -242,7 +241,7 @@ test.describe("Drag and Drop - File Import", () => {
// Check that the drop zone overlay is shown // Check that the drop zone overlay is shown
const dropZone = page.getByText("Drop files to import"); const dropZone = page.getByText("Drop files to import");
const isVisible = await dropZone.isVisible().catch(() => false); const isVisible = await dropZone.isVisible().catch(() => false);
if (isVisible) { if (isVisible) {
await expect(dropZone).toBeVisible(); await expect(dropZone).toBeVisible();
} else { } else {
@@ -255,7 +254,7 @@ test.describe("Drag and Drop - File Import", () => {
} }
}); });
test("should import excalidraw file via file input", async ({ page, request }, testInfo) => { test("should import excalidraw file via file input", async ({ page }, testInfo) => {
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
@@ -275,7 +274,7 @@ test.describe("Drag and Drop - File Import", () => {
// Wait for import success modal // Wait for import success modal
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 });
// Dismiss the modal // Dismiss the modal
await page.getByRole("button", { name: "OK" }).click(); await page.getByRole("button", { name: "OK" }).click();
+29 -28
View File
@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { import {
API_URL,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
getDrawing, getDrawing,
@@ -24,7 +25,7 @@ test.describe("Drawing Creation", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -96,7 +97,7 @@ test.describe("Drawing Creation", () => {
test("should rename drawing via editor header", async ({ page, request }) => { test("should rename drawing via editor header", async ({ page, request }) => {
const originalName = `Rename_Original_${Date.now()}`; const originalName = `Rename_Original_${Date.now()}`;
const newName = `Rename_Updated_${Date.now()}`; const newName = `Rename_Updated_${Date.now()}`;
const drawing = await createDrawing(request, { name: originalName }); const drawing = await createDrawing(request, { name: originalName });
createdDrawingIds.push(drawing.id); createdDrawingIds.push(drawing.id);
@@ -150,7 +151,7 @@ test.describe("Drawing Editing", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -158,7 +159,7 @@ test.describe("Drawing Editing", () => {
}); });
test("should draw a rectangle on canvas", async ({ page, request }) => { test("should draw a rectangle on canvas", async ({ page, request }) => {
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `Draw_Rect_${Date.now()}`, name: `Draw_Rect_${Date.now()}`,
elements: [], elements: [],
}); });
@@ -172,19 +173,19 @@ test.describe("Drawing Editing", () => {
const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox(); const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found"); if (!box) throw new Error("Canvas not found");
console.log(`Canvas bounding box: x=${box.x}, y=${box.y}, width=${box.width}, height=${box.height}`); console.log(`Canvas bounding box: x=${box.x}, y=${box.y}, width=${box.width}, height=${box.height}`);
// Click on the rectangle tool using the label element // Click on the rectangle tool using the label element
// Find the label that contains the rectangle radio button // Find the label that contains the rectangle radio button
const rectangleLabel = page.locator('label:has([data-testid="toolbar-rectangle"])'); const rectangleLabel = page.locator('label:has([data-testid="toolbar-rectangle"])');
await rectangleLabel.click(); await rectangleLabel.click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Verify the tool was selected // Verify the tool was selected
const isRectangleSelectedBefore = await page.locator('[data-testid="toolbar-rectangle"]').isChecked(); const isRectangleSelectedBefore = await page.locator('[data-testid="toolbar-rectangle"]').isChecked();
console.log("Rectangle tool selected before drawing:", isRectangleSelectedBefore); console.log("Rectangle tool selected before drawing:", isRectangleSelectedBefore);
// Draw the rectangle by dragging on the canvas - use center of canvas // Draw the rectangle by dragging on the canvas - use center of canvas
const centerX = box.x + box.width / 2; const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2; const centerY = box.y + box.height / 2;
@@ -192,13 +193,13 @@ test.describe("Drawing Editing", () => {
const startY = centerY - 75; const startY = centerY - 75;
const endX = centerX + 100; const endX = centerX + 100;
const endY = centerY + 75; const endY = centerY + 75;
console.log(`Drawing from (${startX}, ${startY}) to (${endX}, ${endY})`); console.log(`Drawing from (${startX}, ${startY}) to (${endX}, ${endY})`);
// First click on the canvas to ensure it has focus // First click on the canvas to ensure it has focus
await page.mouse.click(centerX, centerY); await page.mouse.click(centerX, centerY);
await page.waitForTimeout(200); await page.waitForTimeout(200);
// Now draw the rectangle // Now draw the rectangle
await page.mouse.move(startX, startY); await page.mouse.move(startX, startY);
await page.waitForTimeout(100); await page.waitForTimeout(100);
@@ -207,10 +208,10 @@ test.describe("Drawing Editing", () => {
await page.mouse.move(endX, endY, { steps: 20 }); await page.mouse.move(endX, endY, { steps: 20 });
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.up(); await page.mouse.up();
// Take a screenshot after drawing // Take a screenshot after drawing
await page.screenshot({ path: 'test-results/after-drawing.png' }); await page.screenshot({ path: 'test-results/after-drawing.png' });
// Check if Undo button is now enabled (indicating something was drawn) // Check if Undo button is now enabled (indicating something was drawn)
const undoButton = page.locator('button[aria-label="Undo"]'); const undoButton = page.locator('button[aria-label="Undo"]');
const isUndoDisabled = await undoButton.getAttribute('disabled'); const isUndoDisabled = await undoButton.getAttribute('disabled');
@@ -231,7 +232,7 @@ test.describe("Drawing Editing", () => {
}); });
test("should draw text on canvas", async ({ page, request }) => { test("should draw text on canvas", async ({ page, request }) => {
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `Draw_Text_${Date.now()}`, name: `Draw_Text_${Date.now()}`,
elements: [], elements: [],
}); });
@@ -245,11 +246,11 @@ test.describe("Drawing Editing", () => {
const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox(); const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found"); if (!box) throw new Error("Canvas not found");
// Click to focus the canvas // Click to focus the canvas
await page.mouse.click(box.x + 100, box.y + 100); await page.mouse.click(box.x + 100, box.y + 100);
await page.waitForTimeout(100); await page.waitForTimeout(100);
// Select text tool using keyboard shortcut (now that canvas is focused) // Select text tool using keyboard shortcut (now that canvas is focused)
await page.keyboard.press("t"); await page.keyboard.press("t");
await page.waitForTimeout(200); await page.waitForTimeout(200);
@@ -260,7 +261,7 @@ test.describe("Drawing Editing", () => {
// Type some text // Type some text
await page.keyboard.type("Hello E2E Test"); await page.keyboard.type("Hello E2E Test");
// Press Escape to finish text editing // Press Escape to finish text editing
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.waitForTimeout(500); await page.waitForTimeout(500);
@@ -276,7 +277,7 @@ test.describe("Drawing Editing", () => {
}); });
test("should use undo/redo functionality", async ({ page, request }) => { test("should use undo/redo functionality", async ({ page, request }) => {
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `Undo_Redo_${Date.now()}`, name: `Undo_Redo_${Date.now()}`,
elements: [], elements: [],
}); });
@@ -290,10 +291,10 @@ test.describe("Drawing Editing", () => {
const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox(); const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found"); if (!box) throw new Error("Canvas not found");
await page.keyboard.press("r"); await page.keyboard.press("r");
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page.mouse.move(box.x + 200, box.y + 200); await page.mouse.move(box.x + 200, box.y + 200);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(box.x + 300, box.y + 300, { steps: 5 }); await page.mouse.move(box.x + 300, box.y + 300, { steps: 5 });
@@ -320,7 +321,7 @@ test.describe("Drawing Deletion", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -341,7 +342,7 @@ test.describe("Drawing Deletion", () => {
// Find the card and select it // Find the card and select it
const card = page.locator(`#drawing-card-${drawing.id}`); const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover(); await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`); const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click(); await selectToggle.click();
@@ -360,9 +361,9 @@ test.describe("Drawing Deletion", () => {
}); });
test("should permanently delete drawing from trash", async ({ page, request }) => { test("should permanently delete drawing from trash", async ({ page, request }) => {
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `Perm_Delete_${Date.now()}`, name: `Perm_Delete_${Date.now()}`,
collectionId: "trash" collectionId: "trash"
}); });
createdDrawingIds.push(drawing.id); createdDrawingIds.push(drawing.id);
@@ -374,7 +375,7 @@ test.describe("Drawing Deletion", () => {
// Select the drawing // Select the drawing
const card = page.locator(`#drawing-card-${drawing.id}`); const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover(); await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`); const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click(); await selectToggle.click();
@@ -388,7 +389,7 @@ test.describe("Drawing Deletion", () => {
await expect(card).not.toBeVisible(); await expect(card).not.toBeVisible();
// Verify via API that drawing is deleted // Verify via API that drawing is deleted
const response = await request.get(`http://localhost:8000/drawings/${drawing.id}`); const response = await request.get(`${API_URL}/drawings/${drawing.id}`);
expect(response.status()).toBe(404); expect(response.status()).toBe(404);
// Remove from cleanup list since it's already deleted // Remove from cleanup list since it's already deleted
@@ -409,7 +410,7 @@ test.describe("Drawing Deletion", () => {
// Select the drawing // Select the drawing
const card = page.locator(`#drawing-card-${drawing.id}`); const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover(); await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`); const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click(); await selectToggle.click();
@@ -422,7 +423,7 @@ test.describe("Drawing Deletion", () => {
// Clear search to see all drawings // Clear search to see all drawings
await page.getByPlaceholder("Search drawings...").fill(""); await page.getByPlaceholder("Search drawings...").fill("");
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Search again to find both // Search again to find both
await page.getByPlaceholder("Search drawings...").fill("Duplicate_Test"); await page.getByPlaceholder("Search drawings...").fill("Duplicate_Test");
await page.waitForTimeout(500); await page.waitForTimeout(500);
+23 -25
View File
@@ -1,12 +1,10 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
import { import {
API_URL, API_URL,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
getCsrfHeaders,
listDrawings, listDrawings,
createCollection,
deleteCollection, deleteCollection,
} from "./helpers/api"; } from "./helpers/api";
@@ -29,7 +27,7 @@ test.describe("Export Functionality", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -38,7 +36,7 @@ test.describe("Export Functionality", () => {
for (const id of createdCollectionIds) { for (const id of createdCollectionIds) {
try { try {
await deleteCollection(request, id); await deleteCollection(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -85,11 +83,11 @@ test.describe("Export Functionality", () => {
// Test JSON/ZIP export endpoint - it returns a ZIP file with .excalidraw files // Test JSON/ZIP export endpoint - it returns a ZIP file with .excalidraw files
const zipResponse = await request.get(`${API_URL}/export/json`); const zipResponse = await request.get(`${API_URL}/export/json`);
expect(zipResponse.ok()).toBe(true); expect(zipResponse.ok()).toBe(true);
// Check it's a ZIP file // Check it's a ZIP file
const contentType = zipResponse.headers()["content-type"]; const contentType = zipResponse.headers()["content-type"];
expect(contentType).toMatch(/application\/zip/); expect(contentType).toMatch(/application\/zip/);
// Check content-disposition header // Check content-disposition header
const contentDisposition = zipResponse.headers()["content-disposition"]; const contentDisposition = zipResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment"); expect(contentDisposition).toContain("attachment");
@@ -103,11 +101,11 @@ test.describe("Export Functionality", () => {
// Test SQLite export endpoint // Test SQLite export endpoint
const sqliteResponse = await request.get(`${API_URL}/export`); const sqliteResponse = await request.get(`${API_URL}/export`);
expect(sqliteResponse.ok()).toBe(true); expect(sqliteResponse.ok()).toBe(true);
// Check content-type header indicates a file download // Check content-type header indicates a file download
const contentType = sqliteResponse.headers()["content-type"]; const contentType = sqliteResponse.headers()["content-type"];
expect(contentType).toMatch(/application\/octet-stream|application\/x-sqlite3/); expect(contentType).toMatch(/application\/octet-stream|application\/x-sqlite3/);
// Check content-disposition header // Check content-disposition header
const contentDisposition = sqliteResponse.headers()["content-disposition"]; const contentDisposition = sqliteResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment"); expect(contentDisposition).toContain("attachment");
@@ -121,7 +119,7 @@ test.describe("Export Functionality", () => {
// Test .db export endpoint // Test .db export endpoint
const dbResponse = await request.get(`${API_URL}/export?format=db`); const dbResponse = await request.get(`${API_URL}/export?format=db`);
expect(dbResponse.ok()).toBe(true); expect(dbResponse.ok()).toBe(true);
const contentDisposition = dbResponse.headers()["content-disposition"]; const contentDisposition = dbResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment"); expect(contentDisposition).toContain("attachment");
expect(contentDisposition).toMatch(/\.db/); expect(contentDisposition).toMatch(/\.db/);
@@ -137,7 +135,7 @@ test.describe.serial("Import Functionality", () => {
for (const drawing of testDrawings) { for (const drawing of testDrawings) {
try { try {
await deleteDrawing(request, drawing.id); await deleteDrawing(request, drawing.id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -145,7 +143,7 @@ test.describe.serial("Import Functionality", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -161,7 +159,7 @@ test.describe.serial("Import Functionality", () => {
await expect(importButton).toBeVisible(); await expect(importButton).toBeVisible();
}); });
test("should import .excalidraw file from Dashboard", async ({ page, request }) => { test("should import .excalidraw file from Dashboard", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
@@ -206,15 +204,14 @@ test.describe.serial("Import Functionality", () => {
}); });
// Write temp file // Write temp file
const tempDir = "/tmp"; // tempFile was here
const tempFile = `${tempDir}/Import_Test_${Date.now()}.excalidraw`;
// Use page.evaluate to check if we can proceed // Use page.evaluate to check if we can proceed
// Actually, Playwright has setInputFiles which can handle this // Actually, Playwright has setInputFiles which can handle this
// Find the import file input // Find the import file input
const fileInput = page.locator("#dashboard-import"); const fileInput = page.locator("#dashboard-import");
// Create a buffer from the fixture content // Create a buffer from the fixture content
await fileInput.setInputFiles({ await fileInput.setInputFiles({
name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`, name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`,
@@ -237,13 +234,13 @@ test.describe.serial("Import Functionality", () => {
await expect(importedCards.first()).toBeVisible({ timeout: 10000 }); await expect(importedCards.first()).toBeVisible({ timeout: 10000 });
}); });
test("should import JSON drawing file from Dashboard", async ({ page, request }) => { test("should import JSON drawing file from Dashboard", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const timestamp = Date.now(); const timestamp = Date.now();
const testName = `Import_JSONTest_${timestamp}`; const testName = `Import_JSONTest_${timestamp}`;
// Create a valid excalidraw JSON file with required fields // Create a valid excalidraw JSON file with required fields
const jsonContent = JSON.stringify({ const jsonContent = JSON.stringify({
type: "excalidraw", type: "excalidraw",
@@ -283,7 +280,7 @@ test.describe.serial("Import Functionality", () => {
}); });
const fileInput = page.locator("#dashboard-import"); const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles({ await fileInput.setInputFiles({
name: `${testName}.json`, name: `${testName}.json`,
mimeType: "application/json", mimeType: "application/json",
@@ -293,9 +290,9 @@ test.describe.serial("Import Functionality", () => {
// Wait for import result - could be success or failure // Wait for import result - could be success or failure
const successModal = page.getByText("Import Successful"); const successModal = page.getByText("Import Successful");
const failModal = page.getByText("Import Failed"); const failModal = page.getByText("Import Failed");
await expect(successModal.or(failModal)).toBeVisible({ timeout: 15000 }); await expect(successModal.or(failModal)).toBeVisible({ timeout: 15000 });
// If we got a failure, check the error // If we got a failure, check the error
if (await failModal.isVisible()) { if (await failModal.isVisible()) {
// Get the error message // Get the error message
@@ -306,7 +303,7 @@ test.describe.serial("Import Functionality", () => {
// Skip the rest of the test since import failed // Skip the rest of the test since import failed
return; return;
} }
await page.getByRole("button", { name: "OK" }).click(); await page.getByRole("button", { name: "OK" }).click();
// Reload to force a fresh fetch of drawings after import // Reload to force a fresh fetch of drawings after import
@@ -331,7 +328,7 @@ test.describe.serial("Import Functionality", () => {
const invalidContent = "this is not valid JSON or excalidraw format {}{}"; const invalidContent = "this is not valid JSON or excalidraw format {}{}";
const fileInput = page.locator("#dashboard-import"); const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles({ await fileInput.setInputFiles({
name: `Import_Invalid_${Date.now()}.excalidraw`, name: `Import_Invalid_${Date.now()}.excalidraw`,
mimeType: "application/json", mimeType: "application/json",
@@ -394,6 +391,7 @@ test.describe("Database Import Verification", () => {
// Test that the verification endpoint responds // Test that the verification endpoint responds
// We don't actually import a database as that would affect the test environment // We don't actually import a database as that would affect the test environment
const response = await request.post(`${API_URL}/import/sqlite/verify`, { const response = await request.post(`${API_URL}/import/sqlite/verify`, {
headers: await getCsrfHeaders(request),
// Send empty form data to test endpoint exists // Send empty form data to test endpoint exists
multipart: { multipart: {
db: { db: {
@@ -403,7 +401,7 @@ test.describe("Database Import Verification", () => {
}, },
}, },
}); });
// Should get an error response since the file is empty/invalid // Should get an error response since the file is empty/invalid
// But the endpoint should exist // But the endpoint should exist
expect([400, 500]).toContain(response.status()); expect([400, 500]).toContain(response.status());
+141 -6
View File
@@ -5,6 +5,91 @@ const DEFAULT_BACKEND_PORT = 8000;
export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`; export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`;
type CsrfTokenResponse = {
token: string;
header?: string;
};
type CsrfInfo = {
token: string;
headerName: string;
};
// Cache CSRF tokens per Playwright request context so parallel tests don't race.
const csrfInfoByRequest = new WeakMap<APIRequestContext, CsrfInfo>();
const csrfFetchByRequest = new WeakMap<APIRequestContext, Promise<CsrfInfo>>();
const fetchCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const response = await request.get(`${API_URL}/csrf-token`);
if (!response.ok()) {
const text = await response.text();
throw new Error(
`Failed to fetch CSRF token: ${response.status()} ${text || "(empty response)"}`
);
}
const data = (await response.json()) as CsrfTokenResponse;
if (!data || typeof data.token !== "string" || data.token.trim().length === 0) {
throw new Error("Failed to fetch CSRF token: missing token in response");
}
const headerName =
typeof data.header === "string" && data.header.trim().length > 0
? data.header
: "x-csrf-token";
return { token: data.token, headerName };
};
const getCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const cached = csrfInfoByRequest.get(request);
if (cached) return cached;
const inFlight = csrfFetchByRequest.get(request);
if (inFlight) return inFlight;
const promise = fetchCsrfInfo(request)
.then((info) => {
csrfInfoByRequest.set(request, info);
return info;
})
.finally(() => {
csrfFetchByRequest.delete(request);
});
csrfFetchByRequest.set(request, promise);
return promise;
};
const refreshCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const promise = fetchCsrfInfo(request)
.then((info) => {
csrfInfoByRequest.set(request, info);
return info;
})
.finally(() => {
csrfFetchByRequest.delete(request);
});
csrfFetchByRequest.set(request, promise);
return promise;
};
export async function getCsrfHeaders(
request: APIRequestContext
): Promise<Record<string, string>> {
const info = await getCsrfInfo(request);
return { [info.headerName]: info.token };
}
const withCsrfHeaders = async (
request: APIRequestContext,
headers: Record<string, string> = {}
): Promise<Record<string, string>> => ({
...headers,
...(await getCsrfHeaders(request)),
});
export interface DrawingRecord { export interface DrawingRecord {
id: string; id: string;
name: string; name: string;
@@ -53,10 +138,26 @@ export async function createDrawing(
overrides: CreateDrawingOptions = {} overrides: CreateDrawingOptions = {}
): Promise<DrawingRecord> { ): Promise<DrawingRecord> {
const payload = { ...defaultDrawingPayload(), ...overrides }; const payload = { ...defaultDrawingPayload(), ...overrides };
const response = await request.post(`${API_URL}/drawings`, { const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
headers: { "Content-Type": "application/json" },
let response = await request.post(`${API_URL}/drawings`, {
headers,
data: payload, 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) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
response = await request.post(`${API_URL}/drawings`, {
headers: retryHeaders,
data: payload,
});
}
if (!response.ok()) { if (!response.ok()) {
const text = await response.text(); const text = await response.text();
throw new Error(`Failed to create drawing: ${response.status()} ${text}`); throw new Error(`Failed to create drawing: ${response.status()} ${text}`);
@@ -77,7 +178,17 @@ export async function deleteDrawing(
request: APIRequestContext, request: APIRequestContext,
id: string id: string
): Promise<void> { ): Promise<void> {
const response = await request.delete(`${API_URL}/drawings/${id}`); const headers = await withCsrfHeaders(request);
let 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,
});
}
if (!response.ok()) { if (!response.ok()) {
// Ignore not found to keep cleanup idempotent // Ignore not found to keep cleanup idempotent
if (response.status() !== 404) { if (response.status() !== 404) {
@@ -113,10 +224,24 @@ export async function createCollection(
request: APIRequestContext, request: APIRequestContext,
name: string name: string
): Promise<CollectionRecord> { ): Promise<CollectionRecord> {
const response = await request.post(`${API_URL}/collections`, { const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
headers: { "Content-Type": "application/json" },
let response = await request.post(`${API_URL}/collections`, {
headers,
data: { name }, data: { name },
}); });
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
response = await request.post(`${API_URL}/collections`, {
headers: retryHeaders,
data: { name },
});
}
expect(response.ok()).toBe(true); expect(response.ok()).toBe(true);
return (await response.json()) as CollectionRecord; return (await response.json()) as CollectionRecord;
} }
@@ -133,7 +258,17 @@ export async function deleteCollection(
request: APIRequestContext, request: APIRequestContext,
id: string id: string
): Promise<void> { ): Promise<void> {
const response = await request.delete(`${API_URL}/collections/${id}`); const headers = await withCsrfHeaders(request);
let 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,
});
}
if (!response.ok()) { if (!response.ok()) {
if (response.status() !== 404) { if (response.status() !== 404) {
const text = await response.text(); const text = await response.text();
+53 -41
View File
@@ -1,7 +1,13 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import { API_URL, createDrawing, deleteDrawing, getDrawing } from "./helpers/api"; import {
API_URL,
createDrawing,
deleteDrawing,
getCsrfHeaders,
getDrawing,
} from "./helpers/api";
/** /**
* E2E Browser Tests for Image Persistence - Issue #17 Regression * E2E Browser Tests for Image Persistence - Issue #17 Regression
@@ -28,13 +34,13 @@ function generateLargeImageDataUrl(sizeInBytes: number = 50000): string {
test.describe("Image Persistence - Browser E2E Tests", () => { test.describe("Image Persistence - Browser E2E Tests", () => {
let testDrawingIds: string[] = []; let testDrawingIds: string[] = [];
test.afterEach(async ({ request }) => { test.afterEach(async ({ request }) => {
// Clean up any drawings created during tests // Clean up any drawings created during tests
for (const id of testDrawingIds) { for (const id of testDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -43,23 +49,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
test("should navigate to dashboard and see drawing list", async ({ page }) => { test("should navigate to dashboard and see drawing list", async ({ page }) => {
await page.goto("/"); await page.goto("/");
// Wait for the page to load // Wait for the page to load
await expect(page).toHaveTitle(/ExcaliDash/i); await expect(page).toHaveTitle(/ExcaliDash/i);
// The dashboard should show some UI elements // The dashboard should show some UI elements
await expect(page.locator("body")).toBeVisible(); await expect(page.locator("body")).toBeVisible();
}); });
test("should create a new drawing via UI", async ({ page }) => { test("should create a new drawing via UI", async ({ page }) => {
await page.goto("/"); await page.goto("/");
// Look for a "New Drawing" or similar button // Look for a "New Drawing" or similar button
const newDrawingBtn = page.getByRole("button", { name: /new|create/i }).first(); const newDrawingBtn = page.getByRole("button", { name: /new|create/i }).first();
if (await newDrawingBtn.isVisible()) { if (await newDrawingBtn.isVisible()) {
await newDrawingBtn.click(); await newDrawingBtn.click();
// Should navigate to editor or show a modal // Should navigate to editor or show a modal
await page.waitForURL(/\/(editor|drawing)/i, { timeout: 5000 }).catch(() => { await page.waitForURL(/\/(editor|drawing)/i, { timeout: 5000 }).catch(() => {
// May stay on same page with modal // May stay on same page with modal
@@ -71,7 +77,7 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
// This is the core regression test for issue #17 // This is the core regression test for issue #17
const largeDataUrl = generateLargeImageDataUrl(50000); const largeDataUrl = generateLargeImageDataUrl(50000);
expect(largeDataUrl.length).toBeGreaterThan(10000); expect(largeDataUrl.length).toBeGreaterThan(10000);
const files = { const files = {
"test-image-1": { "test-image-1": {
id: "test-image-1", id: "test-image-1",
@@ -80,23 +86,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
created: Date.now(), created: Date.now(),
}, },
}; };
// Create drawing with large image // Create drawing with large image
const createdDrawing = await createDrawing(request, { const createdDrawing = await createDrawing(request, {
name: "E2E Test - Large Image", name: "E2E Test - Large Image",
files, files,
}); });
testDrawingIds.push(createdDrawing.id); testDrawingIds.push(createdDrawing.id);
// Retrieve the drawing // Retrieve the drawing
const drawing = await getDrawing(request, createdDrawing.id); const drawing = await getDrawing(request, createdDrawing.id);
const savedFiles = drawing.files || {}; // Already parsed by API const savedFiles = drawing.files || {}; // Already parsed by API
// Verify the image data was preserved // Verify the image data was preserved
expect(savedFiles["test-image-1"]).toBeDefined(); expect(savedFiles["test-image-1"]).toBeDefined();
expect(savedFiles["test-image-1"].dataURL).toBe(largeDataUrl); expect(savedFiles["test-image-1"].dataURL).toBe(largeDataUrl);
expect(savedFiles["test-image-1"].dataURL.length).toBe(largeDataUrl.length); expect(savedFiles["test-image-1"].dataURL.length).toBe(largeDataUrl.length);
console.log("✓ Large image data preserved correctly through save/reload cycle"); console.log("✓ Large image data preserved correctly through save/reload cycle");
}); });
@@ -106,36 +112,36 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
name: "E2E Test - Editor View", name: "E2E Test - Editor View",
}); });
testDrawingIds.push(createdDrawing.id); testDrawingIds.push(createdDrawing.id);
// Navigate to the editor // Navigate to the editor
await page.goto(`/editor/${createdDrawing.id}`); await page.goto(`/editor/${createdDrawing.id}`);
// Wait for the page to load // Wait for the page to load
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// The editor should be visible (Excalidraw canvas) // The editor should be visible (Excalidraw canvas)
// Look for the Excalidraw container or canvas // Look for the Excalidraw container or canvas
const editorContainer = page.locator("[class*='excalidraw'], canvas").first(); const editorContainer = page.locator("[class*='excalidraw'], canvas").first();
await expect(editorContainer).toBeVisible({ timeout: 10000 }); await expect(editorContainer).toBeVisible({ timeout: 10000 });
}); });
test("should import .excalidraw file with embedded image", async ({ page, request }) => { test("should import .excalidraw file with embedded image", async ({ request }) => {
// Load the test fixture // Load the test fixture
const fixturePath = path.join(__dirname, "..", "fixtures", "small-image.excalidraw"); const fixturePath = path.join(__dirname, "..", "fixtures", "small-image.excalidraw");
const fixtureContent = fs.readFileSync(fixturePath, "utf-8"); const fixtureContent = fs.readFileSync(fixturePath, "utf-8");
const fixtureData = JSON.parse(fixtureContent); const fixtureData = JSON.parse(fixtureContent);
// Create drawing via API with fixture data // Create drawing via API with fixture data
const createdDrawing = await createDrawing(request, { const createdDrawing = await createDrawing(request, {
name: "E2E Test - Imported Image", name: "E2E Test - Imported Image",
files: fixtureData.files, files: fixtureData.files,
}); });
testDrawingIds.push(createdDrawing.id); testDrawingIds.push(createdDrawing.id);
// Verify via API that image data was preserved // Verify via API that image data was preserved
const drawing = await getDrawing(request, createdDrawing.id); const drawing = await getDrawing(request, createdDrawing.id);
const savedFiles = drawing.files || {}; // Already parsed by API const savedFiles = drawing.files || {}; // Already parsed by API
expect(savedFiles["embedded-test-image"]).toBeDefined(); expect(savedFiles["embedded-test-image"]).toBeDefined();
expect(savedFiles["embedded-test-image"].dataURL).toBe(fixtureData.files["embedded-test-image"].dataURL); expect(savedFiles["embedded-test-image"].dataURL).toBe(fixtureData.files["embedded-test-image"].dataURL);
}); });
@@ -161,23 +167,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
created: Date.now(), created: Date.now(),
}, },
}; };
const createdDrawing = await createDrawing(request, { const createdDrawing = await createDrawing(request, {
name: "E2E Test - Multiple Images", name: "E2E Test - Multiple Images",
files, files,
}); });
testDrawingIds.push(createdDrawing.id); testDrawingIds.push(createdDrawing.id);
const drawing = await getDrawing(request, createdDrawing.id); const drawing = await getDrawing(request, createdDrawing.id);
const savedFiles = drawing.files || {}; // Already parsed by API const savedFiles = drawing.files || {}; // Already parsed by API
// Verify all images preserved correctly // Verify all images preserved correctly
for (const [id, originalFile] of Object.entries(files)) { for (const [id, originalFile] of Object.entries(files)) {
expect(savedFiles[id]).toBeDefined(); expect(savedFiles[id]).toBeDefined();
expect(savedFiles[id].dataURL).toBe((originalFile as any).dataURL); expect(savedFiles[id].dataURL).toBe((originalFile as any).dataURL);
expect(savedFiles[id].dataURL.length).toBe((originalFile as any).dataURL.length); expect(savedFiles[id].dataURL.length).toBe((originalFile as any).dataURL.length);
} }
console.log("✓ Multiple images of varying sizes preserved correctly"); console.log("✓ Multiple images of varying sizes preserved correctly");
}); });
}); });
@@ -192,10 +198,11 @@ test.describe("Security - Malicious Content Blocking", () => {
created: Date.now(), created: Date.now(),
}, },
}; };
const response = await request.post(`${API_URL}/drawings`, { const response = await request.post(`${API_URL}/drawings`, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(await getCsrfHeaders(request)),
}, },
data: { data: {
name: "Security Test - JS URL", name: "Security Test - JS URL",
@@ -205,7 +212,7 @@ test.describe("Security - Malicious Content Blocking", () => {
preview: null, preview: null,
}, },
}); });
if (!response.ok()) { if (!response.ok()) {
const text = await response.text(); const text = await response.text();
console.error(`API Error: ${response.status()} - ${text}`); console.error(`API Error: ${response.status()} - ${text}`);
@@ -213,12 +220,14 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(response.ok()).toBe(true); expect(response.ok()).toBe(true);
const drawing = await response.json(); const drawing = await response.json();
const savedFiles = drawing.files; // Already parsed by API const savedFiles = drawing.files; // Already parsed by API
// The malicious URL should be blocked/cleared // The malicious URL should be blocked/cleared
expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:"); expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:");
// Cleanup // Cleanup
await request.delete(`${API_URL}/drawings/${drawing.id}`); await request.delete(`${API_URL}/drawings/${drawing.id}`, {
headers: await getCsrfHeaders(request),
});
}); });
test("should block script tags in image data", async ({ request }) => { test("should block script tags in image data", async ({ request }) => {
@@ -230,10 +239,11 @@ test.describe("Security - Malicious Content Blocking", () => {
created: Date.now(), created: Date.now(),
}, },
}; };
const response = await request.post(`${API_URL}/drawings`, { const response = await request.post(`${API_URL}/drawings`, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(await getCsrfHeaders(request)),
}, },
data: { data: {
name: "Security Test - Script Tag", name: "Security Test - Script Tag",
@@ -243,7 +253,7 @@ test.describe("Security - Malicious Content Blocking", () => {
preview: null, preview: null,
}, },
}); });
if (!response.ok()) { if (!response.ok()) {
const text = await response.text(); const text = await response.text();
console.error(`API Error: ${response.status()} - ${text}`); console.error(`API Error: ${response.status()} - ${text}`);
@@ -251,11 +261,13 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(response.ok()).toBe(true); expect(response.ok()).toBe(true);
const drawing = await response.json(); const drawing = await response.json();
const savedFiles = drawing.files; // Already parsed by API const savedFiles = drawing.files; // Already parsed by API
// The script tag should be blocked // The script tag should be blocked
expect(savedFiles["malicious-image"].dataURL).not.toContain("<script>"); expect(savedFiles["malicious-image"].dataURL).not.toContain("<script>");
// Cleanup // Cleanup
await request.delete(`${API_URL}/drawings/${drawing.id}`); await request.delete(`${API_URL}/drawings/${drawing.id}`, {
headers: await getCsrfHeaders(request),
});
}); });
}); });
+18 -19
View File
@@ -2,7 +2,6 @@ import { test, expect } from "@playwright/test";
import { import {
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
listDrawings,
} from "./helpers/api"; } from "./helpers/api";
/** /**
@@ -21,7 +20,7 @@ test.describe("Search Drawings", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -47,10 +46,10 @@ test.describe("Search Drawings", () => {
// Search for the prefix - should show only matching drawings // Search for the prefix - should show only matching drawings
await searchInput.fill(prefix); await searchInput.fill(prefix);
// Wait for search to apply (debounced) // Wait for search to apply (debounced)
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Verify only matching drawings are shown // Verify only matching drawings are shown
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible();
@@ -59,7 +58,7 @@ test.describe("Search Drawings", () => {
// Search for specific drawing // Search for specific drawing
await searchInput.fill(`${prefix}_Alpha`); await searchInput.fill(`${prefix}_Alpha`);
await page.waitForTimeout(500); await page.waitForTimeout(500);
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
await expect(page.locator(`#drawing-card-${drawing2.id}`)).not.toBeVisible(); await expect(page.locator(`#drawing-card-${drawing2.id}`)).not.toBeVisible();
}); });
@@ -92,7 +91,7 @@ test.describe("Search Drawings", () => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const searchInput = page.getByPlaceholder("Search drawings..."); const searchInput = page.getByPlaceholder("Search drawings...");
// Search for one drawing // Search for one drawing
await searchInput.fill(`${prefix}_One`); await searchInput.fill(`${prefix}_One`);
await page.waitForTimeout(500); await page.waitForTimeout(500);
@@ -105,7 +104,7 @@ test.describe("Search Drawings", () => {
// Search for prefix to find both // Search for prefix to find both
await searchInput.fill(prefix); await searchInput.fill(prefix);
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Both should be visible now // Both should be visible now
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible();
@@ -119,10 +118,10 @@ test.describe("Search Drawings", () => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const searchInput = page.getByPlaceholder("Search drawings..."); const searchInput = page.getByPlaceholder("Search drawings...");
// Use keyboard shortcut (Cmd+K on Mac, Ctrl+K on Windows/Linux) // Use keyboard shortcut (Cmd+K on Mac, Ctrl+K on Windows/Linux)
await page.keyboard.press("Meta+k"); await page.keyboard.press("Meta+k");
// Search input should be focused // Search input should be focused
await expect(searchInput).toBeFocused(); await expect(searchInput).toBeFocused();
}); });
@@ -135,7 +134,7 @@ test.describe("Sort Drawings", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -144,7 +143,7 @@ test.describe("Sort Drawings", () => {
test("should sort drawings by name", async ({ page, request }) => { test("should sort drawings by name", async ({ page, request }) => {
const prefix = `SortTest_${Date.now()}`; const prefix = `SortTest_${Date.now()}`;
// Create drawings with names that sort in a specific order // Create drawings with names that sort in a specific order
const [drawingC, drawingA, drawingB] = await Promise.all([ const [drawingC, drawingA, drawingB] = await Promise.all([
createDrawing(request, { name: `${prefix}_Charlie` }), createDrawing(request, { name: `${prefix}_Charlie` }),
@@ -176,7 +175,7 @@ test.describe("Sort Drawings", () => {
test("should toggle sort direction on repeated clicks", async ({ page, request }) => { test("should toggle sort direction on repeated clicks", async ({ page, request }) => {
const prefix = `ToggleSortTest_${Date.now()}`; const prefix = `ToggleSortTest_${Date.now()}`;
const [drawingA, drawingZ] = await Promise.all([ const [drawingA, drawingZ] = await Promise.all([
createDrawing(request, { name: `${prefix}_AAA` }), createDrawing(request, { name: `${prefix}_AAA` }),
createDrawing(request, { name: `${prefix}_ZZZ` }), createDrawing(request, { name: `${prefix}_ZZZ` }),
@@ -191,11 +190,11 @@ test.describe("Sort Drawings", () => {
await page.waitForTimeout(500); await page.waitForTimeout(500);
const nameSortButton = page.getByRole("button", { name: "Name" }); const nameSortButton = page.getByRole("button", { name: "Name" });
// First click - ascending (A first) // First click - ascending (A first)
await nameSortButton.click(); await nameSortButton.click();
await page.waitForTimeout(200); await page.waitForTimeout(200);
let cards = page.locator("[id^='drawing-card-']"); let cards = page.locator("[id^='drawing-card-']");
let firstCard = cards.first(); let firstCard = cards.first();
await expect(firstCard).toHaveId(`drawing-card-${drawingA.id}`); await expect(firstCard).toHaveId(`drawing-card-${drawingA.id}`);
@@ -203,7 +202,7 @@ test.describe("Sort Drawings", () => {
// Second click - descending (Z first) // Second click - descending (Z first)
await nameSortButton.click(); await nameSortButton.click();
await page.waitForTimeout(200); await page.waitForTimeout(200);
cards = page.locator("[id^='drawing-card-']"); cards = page.locator("[id^='drawing-card-']");
firstCard = cards.first(); firstCard = cards.first();
await expect(firstCard).toHaveId(`drawing-card-${drawingZ.id}`); await expect(firstCard).toHaveId(`drawing-card-${drawingZ.id}`);
@@ -211,13 +210,13 @@ test.describe("Sort Drawings", () => {
test("should sort by date created", async ({ page, request }) => { test("should sort by date created", async ({ page, request }) => {
const prefix = `DateSortTest_${Date.now()}`; const prefix = `DateSortTest_${Date.now()}`;
// Create drawings sequentially to ensure different creation times // Create drawings sequentially to ensure different creation times
const drawing1 = await createDrawing(request, { name: `${prefix}_First` }); const drawing1 = await createDrawing(request, { name: `${prefix}_First` });
createdDrawingIds.push(drawing1.id); createdDrawingIds.push(drawing1.id);
await page.waitForTimeout(100); // Ensure different timestamps await page.waitForTimeout(100); // Ensure different timestamps
const drawing2 = await createDrawing(request, { name: `${prefix}_Second` }); const drawing2 = await createDrawing(request, { name: `${prefix}_Second` });
createdDrawingIds.push(drawing2.id); createdDrawingIds.push(drawing2.id);
@@ -241,7 +240,7 @@ test.describe("Sort Drawings", () => {
test("should sort by date modified", async ({ page, request }) => { test("should sort by date modified", async ({ page, request }) => {
const prefix = `ModifiedSortTest_${Date.now()}`; const prefix = `ModifiedSortTest_${Date.now()}`;
const [drawing1, drawing2] = await Promise.all([ const [drawing1, drawing2] = await Promise.all([
createDrawing(request, { name: `${prefix}_One` }), createDrawing(request, { name: `${prefix}_One` }),
createDrawing(request, { name: `${prefix}_Two` }), createDrawing(request, { name: `${prefix}_Two` }),
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
// Locally, you may need to start them manually or use npm run dev // Locally, you may need to start them manually or use npm run dev
webServer: process.env.CI ? [ webServer: process.env.CI ? [
{ {
command: "cd ../backend && DATABASE_URL=file:./prisma/dev.db npm run dev", command: "cd ../backend && DATABASE_URL=file:./dev.db npm run dev",
url: "http://localhost:8000/health", url: "http://localhost:8000/health",
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120000, timeout: 120000,
+85
View File
@@ -7,6 +7,91 @@ export const api = axios.create({
baseURL: API_URL, baseURL: API_URL,
}); });
// CSRF Token Management
let csrfToken: string | null = null;
let csrfHeaderName: string = "x-csrf-token";
let csrfTokenPromise: Promise<void> | null = null;
/**
* Fetch a fresh CSRF token from the server
*/
export const fetchCsrfToken = async (): Promise<void> => {
try {
const response = await axios.get<{ token: string; header: string }>(
`${API_URL}/csrf-token`
);
csrfToken = response.data.token;
csrfHeaderName = response.data.header || "x-csrf-token";
} catch (error) {
console.error("Failed to fetch CSRF token:", error);
throw error;
}
};
/**
* Ensure we have a valid CSRF token, fetching one if needed
*/
const ensureCsrfToken = async (): Promise<void> => {
if (csrfToken) return;
// Prevent multiple simultaneous token fetches
if (!csrfTokenPromise) {
csrfTokenPromise = fetchCsrfToken().finally(() => {
csrfTokenPromise = null;
});
}
await csrfTokenPromise;
};
/**
* Clear the cached CSRF token (useful for handling 403 errors)
*/
export const clearCsrfToken = (): void => {
csrfToken = null;
};
// Add request interceptor to include CSRF token
api.interceptors.request.use(
async (config) => {
// Only add CSRF token for state-changing methods
const method = config.method?.toUpperCase();
if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
await ensureCsrfToken();
if (csrfToken) {
config.headers[csrfHeaderName] = csrfToken;
}
}
return config;
},
(error) => Promise.reject(error)
);
// Add response interceptor to handle CSRF token errors
api.interceptors.response.use(
(response) => response,
async (error) => {
// If we get a 403 with CSRF error, clear token and retry once
if (
error.response?.status === 403 &&
error.response?.data?.error?.includes("CSRF")
) {
clearCsrfToken();
// Retry the request once with a fresh token
const originalRequest = error.config;
if (!originalRequest._csrfRetry) {
originalRequest._csrfRetry = true;
await fetchCsrfToken();
if (csrfToken) {
originalRequest.headers[csrfHeaderName] = csrfToken;
}
return api(originalRequest);
}
}
return Promise.reject(error);
}
);
const coerceTimestamp = (value: string | number | Date): number => { const coerceTimestamp = (value: string | number | Date): number => {
if (typeof value === "number") return value; if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime(); if (value instanceof Date) return value.getTime();
+9 -8
View File
@@ -1,5 +1,5 @@
import { exportToSvg } from "@excalidraw/excalidraw"; import { exportToSvg } from "@excalidraw/excalidraw";
import { API_URL } from "../api"; import { api } from "../api";
export const importDrawings = async ( export const importDrawings = async (
files: File[], files: File[],
@@ -50,21 +50,22 @@ export const importDrawings = async (
preview: svg.outerHTML, preview: svg.outerHTML,
}; };
const res = await fetch(`${API_URL}/drawings`, { await api.post("/drawings", payload, {
method: "POST",
headers: { headers: {
"Content-Type": "application/json", // Backend uses this header to apply stricter validation for imported files.
"X-Imported-File": "true", "X-Imported-File": "true",
}, },
body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error("API Error");
successCount++; successCount++;
} catch (err: any) { } catch (err: any) {
console.error(`Failed to import ${file.name}:`, err); console.error(`Failed to import ${file.name}:`, err);
failCount++; failCount++;
errors.push(`${file.name}: ${err.message}`); const apiMessage =
err?.response?.data?.message ||
err?.response?.data?.error ||
err?.message ||
"API Error";
errors.push(`${file.name}: ${apiMessage}`);
} }
}) })
); );