test: expand test coverage across unit, integration, e2e, and e2e-docker suites

Add ~210 new tests filling gaps identified by a comprehensive 14-agent
coverage audit. Unit+integration tests go from 9,388 to 9,484 (all passing).

Unit tests (+36):
- AI bridge: OOM fallback path, custom tier option
- Web lib: api-errors, format date/datetime, tool-i18n coverage

Integration tests (+19):
- Format matrix: ai-canvas-expand and find-duplicates added to cross-format matrix
- Adversarial: SVG XXE attacks, SQL injection in settings, request body size
  limits, race conditions with identical filenames

E2E Docker (+3):
- ai-canvas-expand tool coverage with HEIC input and edge cases

E2E GUI (~150+):
- Navigation: login rate limiting, ai-canvas-expand in parameterized list
- Responsive: dropzone visibility, text readability, dialog bounds at all viewports
- Keyboard: shortcuts verified from automate, files, tool, and fullscreen pages
- Tool UI: undo/state-reset for 16 tools, crop canvas drag handles, rotate/border
  live preview, linked aspect-ratio inputs for resize
- Batch: per-image undo isolation, batch compress/convert/rotate (not just resize)
- Pipeline: tool palette search, step collapse/expand visibility
- Settings: audit log entry verification, system settings persistence, teams CRUD,
  role permission toggling
- RBAC: user/editor 403 on roles/teams endpoints, privilege escalation prevention,
  cross-role tab parity documented as intentional
- Accessibility: skip-to-content link (WCAG 2.4.1), comprehensive color contrast
  for all headings/body/buttons in both themes with DOM-walking background detection
- Resilience: auth expiry 401 redirect, rate limit 429 handling
- Performance: JS heap memory stability for tool navigation, dialog cycling,
  upload/clear cycles, rapid page navigation
