Files
ExcaliDash/backend/src/__tests__/drawings.integration.ts
T
Zimeng Xiong 49b413bf07 Testing infrastructure, fix truncating of dataURLs (#26)
* feat: implement comprehensive testing infrastructure

- Fix image dataURL truncation bug in security.ts with configurable size limits
- Add backend integration tests (22 tests) with Vitest for API validation
- Add frontend unit tests (11 tests) for JSON serialization
- Implement browser-based E2E tests (8 tests) with Playwright
- Create Docker setup for repeatable E2E testing environment
- Add GitHub Actions CI workflow for automated testing
- Update .gitignore for test artifacts and temporary files

Testing Infrastructure:
- Backend: Vitest + Supertest for API integration tests
- Frontend: Vitest + Testing Library for component tests
- E2E: Playwright with Chromium for full browser automation
- CI/CD: GitHub Actions with parallel test execution

Security Improvements:
- Make dataURL size limit configurable (default: 10MB)
- Enhanced validation for image dataURLs
- Block malicious content (javascript:, script tags)

All tests pass: 41 total (22 backend + 11 frontend + 8 E2E)

* feat(tests): add comprehensive E2E tests for dashboard workflows and image persistence
chore(env): update environment variables for consistent API URL usage
fix(api): centralize API request helpers for drawing and collection management
style(DrawingCard): enhance accessibility with ARIA attributes and data-testid for testing

* cleanup/revise documentation

* cleanup/revise documentation

* Add end-to-end tests for drawing CRUD, export/import, search/sort, and theme toggle functionalities

- Implemented E2E tests for drawing creation, editing, and deletion in `drawing-crud.spec.ts`.
- Added tests for export and import features, including JSON and SQLite formats in `export-import.spec.ts`.
- Created tests for searching and sorting drawings by name and date in `search-and-sort.spec.ts`.
- Developed tests for theme toggle functionality to ensure persistence across sessions in `theme-toggle.spec.ts`.

* fix: exclude test files from production build to fix Docker build

* feat: implement comprehensive testing infrastructure (#19)

* bump version 0.1.7

* feat: implement comprehensive testing infrastructure

- Fix image dataURL truncation bug in security.ts with configurable size limits
- Add backend integration tests (22 tests) with Vitest for API validation
- Add frontend unit tests (11 tests) for JSON serialization
- Implement browser-based E2E tests (8 tests) with Playwright
- Create Docker setup for repeatable E2E testing environment
- Add GitHub Actions CI workflow for automated testing
- Update .gitignore for test artifacts and temporary files

Testing Infrastructure:
- Backend: Vitest + Supertest for API integration tests
- Frontend: Vitest + Testing Library for component tests
- E2E: Playwright with Chromium for full browser automation
- CI/CD: GitHub Actions with parallel test execution

Security Improvements:
- Make dataURL size limit configurable (default: 10MB)
- Enhanced validation for image dataURLs
- Block malicious content (javascript:, script tags)

All tests pass: 41 total (22 backend + 11 frontend + 8 E2E)

* feat(tests): add comprehensive E2E tests for dashboard workflows and image persistence
chore(env): update environment variables for consistent API URL usage
fix(api): centralize API request helpers for drawing and collection management
style(DrawingCard): enhance accessibility with ARIA attributes and data-testid for testing

* Add end-to-end tests for drawing CRUD, export/import, search/sort, and theme toggle functionalities

- Implemented E2E tests for drawing creation, editing, and deletion in `drawing-crud.spec.ts`.
- Added tests for export and import features, including JSON and SQLite formats in `export-import.spec.ts`.
- Created tests for searching and sorting drawings by name and date in `search-and-sort.spec.ts`.
- Developed tests for theme toggle functionality to ensure persistence across sessions in `theme-toggle.spec.ts`.

* Update backend/src/__tests__/testUtils.ts

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>
* version bump 0.1.8

* fix(ci): consolidate E2E server startup to prevent shell isolation issues

Background processes started with & in separate GitHub Actions run steps
can terminate when those steps complete because each step creates a new
shell. This caused the backend and frontend servers to die before the
E2E tests could run.

Fixed by consolidating server startup and test execution into a single
shell step with:
- Proper PID tracking for cleanup
- Health check loops instead of fixed sleep times
- All processes run in the same shell session

* fix(ci): use absolute database path for E2E tests

* fix(backend): use resolved DATABASE_URL path for export/import endpoints

---------

Co-authored-by: Adrian Acala <adrianacala017@gmail.com>
2025-12-19 15:09:15 -08:00

546 lines
17 KiB
TypeScript

/**
* Integration tests for Drawing API - Image Persistence
*
* These tests specifically target the bug from GitHub issue #17:
* "Images don't load fully when reopening the file"
*
* The root cause was that sanitizeDrawingData() was truncating all strings
* in the files object to 10000 characters, which corrupted base64 image data URLs.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
getTestPrisma,
cleanupTestDb,
initTestDb,
setupTestDb,
createTestDrawingPayload,
createSampleFilesObject,
generateLargeImageDataUrl,
compareFilesObjects,
} from "./testUtils";
import {
sanitizeDrawingData,
validateImportedDrawing,
configureSecuritySettings,
resetSecuritySettings,
getSecurityConfig,
} from "../security";
// Test directly against the security functions first (unit-level)
describe("Security Sanitization - Image Data URLs", () => {
// Reset security settings before each test
beforeEach(() => {
resetSecuritySettings();
});
describe("configurable size limits", () => {
it("should use default 10MB limit", () => {
const config = getSecurityConfig();
expect(config.maxDataUrlSize).toBe(10 * 1024 * 1024);
});
it("should allow configuring the size limit", () => {
configureSecuritySettings({ maxDataUrlSize: 5 * 1024 * 1024 });
const config = getSecurityConfig();
expect(config.maxDataUrlSize).toBe(5 * 1024 * 1024);
});
it("should reject dataURL exceeding configured limit", () => {
// Set a small limit for testing
configureSecuritySettings({ maxDataUrlSize: 1000 });
// Create a dataURL larger than 1000 chars
const largeDataUrl = "data:image/png;base64," + "A".repeat(2000);
const files = {
"file-1": {
id: "file-1",
mimeType: "image/png",
dataURL: largeDataUrl,
created: Date.now(),
},
};
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files,
});
const resultFiles = result.files as Record<string, any>;
// Should be cleared because it exceeds the configured limit
expect(resultFiles["file-1"].dataURL).toBe("");
});
it("should allow dataURL under configured limit", () => {
// Set limit to 5000 chars
configureSecuritySettings({ maxDataUrlSize: 5000 });
// Create a dataURL smaller than 5000 chars
const smallDataUrl = "data:image/png;base64," + "A".repeat(100);
const files = {
"file-1": {
id: "file-1",
mimeType: "image/png",
dataURL: smallDataUrl,
created: Date.now(),
},
};
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files,
});
const resultFiles = result.files as Record<string, any>;
expect(resultFiles["file-1"].dataURL).toBe(smallDataUrl);
});
it("should reset to defaults", () => {
configureSecuritySettings({ maxDataUrlSize: 100 });
expect(getSecurityConfig().maxDataUrlSize).toBe(100);
resetSecuritySettings();
expect(getSecurityConfig().maxDataUrlSize).toBe(10 * 1024 * 1024);
});
});
describe("sanitizeDrawingData - files handling", () => {
it("should preserve small image data URLs unchanged", () => {
const files = createSampleFilesObject(1, "small");
const originalDataUrl = Object.values(files)[0].dataURL;
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files,
});
const resultFiles = result.files as Record<string, any>;
const resultDataUrl = Object.values(resultFiles)[0]?.dataURL;
expect(resultDataUrl).toBe(originalDataUrl);
expect(resultDataUrl.length).toBe(originalDataUrl.length);
});
it("should preserve large image data URLs (>10000 chars) - REGRESSION TEST for issue #17", () => {
const files = createSampleFilesObject(1, "large");
const originalDataUrl = Object.values(files)[0].dataURL;
// Verify this is actually a large data URL that would trigger the bug
expect(originalDataUrl.length).toBeGreaterThan(10000);
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files,
});
const resultFiles = result.files as Record<string, any>;
const resultDataUrl = Object.values(resultFiles)[0]?.dataURL;
// THIS IS THE KEY ASSERTION - the old code would truncate to ~10000 chars
expect(resultDataUrl.length).toBe(originalDataUrl.length);
expect(resultDataUrl).toBe(originalDataUrl);
});
it("should handle multiple images with large data URLs", () => {
const files = createSampleFilesObject(3, "large");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files,
});
const comparison = compareFilesObjects(files, result.files as Record<string, any>);
expect(comparison.isEqual).toBe(true);
expect(comparison.differences).toHaveLength(0);
});
it("should sanitize malicious script tags in dataURL", () => {
const maliciousFiles = {
"file-1": {
id: "file-1",
mimeType: "image/png",
dataURL: "data:image/png;base64,<script>alert('xss')</script>AAAA",
created: Date.now(),
},
};
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: maliciousFiles,
});
const resultFiles = result.files as Record<string, any>;
// The dataURL should be cleared or sanitized when it contains script tags
expect(resultFiles["file-1"].dataURL).not.toContain("<script>");
});
it("should sanitize javascript: protocol in dataURL", () => {
const maliciousFiles = {
"file-1": {
id: "file-1",
mimeType: "image/png",
dataURL: "javascript:alert('xss')",
created: Date.now(),
},
};
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: maliciousFiles,
});
const resultFiles = result.files as Record<string, any>;
// javascript: URLs should be blocked
expect(resultFiles["file-1"].dataURL).not.toContain("javascript:");
});
it("should handle null files object", () => {
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: null,
});
expect(result.files).toBeNull();
});
it("should handle empty files object", () => {
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
});
expect(result.files).toEqual({});
});
it("should sanitize non-dataURL string properties in files", () => {
const files = {
"file-1": {
id: "<script>alert('xss')</script>",
mimeType: "image/png<script>",
dataURL: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
created: Date.now(),
},
};
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files,
});
const resultFiles = result.files as Record<string, any>;
// id and mimeType should be sanitized, dataURL should be preserved
expect(resultFiles["file-1"].id).not.toContain("<script>");
expect(resultFiles["file-1"].mimeType).not.toContain("<script>");
// dataURL should remain intact
expect(resultFiles["file-1"].dataURL).toBe(files["file-1"].dataURL);
});
it("should handle case-insensitive image MIME types", () => {
const files = {
"file-1": {
id: "file-1",
mimeType: "IMAGE/PNG",
dataURL: "data:IMAGE/PNG;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
created: Date.now(),
},
};
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files,
});
const resultFiles = result.files as Record<string, any>;
// Should still preserve the data URL even with uppercase
expect(resultFiles["file-1"].dataURL).toBe(files["file-1"].dataURL);
});
});
describe("validateImportedDrawing - with files", () => {
it("should validate drawing with embedded images", () => {
const files = createSampleFilesObject(2, "large");
const drawing = {
elements: [
{
id: "img-1",
type: "image",
fileId: Object.keys(files)[0],
x: 0,
y: 0,
width: 100,
height: 100,
angle: 0,
version: 1,
versionNonce: 1,
},
],
appState: { viewBackgroundColor: "#ffffff" },
files,
};
const isValid = validateImportedDrawing(drawing);
expect(isValid).toBe(true);
});
it("should reject drawing with malicious content in files", () => {
const drawing = {
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {
"file-1": {
id: "file-1",
dataURL: "javascript:alert('xss')",
},
},
};
// The validation should still pass, but sanitization should clean the data
const isValid = validateImportedDrawing(drawing);
expect(isValid).toBe(true);
});
});
});
// Database integration tests
describe("Drawing API - Database Round-Trip", () => {
const prisma = getTestPrisma();
beforeAll(async () => {
setupTestDb();
await initTestDb(prisma);
});
afterAll(async () => {
await prisma.$disconnect();
});
beforeEach(async () => {
await cleanupTestDb(prisma);
});
it("should preserve image data URLs through create and read cycle", async () => {
const files = createSampleFilesObject(1, "large");
const originalDataUrl = Object.values(files)[0].dataURL;
// Verify the data URL is large enough to trigger the bug
expect(originalDataUrl.length).toBeGreaterThan(10000);
// Create drawing with files
const created = await prisma.drawing.create({
data: {
name: "Test with Image",
elements: JSON.stringify([]),
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
files: JSON.stringify(files),
},
});
// Read it back
const retrieved = await prisma.drawing.findUnique({
where: { id: created.id },
});
expect(retrieved).not.toBeNull();
const parsedFiles = JSON.parse(retrieved!.files || "{}");
const retrievedDataUrl = Object.values(parsedFiles as Record<string, any>)[0]?.dataURL;
// THE KEY ASSERTION - data should not be truncated
expect(retrievedDataUrl.length).toBe(originalDataUrl.length);
expect(retrievedDataUrl).toBe(originalDataUrl);
});
it("should handle multiple images with varying sizes", async () => {
const files = {
"small-image": {
id: "small-image",
mimeType: "image/png",
dataURL: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
},
"large-image": {
id: "large-image",
mimeType: "image/png",
dataURL: generateLargeImageDataUrl(),
},
};
const created = await prisma.drawing.create({
data: {
name: "Multi-Image Test",
elements: JSON.stringify([]),
appState: JSON.stringify({}),
files: JSON.stringify(files),
},
});
const retrieved = await prisma.drawing.findUnique({
where: { id: created.id },
});
const parsedFiles = JSON.parse(retrieved!.files || "{}") as Record<string, any>;
// Both images should be fully preserved
expect(parsedFiles["small-image"].dataURL).toBe(files["small-image"].dataURL);
expect(parsedFiles["large-image"].dataURL).toBe(files["large-image"].dataURL);
expect(parsedFiles["large-image"].dataURL.length).toBe(files["large-image"].dataURL.length);
});
it("should preserve files through update cycle", async () => {
// Create with no files
const created = await prisma.drawing.create({
data: {
name: "Update Test",
elements: JSON.stringify([]),
appState: JSON.stringify({}),
files: JSON.stringify({}),
},
});
// Update with large image
const files = createSampleFilesObject(1, "large");
const originalDataUrl = Object.values(files)[0].dataURL;
await prisma.drawing.update({
where: { id: created.id },
data: {
files: JSON.stringify(files),
},
});
// Read back
const retrieved = await prisma.drawing.findUnique({
where: { id: created.id },
});
const parsedFiles = JSON.parse(retrieved!.files || "{}") as Record<string, any>;
const retrievedDataUrl = Object.values(parsedFiles)[0]?.dataURL;
expect(retrievedDataUrl).toBe(originalDataUrl);
});
});
// Test the specific scenario from issue #17
describe("Issue #17 Regression Test - Images Not Loading Fully", () => {
it("should reproduce and verify fix for truncated image data", () => {
// This is the exact scenario that caused issue #17:
// 1. User uploads an image to a drawing
// 2. The image is saved as a base64 data URL in the files object
// 3. On save, sanitizeDrawingData() truncates the dataURL to 10000 chars
// 4. On reload, the image appears broken/half-loaded
// Create a realistic image data URL (around 50KB, typical for a small image)
const largeImageDataUrl = generateLargeImageDataUrl();
// Verify it would have been affected by the bug
expect(largeImageDataUrl.length).toBeGreaterThan(10000);
console.log(`Testing with image data URL of length: ${largeImageDataUrl.length}`);
const filesObject = {
"user-uploaded-image": {
id: "user-uploaded-image",
mimeType: "image/png",
dataURL: largeImageDataUrl,
created: Date.now(),
lastRetrieved: Date.now(),
},
};
// Simulate what happens when saving a drawing
const sanitizedData = sanitizeDrawingData({
elements: [
{
id: "image-element",
type: "image",
fileId: "user-uploaded-image",
x: 0,
y: 0,
width: 400,
height: 300,
angle: 0,
version: 1,
versionNonce: 1,
},
],
appState: { viewBackgroundColor: "#ffffff" },
files: filesObject,
preview: null,
});
// THE BUG: Old code would have truncated this
// THE FIX: New code should preserve the full data URL
const sanitizedFiles = sanitizedData.files as Record<string, any>;
const sanitizedDataUrl = sanitizedFiles["user-uploaded-image"]?.dataURL;
// Assertions that would FAIL with the old buggy code:
expect(sanitizedDataUrl).toBeDefined();
expect(sanitizedDataUrl.length).toBe(largeImageDataUrl.length);
expect(sanitizedDataUrl).toBe(largeImageDataUrl);
// Verify it's still a valid data URL structure
expect(sanitizedDataUrl).toMatch(/^data:image\/png;base64,/);
console.log("✓ Issue #17 regression test passed - image data preserved correctly");
});
it("should handle edge case: exactly 10000 character data URL", () => {
// Create a data URL that's exactly at the truncation boundary
const baseData = "data:image/png;base64,";
const neededChars = 10000 - baseData.length;
const paddedBase64 = "A".repeat(neededChars);
const exactDataUrl = baseData + paddedBase64;
expect(exactDataUrl.length).toBe(10000);
const result = sanitizeDrawingData({
elements: [],
appState: {},
files: {
"boundary-test": {
id: "boundary-test",
dataURL: exactDataUrl,
},
},
});
const resultFiles = result.files as Record<string, any>;
expect(resultFiles["boundary-test"].dataURL.length).toBe(10000);
});
it("should handle edge case: 10001 character data URL (just over limit)", () => {
// This would have been the first case to fail with the old code
const baseData = "data:image/png;base64,";
const neededChars = 10001 - baseData.length;
const paddedBase64 = "A".repeat(neededChars);
const justOverDataUrl = baseData + paddedBase64;
expect(justOverDataUrl.length).toBe(10001);
const result = sanitizeDrawingData({
elements: [],
appState: {},
files: {
"over-limit-test": {
id: "over-limit-test",
dataURL: justOverDataUrl,
},
},
});
const resultFiles = result.files as Record<string, any>;
// WITH THE FIX: should still be 10001 characters
// WITH THE BUG: would have been truncated to 10000
expect(resultFiles["over-limit-test"].dataURL.length).toBe(10001);
});
});