Compare commits

...

6 Commits

Author SHA1 Message Date
Zimeng Xiong 81918b00cd chore: release v0.3.1 2026-01-20 13:41:22 -08:00
Zimeng Xiong 3b384dc5fb CSRF token validation failing behind nginx proxy (#38)
Express was not configured to trust proxy headers, causing req.ip to return nginx's internal container IP instead of the actual client IP. In Docker environments, nginx can appear with different internal IPs between requests, causing the CSRF clientId to change and token validation to fail.
2026-01-20 13:39:33 -08:00
Zimeng Xiong 7c238701b7 Update RELEASE.md with CSRF_SECRET instructions (#33)
Added instructions for the required CSRF_SECRET environment variable for CSRF protection in Kubernetes deployments.
2026-01-14 13:11:25 -08:00
Zimeng Xiong c5c8b15e75 Update README header to remove version number
Removed version number from README header.
2026-01-14 13:10:43 -08:00
Zimeng Xiong 9bc3c7c8fc chore: release v0.3.0 2026-01-14 11:26:20 -08:00
Zimeng Xiong 0476315322 0.2.1 Release (#32)
* feat(security): implement CSRF protection

* chore: clean up CSRF implementation

  - Remove unused generateCsrfToken export from security.ts
  - Remove redundant /csrf-token path check (GET already exempt)
  - Restore defineConfig wrapper in vitest.config.ts for type safety

* add K8S note in README, fix broken e2e

* feat/upload-bar (#30)

* feat/upload-bar: add a upload bar when user upload file, indicate the upload process

* feat/save-loading-status: add save status when click back button from editor

* fix: address PR review issues in upload and save features

- Replace deprecated substr() with substring() in UploadContext
- Fix broken error handling that checked stale task status
- Fix missing useEffect dependency in UploadStatus
- Fix CSS class conflict in progress bar styling
- Add error recovery for save state in Editor (reset on failure)
- Use .finally() instead of .then() to ensure refresh on upload failure
- Fix inconsistent indentation in UploadContext

* fix e2e tests

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>

* chore: pre-release v0.2.1-dev

* Update backend/src/security.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix filename/math random UUID generation

---------

Co-authored-by: AdrianAcala <adrianacala017@gmail.com>
Co-authored-by: adamant368 <60790941+Yiheng-Liu@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-14 11:25:27 -08:00
37 changed files with 2090 additions and 681 deletions
+2
View File
@@ -31,7 +31,9 @@ backend/dist/
# E2E Testing
e2e/node_modules/
e2e/test-results/
e2e/test-results-user/
e2e/playwright-report/
e2e/playwright-report-user/
e2e/.playwright/
# Temporary files
+1 -1
View File
@@ -148,7 +148,7 @@ ExcaliDash/
**Backend (.env):**
```bash
DATABASE_URL="file:./prisma/dev.db"
DATABASE_URL="file:./dev.db"
PORT=8000
NODE_ENV=development
```
+550
View File
File diff suppressed because it is too large Load Diff
+22 -2
View File
@@ -1,6 +1,6 @@
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
# ExcaliDash v0.1.8
# ExcaliDash
![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash)
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)
@@ -22,6 +22,8 @@ A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excali
- [Installation](#installation)
- [Docker Hub (Recommended)](#dockerhub-recommended)
- [Docker Build](#docker-build)
- [Reverse Proxy / Traefik Setups](#reverse-proxy--traefik-setups-docker)
- [Multi-Container / Kubernetes Deployments](#multi-container--kubernetes-deployments)
- [Development](#development)
- [Clone the Repository](#clone-the-repository)
- [Frontend](#frontend)
@@ -75,7 +77,7 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp
# Installation
> [!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]
> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
@@ -134,6 +136,24 @@ frontend:
- BACKEND_URL=excalidash-backend.default.svc.cluster.local:8000
```
### Multi-Container / Kubernetes Deployments
When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set the `CSRF_SECRET` environment variable to the same value across all instances.
```bash
# Generate a secure secret
openssl rand -base64 32
```
```yaml
# docker-compose.yml or k8s deployment
backend:
environment:
- CSRF_SECRET=your-generated-secret-here
```
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
# Development
## Clone the Repository
+31 -18
View File
@@ -1,30 +1,43 @@
# ExcaliDash v0.1.5
CSRF Protection (8a78b2b)
Date: 2025-11-23
- Implemented comprehensive CSRF (Cross-Site Request Forgery) protection for enhanced security
- Added new backend/src/security.ts module for security utilities
- Frontend API layer now handles CSRF tokens automatically
- Added integration tests for CSRF validation
Compatibility: v0.1.x (Backward Compatible)
Upload Progress Indicator (8f9b9b4)
# Security
- Added a visual upload progress bar when users upload files
- New UploadContext for managing upload state across components
- New UploadStatus component displaying real-time upload progress
- Save status indicator when navigating back from the editor
- Improved error handling and recovery for failed uploads
- RCE: implemented strict Zod schema validation and input sanitization on file uploads; added path traversal guards to file handling logic
Bug Fixes
- XSS: used DOMPurify for HTML sanitization; blocked execution-capable SVG attributes and enforces CSP headers.
- Fixed broken e2e tests (cae8f3c)
- Replaced deprecated substr() with substring()
- Fixed stale state issues in error handling
- Fixed missing useEffect dependencies
- Fixed CSS class conflicts in progress bar styling
- Added error recovery for save state in Editor
- DoS: moved CPU-intensive operations to worker threads to prevent event loop blocking; request rate limiting (1,000 req/15 min per IP) and streaming for large files
Infrastructure
# Infras & Deployment
- Updated docker-compose configurations with new environment variables
- E2E test suite improvements and reliability fixes
- Added Kubernetes deployment note in README
- non-root execution (uid 1001) in containers
- migrated to multi-stage Docker builds
### Kubernetes
# Database
A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
- migrated to better-sqlite3, converted all DB interactions to non-blocking async operations and offloaded integrity checks to worker threads.
```bash
openssl rand -base64 32
- implemented SQLite magic header validation; added automatic backup triggers preceding data import
Add it to your deployment:
- Docker Compose: Add CSRF_SECRET=<your-secret> to the backend service environment
- Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment
- input validation logic
# Frontend
- updated Settings UI to show version
If not set, the backend will refuse to start.
```
+1 -1
View File
@@ -1 +1 @@
0.1.8
0.3.1
+197 -365
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "0.1.8",
"version": "0.3.1",
"description": "",
"main": "index.js",
"scripts": {
@@ -16,7 +16,7 @@
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/archiver": "^7.0.0",
"@types/jsdom": "^27.0.0",
"@types/jsdom": "^21.1.7",
"@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1",
"archiver": "^7.0.1",
@@ -25,7 +25,7 @@
"dompurify": "^3.3.0",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"jsdom": "^27.2.0",
"jsdom": "^22.1.0",
"multer": "^2.0.2",
"prisma": "^5.22.0",
"socket.io": "^4.8.1",
+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();
});
});
+164 -15
View File
@@ -18,6 +18,10 @@ import {
sanitizeSvg,
elementSchema,
appStateSchema,
createCsrfToken,
validateCsrfToken,
getCsrfTokenHeader,
getOriginFromReferer,
} from "./security";
dotenv.config();
@@ -34,9 +38,22 @@ const resolveDatabaseUrl = (rawUrl?: string) => {
}
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)
? filePath
: path.resolve(backendRoot, filePath);
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`;
};
@@ -63,11 +80,15 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => {
const ensureProtocol = (origin: string) =>
/^https?:\/\//i.test(origin) ? origin : `http://${origin}`;
const removeTrailingSlash = (origin: string) =>
origin.endsWith("/") ? origin.slice(0, -1) : origin;
const parsed = rawOrigins
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0)
.map(ensureProtocol);
.map(ensureProtocol)
.map(removeTrailingSlash);
return parsed.length > 0 ? parsed : [fallback];
};
@@ -108,6 +129,12 @@ const initializeUploadDir = async () => {
};
const app = express();
// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx
// Required for correct client IP detection when running behind a reverse proxy
// This fixes CSRF token validation failures in Docker/K8s environments
app.set("trust proxy", 1);
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
@@ -211,6 +238,8 @@ app.use(
cors({
origin: allowedOrigins,
credentials: true,
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
exposedHeaders: ["x-csrf-token"],
})
);
app.use(express.json({ limit: "50mb" }));
@@ -244,12 +273,12 @@ app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self' ws: wss:; " +
"frame-ancestors 'none';"
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self' ws: wss:; " +
"frame-ancestors 'none';"
);
next();
@@ -296,6 +325,128 @@ app.use((req, res, 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)
// Note: /csrf-token is a GET endpoint, so it's automatically exempt
const safeMethods = ["GET", "HEAD", "OPTIONS"];
if (safeMethods.includes(req.method)) {
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
.union([z.record(z.string(), z.any()), z.null()])
.optional()
@@ -922,8 +1073,7 @@ app.get("/export", async (req, res) => {
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="excalidash-db-${
new Date().toISOString().split("T")[0]
`attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0]
}.${extension}"`
);
@@ -946,8 +1096,7 @@ app.get("/export/json", async (req, res) => {
res.setHeader("Content-Type", "application/zip");
res.setHeader(
"Content-Disposition",
`attachment; filename="excalidraw-drawings-${
new Date().toISOString().split("T")[0]
`attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0]
}.zip"`
);
@@ -1012,8 +1161,8 @@ Total Drawings: ${drawings.length}
Collections:
${Object.entries(drawingsByCollection)
.map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`)
.join("\n")}
.map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`)
.join("\n")}
`;
archive.append(readmeContent, { name: "README.txt" });
@@ -1085,7 +1234,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
try {
await fsPromises.access(dbPath);
await fsPromises.copyFile(dbPath, backupPath);
} catch {}
} catch { }
await moveFile(stagedPath, dbPath);
} catch (error) {
+180
View File
@@ -1,6 +1,10 @@
/**
* Security utilities for XSS prevention, data sanitization, and CSRF protection
*/
import { z } from "zod";
import DOMPurify from "dompurify";
import { JSDOM } from "jsdom";
import crypto from "crypto";
// Create a DOM environment for DOMPurify (Node.js compatibility)
const window = new JSDOM("").window;
@@ -523,3 +527,179 @@ export const validateImportedDrawing = (data: any): boolean => {
return false;
}
};
// ============================================================================
// CSRF Protection
// ============================================================================
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();
};
/**
* Create 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
View File
@@ -6,6 +6,8 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000
- NODE_ENV=production
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
# - CSRF_SECRET=${CSRF_SECRET}
volumes:
- backend-data:/app/prisma
networks:
+2
View File
@@ -8,6 +8,8 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000
- NODE_ENV=production
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
# - CSRF_SECRET=${CSRF_SECRET}
volumes:
- backend-data:/app/prisma
networks:
+1 -1
View File
@@ -1,5 +1,5 @@
# Playwright E2E Test Runner
FROM mcr.microsoft.com/playwright:v1.52.0-noble
FROM mcr.microsoft.com/playwright:v1.57.0-noble
WORKDIR /app
+13 -8
View File
@@ -17,14 +17,18 @@ services:
context: ../backend
dockerfile: Dockerfile
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
- 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:
- "8000:8000"
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
timeout: 5s
retries: 10
@@ -35,17 +39,18 @@ services:
# Frontend web server
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
args:
- VITE_API_URL=http://backend:8000
# Use the repo root as build context because `frontend/Dockerfile` expects
# `frontend/...` paths (same as production `docker-compose.yml`).
context: ..
dockerfile: frontend/Dockerfile
ports:
- "5173:80"
depends_on:
backend:
condition: service_healthy
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
timeout: 5s
retries: 10
+28 -18
View File
@@ -17,49 +17,56 @@ const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
*/
export default defineConfig({
testDir: "./tests",
// Run tests in parallel
fullyParallel: true,
// Fail the build on test.only() in CI
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Limit parallel workers in CI
workers: process.env.CI ? 1 : undefined,
// Reporter configuration
reporter: [
["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
outputDir: "test-results",
outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR || "test-results",
// Global timeout for each test
timeout: 60000,
// Expect timeout
expect: {
timeout: 10000,
},
use: {
// Base URL for page.goto()
baseURL: FRONTEND_URL,
// Collect trace on first retry
trace: "on-first-retry",
// Screenshot on failure
screenshot: "only-on-failure",
// Video on failure
video: "on-first-retry",
// Headed mode based on env var
headless: process.env.HEADED !== "true",
},
@@ -67,7 +74,7 @@ export default defineConfig({
projects: [
{
name: "chromium",
use: {
use: {
...devices["Desktop Chrome"],
// Viewport for consistent screenshots
viewport: { width: 1280, height: 720 },
@@ -76,7 +83,7 @@ export default defineConfig({
],
// Run local dev servers before tests (skip if NO_SERVER or CI)
webServer: (process.env.CI || process.env.NO_SERVER) ? undefined : [
webServer: (process.env.CI || process.env.NO_SERVER === "true") ? undefined : [
{
command: "cd ../backend && npm run dev",
url: `${BACKEND_URL}/health`,
@@ -85,8 +92,11 @@ export default defineConfig({
stdout: "pipe",
stderr: "pipe",
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,
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 {
API_URL,
createDrawing,
deleteDrawing,
getDrawing,
@@ -22,7 +21,7 @@ test.describe("Real-time Collaboration", () => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -63,7 +62,7 @@ test.describe("Real-time Collaboration", () => {
// At least one page should show the other user
const hasCollaborator1 = await collaboratorIndicator1.count();
const hasCollaborator2 = await collaboratorIndicator2.count();
// Socket.io presence should eventually show users
// This test validates the socket connection works
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 }) => {
// Create a test drawing
const drawing = await createDrawing(request, {
const drawing = await createDrawing(request, {
name: `Collab_Sync_${Date.now()}`,
elements: [],
});
@@ -121,10 +120,10 @@ test.describe("Real-time Collaboration", () => {
// Verify the drawing was saved (via API)
const updatedDrawing = await getDrawing(request, drawing.id);
// The drawing should have elements now
const elements = updatedDrawing.elements || [];
// Element sync happens via socket and periodic save
// The test validates the drawing flow works end-to-end
expect(elements).toBeDefined();
@@ -136,7 +135,7 @@ test.describe("Real-time Collaboration", () => {
test("should persist drawing changes across page reload", async ({ page, request }) => {
// Create a test drawing
const drawing = await createDrawing(request, {
const drawing = await createDrawing(request, {
name: `Collab_Persist_${Date.now()}`,
elements: [],
});
@@ -149,7 +148,7 @@ test.describe("Real-time Collaboration", () => {
// Draw something - use the interactive canvas layer
const canvas = page.locator("canvas.excalidraw__canvas.interactive");
// Select rectangle tool
await page.keyboard.press("r");
await page.waitForTimeout(200);
@@ -157,7 +156,7 @@ test.describe("Real-time Collaboration", () => {
// Draw a rectangle - click on the interactive canvas
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found");
await page.mouse.move(box.x + 150, box.y + 150);
await page.mouse.down();
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 box = await canvas1.boundingBox();
if (!box) throw new Error("Canvas not found");
await page1.mouse.move(box.x + 300, box.y + 300);
await page1.waitForTimeout(500);
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
// Excalidraw shows collaborator cursors with names
// This test validates the socket connection for cursor sync
// Wait for potential cursor updates
await page2.waitForTimeout(1000);
+2 -2
View File
@@ -45,7 +45,7 @@ test.describe("Dashboard Workflows", () => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (error) {
} catch {
// Ignore cleanup failures to keep tests resilient
}
}
@@ -54,7 +54,7 @@ test.describe("Dashboard Workflows", () => {
for (const id of createdCollectionIds) {
try {
await deleteCollection(request, id);
} catch (error) {
} catch {
// Ignore cleanup failures to keep tests resilient
}
}
+17 -21
View File
@@ -2,7 +2,6 @@ import { test, expect } from "@playwright/test";
import * as path from "path";
import * as fs from "fs";
import {
API_URL,
createDrawing,
deleteDrawing,
listDrawings,
@@ -27,7 +26,7 @@ test.describe("Drag and Drop - Collections", () => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -36,7 +35,7 @@ test.describe("Drag and Drop - Collections", () => {
for (const id of createdCollectionIds) {
try {
await deleteCollection(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -61,7 +60,7 @@ test.describe("Drag and Drop - Collections", () => {
// Hover to reveal the collection picker
await card.hover();
// Click the collection picker button on the card
const collectionPicker = card.locator(`[data-testid="collection-picker-${drawing.id}"]`);
await collectionPicker.click();
@@ -76,7 +75,7 @@ test.describe("Drag and Drop - Collections", () => {
// Navigate to the collection and verify drawing is there
await page.getByRole("navigation").getByRole("button", { name: collection.name }).click();
await page.waitForLoadState("networkidle");
await expect(card).toBeVisible();
});
@@ -85,9 +84,9 @@ test.describe("Drag and Drop - Collections", () => {
const collection = await createCollection(request, `UnorgTest_Collection_${Date.now()}`);
createdCollectionIds.push(collection.id);
const drawing = await createDrawing(request, {
const drawing = await createDrawing(request, {
name: `UnorgTest_Drawing_${Date.now()}`,
collectionId: collection.id
collectionId: collection.id
});
createdDrawingIds.push(drawing.id);
@@ -119,7 +118,7 @@ test.describe("Drag and Drop - Collections", () => {
// Navigate to Unorganized and verify drawing is there
await page.getByRole("navigation").getByRole("button", { name: "Unorganized" }).click();
await page.waitForLoadState("networkidle");
await expect(page.locator(`#drawing-card-${drawing.id}`)).toBeVisible();
});
@@ -146,7 +145,7 @@ test.describe("Drag and Drop - Collections", () => {
// Select both drawings
const card1 = page.locator(`#drawing-card-${drawing1.id}`);
const card2 = page.locator(`#drawing-card-${drawing2.id}`);
await card1.hover();
const toggle1 = card1.locator(`[data-testid="select-drawing-${drawing1.id}"]`);
await toggle1.click();
@@ -186,7 +185,7 @@ test.describe("Drag and Drop - File Import", () => {
for (const drawing of drawings) {
try {
await deleteDrawing(request, drawing.id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -194,7 +193,7 @@ test.describe("Drag and Drop - File Import", () => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -205,7 +204,7 @@ test.describe("Drag and Drop - File Import", () => {
// Note: Simulating drag events with files is unreliable in Playwright
// because the DataTransfer API has security restrictions.
// This test verifies the drop zone UI exists and can be triggered.
await page.goto("/");
await page.waitForLoadState("networkidle");
@@ -218,13 +217,13 @@ test.describe("Drag and Drop - File Import", () => {
try {
const dt = new DataTransfer();
dt.items.add(new File(['test'], 'test.excalidraw', { type: 'application/json' }));
const event = new DragEvent('dragenter', {
bubbles: true,
cancelable: true,
dataTransfer: dt,
});
// Find the main content area and dispatch the event
const main = document.querySelector('main');
if (main) {
@@ -242,7 +241,7 @@ test.describe("Drag and Drop - File Import", () => {
// Check that the drop zone overlay is shown
const dropZone = page.getByText("Drop files to import");
const isVisible = await dropZone.isVisible().catch(() => false);
if (isVisible) {
await expect(dropZone).toBeVisible();
} 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.waitForLoadState("networkidle");
@@ -273,11 +272,8 @@ test.describe("Drag and Drop - File Import", () => {
const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles(fixturePath);
// Wait for import success modal
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 });
// Dismiss the modal
await page.getByRole("button", { name: "OK" }).click();
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Search for the imported drawing (it uses the filename as name)
await page.getByPlaceholder("Search drawings...").fill("small-image");
+29 -28
View File
@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
import {
API_URL,
createDrawing,
deleteDrawing,
getDrawing,
@@ -24,7 +25,7 @@ test.describe("Drawing Creation", () => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -96,7 +97,7 @@ test.describe("Drawing Creation", () => {
test("should rename drawing via editor header", async ({ page, request }) => {
const originalName = `Rename_Original_${Date.now()}`;
const newName = `Rename_Updated_${Date.now()}`;
const drawing = await createDrawing(request, { name: originalName });
createdDrawingIds.push(drawing.id);
@@ -150,7 +151,7 @@ test.describe("Drawing Editing", () => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -158,7 +159,7 @@ test.describe("Drawing Editing", () => {
});
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()}`,
elements: [],
});
@@ -172,19 +173,19 @@ test.describe("Drawing Editing", () => {
const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox();
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}`);
// Click on the rectangle tool using the label element
// Find the label that contains the rectangle radio button
const rectangleLabel = page.locator('label:has([data-testid="toolbar-rectangle"])');
await rectangleLabel.click();
await page.waitForTimeout(500);
// Verify the tool was selected
const isRectangleSelectedBefore = await page.locator('[data-testid="toolbar-rectangle"]').isChecked();
console.log("Rectangle tool selected before drawing:", isRectangleSelectedBefore);
// Draw the rectangle by dragging on the canvas - use center of canvas
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
@@ -192,13 +193,13 @@ test.describe("Drawing Editing", () => {
const startY = centerY - 75;
const endX = centerX + 100;
const endY = centerY + 75;
console.log(`Drawing from (${startX}, ${startY}) to (${endX}, ${endY})`);
// First click on the canvas to ensure it has focus
await page.mouse.click(centerX, centerY);
await page.waitForTimeout(200);
// Now draw the rectangle
await page.mouse.move(startX, startY);
await page.waitForTimeout(100);
@@ -207,10 +208,10 @@ test.describe("Drawing Editing", () => {
await page.mouse.move(endX, endY, { steps: 20 });
await page.waitForTimeout(100);
await page.mouse.up();
// Take a screenshot after drawing
await page.screenshot({ path: 'test-results/after-drawing.png' });
// Check if Undo button is now enabled (indicating something was drawn)
const undoButton = page.locator('button[aria-label="Undo"]');
const isUndoDisabled = await undoButton.getAttribute('disabled');
@@ -231,7 +232,7 @@ test.describe("Drawing Editing", () => {
});
test("should draw text on canvas", async ({ page, request }) => {
const drawing = await createDrawing(request, {
const drawing = await createDrawing(request, {
name: `Draw_Text_${Date.now()}`,
elements: [],
});
@@ -245,11 +246,11 @@ test.describe("Drawing Editing", () => {
const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found");
// Click to focus the canvas
await page.mouse.click(box.x + 100, box.y + 100);
await page.waitForTimeout(100);
// Select text tool using keyboard shortcut (now that canvas is focused)
await page.keyboard.press("t");
await page.waitForTimeout(200);
@@ -260,7 +261,7 @@ test.describe("Drawing Editing", () => {
// Type some text
await page.keyboard.type("Hello E2E Test");
// Press Escape to finish text editing
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
@@ -276,7 +277,7 @@ test.describe("Drawing Editing", () => {
});
test("should use undo/redo functionality", async ({ page, request }) => {
const drawing = await createDrawing(request, {
const drawing = await createDrawing(request, {
name: `Undo_Redo_${Date.now()}`,
elements: [],
});
@@ -290,10 +291,10 @@ test.describe("Drawing Editing", () => {
const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found");
await page.keyboard.press("r");
await page.waitForTimeout(200);
await page.mouse.move(box.x + 200, box.y + 200);
await page.mouse.down();
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) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -341,7 +342,7 @@ test.describe("Drawing Deletion", () => {
// Find the card and select it
const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click();
@@ -360,9 +361,9 @@ test.describe("Drawing Deletion", () => {
});
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()}`,
collectionId: "trash"
collectionId: "trash"
});
createdDrawingIds.push(drawing.id);
@@ -374,7 +375,7 @@ test.describe("Drawing Deletion", () => {
// Select the drawing
const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click();
@@ -388,7 +389,7 @@ test.describe("Drawing Deletion", () => {
await expect(card).not.toBeVisible();
// 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);
// Remove from cleanup list since it's already deleted
@@ -409,7 +410,7 @@ test.describe("Drawing Deletion", () => {
// Select the drawing
const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click();
@@ -422,7 +423,7 @@ test.describe("Drawing Deletion", () => {
// Clear search to see all drawings
await page.getByPlaceholder("Search drawings...").fill("");
await page.waitForTimeout(500);
// Search again to find both
await page.getByPlaceholder("Search drawings...").fill("Duplicate_Test");
await page.waitForTimeout(500);
+35 -46
View File
@@ -1,12 +1,10 @@
import { test, expect } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
import {
API_URL,
createDrawing,
deleteDrawing,
getCsrfHeaders,
listDrawings,
createCollection,
deleteCollection,
} from "./helpers/api";
@@ -29,7 +27,7 @@ test.describe("Export Functionality", () => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -38,7 +36,7 @@ test.describe("Export Functionality", () => {
for (const id of createdCollectionIds) {
try {
await deleteCollection(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -85,11 +83,11 @@ test.describe("Export Functionality", () => {
// Test JSON/ZIP export endpoint - it returns a ZIP file with .excalidraw files
const zipResponse = await request.get(`${API_URL}/export/json`);
expect(zipResponse.ok()).toBe(true);
// Check it's a ZIP file
const contentType = zipResponse.headers()["content-type"];
expect(contentType).toMatch(/application\/zip/);
// Check content-disposition header
const contentDisposition = zipResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment");
@@ -103,11 +101,11 @@ test.describe("Export Functionality", () => {
// Test SQLite export endpoint
const sqliteResponse = await request.get(`${API_URL}/export`);
expect(sqliteResponse.ok()).toBe(true);
// Check content-type header indicates a file download
const contentType = sqliteResponse.headers()["content-type"];
expect(contentType).toMatch(/application\/octet-stream|application\/x-sqlite3/);
// Check content-disposition header
const contentDisposition = sqliteResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment");
@@ -121,7 +119,7 @@ test.describe("Export Functionality", () => {
// Test .db export endpoint
const dbResponse = await request.get(`${API_URL}/export?format=db`);
expect(dbResponse.ok()).toBe(true);
const contentDisposition = dbResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment");
expect(contentDisposition).toMatch(/\.db/);
@@ -137,7 +135,7 @@ test.describe.serial("Import Functionality", () => {
for (const drawing of testDrawings) {
try {
await deleteDrawing(request, drawing.id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -145,7 +143,7 @@ test.describe.serial("Import Functionality", () => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -161,7 +159,7 @@ test.describe.serial("Import Functionality", () => {
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.waitForLoadState("networkidle");
@@ -206,15 +204,14 @@ test.describe.serial("Import Functionality", () => {
});
// Write temp file
const tempDir = "/tmp";
const tempFile = `${tempDir}/Import_Test_${Date.now()}.excalidraw`;
// tempFile was here
// Use page.evaluate to check if we can proceed
// Actually, Playwright has setInputFiles which can handle this
// Find the import file input
const fileInput = page.locator("#dashboard-import");
// Create a buffer from the fixture content
await fileInput.setInputFiles({
name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`,
@@ -222,9 +219,8 @@ test.describe.serial("Import Functionality", () => {
buffer: Buffer.from(fixtureContent),
});
// Wait for success modal
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "OK" }).click();
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Reload to ensure dashboard state reflects the newly imported drawing
await page.reload({ waitUntil: "networkidle" });
@@ -237,13 +233,13 @@ test.describe.serial("Import Functionality", () => {
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.waitForLoadState("networkidle");
const timestamp = Date.now();
const testName = `Import_JSONTest_${timestamp}`;
// Create a valid excalidraw JSON file with required fields
const jsonContent = JSON.stringify({
type: "excalidraw",
@@ -283,31 +279,22 @@ test.describe.serial("Import Functionality", () => {
});
const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles({
name: `${testName}.json`,
mimeType: "application/json",
buffer: Buffer.from(jsonContent),
});
// Wait for import result - could be success or failure
const successModal = page.getByText("Import Successful");
const failModal = page.getByText("Import Failed");
await expect(successModal.or(failModal)).toBeVisible({ timeout: 15000 });
// If we got a failure, check the error
if (await failModal.isVisible()) {
// Get the error message
const errorText = await page.locator(".modal, [role='dialog']").textContent();
console.log("Import failed with:", errorText);
// Still click OK to dismiss
await page.getByRole("button", { name: "OK" }).click();
// Skip the rest of the test since import failed
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 15000 });
// Check if upload failed (shows "Failed" text in the upload status)
const failedIndicator = page.getByText("Failed");
if (await failedIndicator.isVisible()) {
console.log("Import failed - skipping rest of test");
return;
}
await page.getByRole("button", { name: "OK" }).click();
// Reload to force a fresh fetch of drawings after import
await page.reload({ waitUntil: "networkidle" });
@@ -331,16 +318,17 @@ test.describe.serial("Import Functionality", () => {
const invalidContent = "this is not valid JSON or excalidraw format {}{}";
const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles({
name: `Import_Invalid_${Date.now()}.excalidraw`,
mimeType: "application/json",
buffer: Buffer.from(invalidContent),
});
// Should show error modal
await expect(page.getByText("Import Failed")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "OK" }).click();
// Wait for upload to complete and check for failure indicator
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Should show "Failed" status in the upload status component
await expect(page.getByText("Failed")).toBeVisible();
});
test("should import multiple drawings at once", async ({ page }) => {
@@ -377,8 +365,8 @@ test.describe.serial("Import Functionality", () => {
const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles(files);
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "OK" }).click();
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Verify both were imported by searching for the unique prefix
await page.getByPlaceholder("Search drawings...").fill(searchPrefix);
@@ -394,6 +382,7 @@ test.describe("Database Import Verification", () => {
// Test that the verification endpoint responds
// We don't actually import a database as that would affect the test environment
const response = await request.post(`${API_URL}/import/sqlite/verify`, {
headers: await getCsrfHeaders(request),
// Send empty form data to test endpoint exists
multipart: {
db: {
@@ -403,7 +392,7 @@ test.describe("Database Import Verification", () => {
},
},
});
// Should get an error response since the file is empty/invalid
// But the endpoint should exist
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}`;
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 {
id: string;
name: string;
@@ -53,10 +138,26 @@ export async function createDrawing(
overrides: CreateDrawingOptions = {}
): Promise<DrawingRecord> {
const payload = { ...defaultDrawingPayload(), ...overrides };
const response = await request.post(`${API_URL}/drawings`, {
headers: { "Content-Type": "application/json" },
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
let response = await request.post(`${API_URL}/drawings`, {
headers,
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()) {
const text = await response.text();
throw new Error(`Failed to create drawing: ${response.status()} ${text}`);
@@ -77,7 +178,17 @@ export async function deleteDrawing(
request: APIRequestContext,
id: string
): 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()) {
// Ignore not found to keep cleanup idempotent
if (response.status() !== 404) {
@@ -113,10 +224,24 @@ export async function createCollection(
request: APIRequestContext,
name: string
): Promise<CollectionRecord> {
const response = await request.post(`${API_URL}/collections`, {
headers: { "Content-Type": "application/json" },
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
let response = await request.post(`${API_URL}/collections`, {
headers,
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);
return (await response.json()) as CollectionRecord;
}
@@ -133,7 +258,17 @@ export async function deleteCollection(
request: APIRequestContext,
id: string
): 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.status() !== 404) {
const text = await response.text();
+53 -41
View File
@@ -1,7 +1,13 @@
import { test, expect } from "@playwright/test";
import * as fs from "fs";
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
@@ -28,13 +34,13 @@ function generateLargeImageDataUrl(sizeInBytes: number = 50000): string {
test.describe("Image Persistence - Browser E2E Tests", () => {
let testDrawingIds: string[] = [];
test.afterEach(async ({ request }) => {
// Clean up any drawings created during tests
for (const id of testDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// 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 }) => {
await page.goto("/");
// Wait for the page to load
await expect(page).toHaveTitle(/ExcaliDash/i);
// The dashboard should show some UI elements
await expect(page.locator("body")).toBeVisible();
});
test("should create a new drawing via UI", async ({ page }) => {
await page.goto("/");
// Look for a "New Drawing" or similar button
const newDrawingBtn = page.getByRole("button", { name: /new|create/i }).first();
if (await newDrawingBtn.isVisible()) {
await newDrawingBtn.click();
// Should navigate to editor or show a modal
await page.waitForURL(/\/(editor|drawing)/i, { timeout: 5000 }).catch(() => {
// 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
const largeDataUrl = generateLargeImageDataUrl(50000);
expect(largeDataUrl.length).toBeGreaterThan(10000);
const files = {
"test-image-1": {
id: "test-image-1",
@@ -80,23 +86,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
created: Date.now(),
},
};
// Create drawing with large image
const createdDrawing = await createDrawing(request, {
name: "E2E Test - Large Image",
files,
});
testDrawingIds.push(createdDrawing.id);
// Retrieve the drawing
const drawing = await getDrawing(request, createdDrawing.id);
const savedFiles = drawing.files || {}; // Already parsed by API
// Verify the image data was preserved
expect(savedFiles["test-image-1"]).toBeDefined();
expect(savedFiles["test-image-1"].dataURL).toBe(largeDataUrl);
expect(savedFiles["test-image-1"].dataURL.length).toBe(largeDataUrl.length);
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",
});
testDrawingIds.push(createdDrawing.id);
// Navigate to the editor
await page.goto(`/editor/${createdDrawing.id}`);
// Wait for the page to load
await page.waitForLoadState("networkidle");
// The editor should be visible (Excalidraw canvas)
// Look for the Excalidraw container or canvas
const editorContainer = page.locator("[class*='excalidraw'], canvas").first();
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
const fixturePath = path.join(__dirname, "..", "fixtures", "small-image.excalidraw");
const fixtureContent = fs.readFileSync(fixturePath, "utf-8");
const fixtureData = JSON.parse(fixtureContent);
// Create drawing via API with fixture data
const createdDrawing = await createDrawing(request, {
name: "E2E Test - Imported Image",
files: fixtureData.files,
});
testDrawingIds.push(createdDrawing.id);
// Create drawing via API with fixture data
const createdDrawing = await createDrawing(request, {
name: "E2E Test - Imported Image",
files: fixtureData.files,
});
testDrawingIds.push(createdDrawing.id);
// 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
expect(savedFiles["embedded-test-image"]).toBeDefined();
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(),
},
};
const createdDrawing = await createDrawing(request, {
name: "E2E Test - Multiple Images",
files,
});
testDrawingIds.push(createdDrawing.id);
const drawing = await getDrawing(request, createdDrawing.id);
const savedFiles = drawing.files || {}; // Already parsed by API
// Verify all images preserved correctly
for (const [id, originalFile] of Object.entries(files)) {
expect(savedFiles[id]).toBeDefined();
expect(savedFiles[id].dataURL).toBe((originalFile as any).dataURL);
expect(savedFiles[id].dataURL.length).toBe((originalFile as any).dataURL.length);
}
console.log("✓ Multiple images of varying sizes preserved correctly");
});
});
@@ -192,10 +198,11 @@ test.describe("Security - Malicious Content Blocking", () => {
created: Date.now(),
},
};
const response = await request.post(`${API_URL}/drawings`, {
headers: {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
},
data: {
name: "Security Test - JS URL",
@@ -205,7 +212,7 @@ test.describe("Security - Malicious Content Blocking", () => {
preview: null,
},
});
if (!response.ok()) {
const text = await response.text();
console.error(`API Error: ${response.status()} - ${text}`);
@@ -213,12 +220,14 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(response.ok()).toBe(true);
const drawing = await response.json();
const savedFiles = drawing.files; // Already parsed by API
// The malicious URL should be blocked/cleared
expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:");
// 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 }) => {
@@ -230,10 +239,11 @@ test.describe("Security - Malicious Content Blocking", () => {
created: Date.now(),
},
};
const response = await request.post(`${API_URL}/drawings`, {
headers: {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
},
data: {
name: "Security Test - Script Tag",
@@ -243,7 +253,7 @@ test.describe("Security - Malicious Content Blocking", () => {
preview: null,
},
});
if (!response.ok()) {
const text = await response.text();
console.error(`API Error: ${response.status()} - ${text}`);
@@ -251,11 +261,13 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(response.ok()).toBe(true);
const drawing = await response.json();
const savedFiles = drawing.files; // Already parsed by API
// The script tag should be blocked
expect(savedFiles["malicious-image"].dataURL).not.toContain("<script>");
// 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 {
createDrawing,
deleteDrawing,
listDrawings,
} from "./helpers/api";
/**
@@ -21,7 +20,7 @@ test.describe("Search Drawings", () => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -47,10 +46,10 @@ test.describe("Search Drawings", () => {
// Search for the prefix - should show only matching drawings
await searchInput.fill(prefix);
// Wait for search to apply (debounced)
await page.waitForTimeout(500);
// Verify only matching drawings are shown
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible();
@@ -59,7 +58,7 @@ test.describe("Search Drawings", () => {
// Search for specific drawing
await searchInput.fill(`${prefix}_Alpha`);
await page.waitForTimeout(500);
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
await expect(page.locator(`#drawing-card-${drawing2.id}`)).not.toBeVisible();
});
@@ -92,7 +91,7 @@ test.describe("Search Drawings", () => {
await page.waitForLoadState("networkidle");
const searchInput = page.getByPlaceholder("Search drawings...");
// Search for one drawing
await searchInput.fill(`${prefix}_One`);
await page.waitForTimeout(500);
@@ -105,7 +104,7 @@ test.describe("Search Drawings", () => {
// Search for prefix to find both
await searchInput.fill(prefix);
await page.waitForTimeout(500);
// Both should be visible now
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible();
@@ -119,10 +118,10 @@ test.describe("Search Drawings", () => {
await page.waitForLoadState("networkidle");
const searchInput = page.getByPlaceholder("Search drawings...");
// Use keyboard shortcut (Cmd+K on Mac, Ctrl+K on Windows/Linux)
await page.keyboard.press("Meta+k");
// Search input should be focused
await expect(searchInput).toBeFocused();
});
@@ -135,7 +134,7 @@ test.describe("Sort Drawings", () => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch (e) {
} catch {
// Ignore cleanup errors
}
}
@@ -144,7 +143,7 @@ test.describe("Sort Drawings", () => {
test("should sort drawings by name", async ({ page, request }) => {
const prefix = `SortTest_${Date.now()}`;
// Create drawings with names that sort in a specific order
const [drawingC, drawingA, drawingB] = await Promise.all([
createDrawing(request, { name: `${prefix}_Charlie` }),
@@ -176,7 +175,7 @@ test.describe("Sort Drawings", () => {
test("should toggle sort direction on repeated clicks", async ({ page, request }) => {
const prefix = `ToggleSortTest_${Date.now()}`;
const [drawingA, drawingZ] = await Promise.all([
createDrawing(request, { name: `${prefix}_AAA` }),
createDrawing(request, { name: `${prefix}_ZZZ` }),
@@ -191,11 +190,11 @@ test.describe("Sort Drawings", () => {
await page.waitForTimeout(500);
const nameSortButton = page.getByRole("button", { name: "Name" });
// First click - ascending (A first)
await nameSortButton.click();
await page.waitForTimeout(200);
let cards = page.locator("[id^='drawing-card-']");
let firstCard = cards.first();
await expect(firstCard).toHaveId(`drawing-card-${drawingA.id}`);
@@ -203,7 +202,7 @@ test.describe("Sort Drawings", () => {
// Second click - descending (Z first)
await nameSortButton.click();
await page.waitForTimeout(200);
cards = page.locator("[id^='drawing-card-']");
firstCard = cards.first();
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 }) => {
const prefix = `DateSortTest_${Date.now()}`;
// Create drawings sequentially to ensure different creation times
const drawing1 = await createDrawing(request, { name: `${prefix}_First` });
createdDrawingIds.push(drawing1.id);
await page.waitForTimeout(100); // Ensure different timestamps
const drawing2 = await createDrawing(request, { name: `${prefix}_Second` });
createdDrawingIds.push(drawing2.id);
@@ -241,7 +240,7 @@ test.describe("Sort Drawings", () => {
test("should sort by date modified", async ({ page, request }) => {
const prefix = `ModifiedSortTest_${Date.now()}`;
const [drawing1, drawing2] = await Promise.all([
createDrawing(request, { name: `${prefix}_One` }),
createDrawing(request, { name: `${prefix}_Two` }),
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "0.1.7",
"version": "0.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.1.7",
"version": "0.1.8",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",

Some files were not shown because too many files have changed in this diff Show More