This commit is contained in:
SnapOtter
2026-05-15 21:35:02 +08:00
parent d38621d7b9
commit 3b181dd1ac
24 changed files with 3398 additions and 0 deletions
@@ -16,6 +16,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
@@ -24,6 +25,8 @@ import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from
// ---------------------------------------------------------------------------
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png"));
const SVG_XXE_FILE = readFileSync(join(FIXTURES, "security", "svg-xxe-file-read.svg"));
const SVG_XXE_SSRF = readFileSync(join(FIXTURES, "security", "svg-xxe-ssrf.svg"));
// ---------------------------------------------------------------------------
// Shared state
@@ -703,6 +706,368 @@ describe("Concurrent requests -- data integrity verification", () => {
}, 120_000);
});
// ===========================================================================
// SVG XXE ATTACKS THROUGH API ENDPOINT
// Existing unit tests verify sanitizeSvg() strips DOCTYPE, but these
// integration tests verify the full API endpoint rejects XXE payloads
// end-to-end through svg-to-raster.
// ===========================================================================
describe("SVG XXE attacks through svg-to-raster endpoint", () => {
it("strips DOCTYPE with file-read XXE entity from svg-to-raster", async () => {
const res = await postTool("svg-to-raster", [
{
name: "file",
filename: "xxe-file-read.svg",
content: SVG_XXE_FILE,
contentType: "image/svg+xml",
},
{
name: "settings",
content: JSON.stringify({ outputFormat: "png" }),
},
]);
// The server must NOT return /etc/passwd contents.
// Should either succeed (with DOCTYPE stripped, entity ignored) or reject.
expect([200, 400, 422]).toContain(res.statusCode);
// If it succeeded, the output should be an image, not text
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.downloadUrl).toBeDefined();
// Download the output and verify it is a valid PNG, not leaked file contents
const dlRes = await app.inject({
method: "GET",
url: json.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
expect(dlRes.statusCode).toBe(200);
// The response body must not contain passwd file content
expect(dlRes.body).not.toContain("root:");
expect(dlRes.body).not.toContain("/bin/bash");
}
});
it("strips DOCTYPE with SSRF XXE entity from svg-to-raster", async () => {
const res = await postTool("svg-to-raster", [
{
name: "file",
filename: "xxe-ssrf.svg",
content: SVG_XXE_SSRF,
contentType: "image/svg+xml",
},
{
name: "settings",
content: JSON.stringify({ outputFormat: "png" }),
},
]);
// Must not make an outbound request to the metadata service
expect([200, 400, 422]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.downloadUrl).toBeDefined();
}
});
it("strips DOCTYPE with parameter entity expansion from inline SVG", async () => {
// Parameter entity expansion can cause DoS (billion laughs variant)
const paramEntitySvg = Buffer.from(
'<?xml version="1.0"?>' +
'<!DOCTYPE svg [<!ENTITY a "AAAAAAAAAA"><!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">]>' +
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">' +
"<text>&b;</text></svg>",
);
const res = await postTool("svg-to-raster", [
{
name: "file",
filename: "param-entity.svg",
content: paramEntitySvg,
contentType: "image/svg+xml",
},
{
name: "settings",
content: JSON.stringify({ outputFormat: "png" }),
},
]);
// Must not expand entities or crash
expect([200, 400, 422]).toContain(res.statusCode);
});
});
// ===========================================================================
// SQL INJECTION IN SETTINGS VALUES
// Existing tests cover SQL injection in text-overlay text and in the URL
// path. These tests verify SQL injection via other settings fields that
// might reach the database (e.g., through analytics or job tracking).
// ===========================================================================
describe("SQL injection in settings values -- additional vectors", () => {
it("handles SQL injection in border color field without DB corruption", async () => {
const res = await postTool("border", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({
borderWidth: 10,
borderColor: "'; DROP TABLE jobs; --",
}),
},
]);
// Zod hex color regex should reject this
expect(res.statusCode).toBe(400);
// Verify the database is intact
const healthRes = await app.inject({
method: "GET",
url: "/api/v1/health",
});
expect(healthRes.statusCode).toBe(200);
});
it("handles SQL injection in convert format field without DB corruption", async () => {
const res = await postTool("convert", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({
format: "png'; DELETE FROM users WHERE '1'='1",
}),
},
]);
// Zod enum should reject this
expect(res.statusCode).toBe(400);
// Verify DB still works
const healthRes = await app.inject({
method: "GET",
url: "/api/v1/health",
});
expect(healthRes.statusCode).toBe(200);
});
it("handles SQL injection in crop settings without DB corruption", async () => {
const res = await postTool("crop", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: '{"left": 0, "top": 0, "width": "100; DROP TABLE sessions", "height": 100}',
},
]);
// Zod z.number() should reject string
expect(res.statusCode).toBe(400);
});
});
// ===========================================================================
// REQUEST BODY SIZE LIMITS
// The settings payload is capped at 64KB. Existing tests cover a 100KB
// settings string. These tests verify the limit from additional angles.
// ===========================================================================
describe("Request body size limits -- settings payload", () => {
it("rejects settings payload at exactly 65537 bytes (64KB + 1)", async () => {
// Build a settings object that is exactly one byte over the 64KB limit
const padLength = 65537 - '{"width":100,"pad":""}'.length;
const bigSettings = JSON.stringify({ width: 100, pad: "X".repeat(padLength) });
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: bigSettings },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/too large|64KB/i);
});
it("accepts settings payload at exactly 65536 bytes (64KB limit)", async () => {
const padLength = 65536 - '{"width":100,"pad":""}'.length;
const borderlineSettings = JSON.stringify({ width: 100, pad: "Y".repeat(padLength) });
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: borderlineSettings },
]);
// Exactly at the limit should be accepted (Zod strips the unknown "pad" field)
expect(res.statusCode).toBe(200);
});
});
// ===========================================================================
// EXTREMELY LONG PARAMETER VALUES (non-settings payloads)
// Verifies that Zod validation rejects excessively long values for
// fields with max length constraints.
// ===========================================================================
describe("Extremely long parameter values", () => {
it("rejects text-overlay with 10000-char text (max 500)", async () => {
const res = await postTool("text-overlay", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({
text: "B".repeat(10000),
fontSize: 24,
}),
},
]);
expect(res.statusCode).toBe(400);
});
it("rejects watermark-text with 10000-char text", async () => {
const res = await postTool("watermark-text", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({
text: "W".repeat(10000),
fontSize: 12,
opacity: 50,
}),
},
]);
expect(res.statusCode).toBe(400);
});
});
// ===========================================================================
// RACE CONDITION: CONCURRENT WRITES TO SAME OUTPUT PATHS
// Verifies that concurrent requests with identical filenames do not
// overwrite each other's output files (each should get a unique
// workspace/jobId).
// ===========================================================================
describe("Race conditions -- concurrent identical filename requests", () => {
it("10 concurrent requests with identical filename produce unique outputs", async () => {
const results = await Promise.all(
Array.from({ length: 10 }, () =>
app.inject(
buildToolRequest("resize", PNG_200x150, "same-name.png", {
width: 100,
}),
),
),
);
// All must succeed
for (const res of results) {
expect(res.statusCode).toBe(200);
}
// All must produce unique job IDs and download URLs
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
expect(new Set(jobIds).size).toBe(10);
const urls = results.map((r) => JSON.parse(r.body).downloadUrl);
expect(new Set(urls).size).toBe(10);
// Download two outputs and verify they are valid, independent images
const dl1 = await app.inject({
method: "GET",
url: urls[0],
headers: { authorization: `Bearer ${adminToken}` },
});
const dl2 = await app.inject({
method: "GET",
url: urls[1],
headers: { authorization: `Bearer ${adminToken}` },
});
expect(dl1.statusCode).toBe(200);
expect(dl2.statusCode).toBe(200);
const meta1 = await sharp(dl1.rawPayload).metadata();
const meta2 = await sharp(dl2.rawPayload).metadata();
expect(meta1.width).toBe(100);
expect(meta2.width).toBe(100);
}, 120_000);
it("concurrent pipeline and single request with same filename -- no collision", async () => {
const pipelinePayload = createMultipartPayload([
{
name: "file",
filename: "collision-test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "pipeline",
content: JSON.stringify({
steps: [
{ toolId: "resize", settings: { width: 80 } },
{ toolId: "compress", settings: { quality: 50 } },
],
}),
},
]);
const [pipelineRes, singleRes] = await Promise.all([
app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
"content-type": pipelinePayload.contentType,
authorization: `Bearer ${adminToken}`,
},
body: pipelinePayload.body,
}),
app.inject(
buildToolRequest("resize", PNG_200x150, "collision-test.png", {
width: 80,
}),
),
]);
// Both must succeed
expect(pipelineRes.statusCode).toBe(200);
expect(singleRes.statusCode).toBe(200);
// Different job IDs despite same filename
const pipeJob = JSON.parse(pipelineRes.body).jobId;
const singleJob = JSON.parse(singleRes.body).jobId;
expect(pipeJob).not.toBe(singleJob);
}, 60_000);
});
// ===========================================================================
// SERVER STABILITY AFTER SECURITY BARRAGE
// ===========================================================================