mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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
|
||||
// ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user