Files
SnapOtter/tests/integration/security/adversarial-matrix.test.ts
T

1306 lines
48 KiB
TypeScript
Raw Normal View History

/**
* Adversarial matrix integration tests for the SnapOtter image API.
*
* Covers gaps not addressed by adversarial.test.ts, adversarial-extended.test.ts,
* concurrent.test.ts, or edge-cases.test.ts:
*
* - Memory pressure: 10 sequential large-file uploads, back-to-back tool chain
* - Corrupted headers through non-resize tools (compress, crop, convert, rotate)
* - Wrong content-type headers (application/json with binary data)
* - Unicode filenames: emoji-only, very long (200+ chars)
* - Extreme dimensions: resize to 1x1, crop with 0 dimensions, rotate 1x1 by 45
* - Pipeline: 21+ steps (over limit), same step 10x same settings, up-then-down
* - Batch: all identical files, mixed valid/invalid through multiple tools
* - Parameter boundaries: quality -1/999, width "abc"/NaN, angle -360/720/999
* - Concurrent: 10 resize with large file, data integrity verification
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../test-server.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const PNG_200x150 = readFixture(fixtures.image.base.png200);
const PNG_1x1 = readFixture(fixtures.image.edge.px1);
const JPG_100x100 = readFixture(fixtures.image.base.jpg100);
const STRESS_LARGE = readFixture(fixtures.image.stressLarge);
// ---------------------------------------------------------------------------
// Shared state
// ---------------------------------------------------------------------------
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/** Helper to POST a multipart payload to a tool endpoint. */
function postTool(
toolId: string,
fields: Array<{
name: string;
filename?: string;
contentType?: string;
content: Buffer | string;
}>,
) {
const { body, contentType } = createMultipartPayload(fields);
return app.inject({
method: "POST",
url: `/api/v1/tools/${toolId}`,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
});
}
/** Helper to POST a batch request. */
function postBatch(
toolId: string,
fields: Array<{
name: string;
filename?: string;
contentType?: string;
content: Buffer | string;
}>,
) {
const { body, contentType } = createMultipartPayload(fields);
return app.inject({
method: "POST",
url: `/api/v1/tools/${toolId}/batch`,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
});
}
/** Helper to POST a pipeline execution request. */
function executePipeline(
image: Buffer,
filename: string,
pipeline: {
steps: Array<{ toolId: string; settings?: Record<string, unknown> }>;
},
) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, content: image, contentType: "image/png" },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
return app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
});
}
/** Helper to build an inject config for a tool request. */
function buildToolRequest(
toolId: string,
image: Buffer,
filename: string,
settings: Record<string, unknown>,
imgContentType = "image/png",
) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, content: image, contentType: imgContentType },
{ name: "settings", content: JSON.stringify(settings) },
]);
return {
method: "POST" as const,
url: `/api/v1/tools/${toolId}`,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// MEMORY PRESSURE -- 10 SEQUENTIAL LARGE FILE UPLOADS
// ═══════════════════════════════════════════════════════════════════════════
describe("Memory pressure -- 10 sequential large-file resize uploads", () => {
it("processes stress-large.jpg 10 times sequentially without degradation", async () => {
const results: Array<{ statusCode: number; jobId: string }> = [];
for (let i = 0; i < 10; i++) {
const res = await postTool("resize", [
{
name: "file",
filename: `stress-seq-${i}.jpg`,
content: STRESS_LARGE,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ width: 300 }) },
]);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
results.push({ statusCode: res.statusCode, jobId: json.jobId });
}
// All 10 must produce unique job IDs
const jobIds = results.map((r) => r.jobId);
expect(new Set(jobIds).size).toBe(10);
// Verify server is still responsive after 10 large uploads
const healthRes = await app.inject({
method: "GET",
url: "/api/v1/health",
});
expect(healthRes.statusCode).toBe(200);
}, 300_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// MEMORY PRESSURE -- BACK-TO-BACK TOOL CHAIN WITH LARGE FILE
// ═══════════════════════════════════════════════════════════════════════════
describe("Memory pressure -- large file through resize, compress, rotate back-to-back", () => {
it("processes stress-large.jpg through resize then compress then rotate sequentially", async () => {
const resizeRes = await postTool("resize", [
{
name: "file",
filename: "chain-resize.jpg",
content: STRESS_LARGE,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ width: 500 }) },
]);
expect(resizeRes.statusCode).toBe(200);
expect(JSON.parse(resizeRes.body).processedSize).toBeLessThan(
JSON.parse(resizeRes.body).originalSize,
);
const compressRes = await postTool("compress", [
{
name: "file",
filename: "chain-compress.jpg",
content: STRESS_LARGE,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ quality: 30 }) },
]);
expect(compressRes.statusCode).toBe(200);
expect(JSON.parse(compressRes.body).processedSize).toBeLessThan(
JSON.parse(compressRes.body).originalSize,
);
const rotateRes = await postTool("rotate", [
{
name: "file",
filename: "chain-rotate.jpg",
content: STRESS_LARGE,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ angle: 180 }) },
]);
expect(rotateRes.statusCode).toBe(200);
expect(JSON.parse(rotateRes.body).jobId).toBeDefined();
}, 180_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// CORRUPTED HEADERS THROUGH NON-RESIZE TOOLS
// ═══════════════════════════════════════════════════════════════════════════
describe("Corrupted headers through non-resize tools", () => {
it("rejects random binary garbage through compress gracefully", async () => {
const garbage = Buffer.from(
Array.from({ length: 4096 }, () => Math.floor(Math.random() * 256)),
);
const res = await postTool("compress", [
{
name: "file",
filename: "garbage.jpg",
content: garbage,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
]);
expect([400, 422]).toContain(res.statusCode);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
it("rejects random binary garbage through crop gracefully", async () => {
const garbage = Buffer.from(
Array.from({ length: 2048 }, () => Math.floor(Math.random() * 256)),
);
const res = await postTool("crop", [
{
name: "file",
filename: "garbage.png",
content: garbage,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 10, height: 10 }),
},
]);
expect([400, 422]).toContain(res.statusCode);
});
it("rejects random binary garbage through convert gracefully", async () => {
const garbage = Buffer.from(
Array.from({ length: 2048 }, () => Math.floor(Math.random() * 256)),
);
const res = await postTool("convert", [
{
name: "file",
filename: "garbage.png",
content: garbage,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ format: "webp" }) },
]);
expect([400, 422]).toContain(res.statusCode);
});
it("handles .jpg extension with PNG magic bytes through compress", async () => {
// PNG data sent as .jpg -- Sharp should auto-detect and process correctly
const res = await postTool("compress", [
{
name: "file",
filename: "really-a-png.jpg",
content: PNG_200x150,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ quality: 60 }) },
]);
// Sharp detects via magic bytes, not extension
expect(res.statusCode).toBe(200);
});
it("handles .png extension with JPEG magic bytes through crop", async () => {
// JPEG data sent as .png -- Sharp should auto-detect and process correctly
const res = await postTool("crop", [
{
name: "file",
filename: "really-a-jpeg.png",
content: JPG_100x100,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 50, height: 50 }),
},
]);
expect(res.statusCode).toBe(200);
});
it("handles .png extension with JPEG content through rotate", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "mislabeled.png",
content: JPG_100x100,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
]);
expect(res.statusCode).toBe(200);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// WRONG CONTENT-TYPE HEADER MISMATCHES
// ═══════════════════════════════════════════════════════════════════════════
describe("Wrong content-type header -- application/json with binary data", () => {
it("processes image when part content-type is application/json", async () => {
// The multipart part claims to be JSON but contains a real PNG image
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "application/json",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
// Sharp detects format from magic bytes, not content-type
expect(res.statusCode).toBe(200);
});
it("processes image when part content-type is image/png but data is JPEG", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test.jpg",
content: JPG_100x100,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
// Sharp detects actual JPEG format
expect(res.statusCode).toBe(200);
});
it("processes image when part content-type is video/mp4 (completely wrong)", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "video/mp4",
},
{ name: "settings", content: JSON.stringify({ quality: 80 }) },
]);
// Sharp ignores content-type and reads magic bytes
expect(res.statusCode).toBe(200);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// UNICODE FILENAMES -- ADDITIONAL PATTERNS
// ═══════════════════════════════════════════════════════════════════════════
describe("Unicode filenames -- additional adversarial patterns", () => {
it("handles emoji-only filename: \u{1F3A8}.jpg", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "\u{1F3A8}.jpg",
content: JPG_100x100,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.downloadUrl).toBeDefined();
});
it("handles multiple emoji in filename: \u{1F4F7}\u{1F30D}\u{2728}.png", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "\u{1F4F7}\u{1F30D}\u{2728}.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 80 }) },
]);
expect(res.statusCode).toBe(200);
});
it("handles very long filename (250+ chars)", async () => {
const longBase = "a".repeat(250);
const longName = `${longBase}.png`;
const res = await postTool("compress", [
{
name: "file",
filename: longName,
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ quality: 70 }) },
]);
// sanitizeFilename may truncate but should not crash
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.downloadUrl).toBeDefined();
});
it("handles filename with tab and newline characters", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "tab\there\nnewline.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 80 }) },
]);
// Whitespace should be sanitized or stripped
expect([200, 400]).toContain(res.statusCode);
});
it("handles filename with unicode zero-width characters", async () => {
// Zero-width joiner, zero-width space
const res = await postTool("resize", [
{
name: "file",
filename: "testimage.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 80 }) },
]);
expect(res.statusCode).toBe(200);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// EXTREME DIMENSIONS -- RESIZE TO 1x1 OUTPUT
// ═══════════════════════════════════════════════════════════════════════════
describe("Extreme dimensions -- resize to 1x1 output", () => {
it("resizes a 200x150 image down to 1x1", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "shrink-to-pixel.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ width: 1, height: 1 }),
},
]);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.processedSize).toBeLessThan(json.originalSize);
});
it("resizes a JPEG image down to 1x1", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "shrink-jpg.jpg",
content: JPG_100x100,
contentType: "image/jpeg",
},
{
name: "settings",
content: JSON.stringify({ width: 1, height: 1 }),
},
]);
expect(res.statusCode).toBe(200);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// EXTREME DIMENSIONS -- CROP EDGE CASES
// ═══════════════════════════════════════════════════════════════════════════
describe("Extreme dimensions -- crop edge cases", () => {
it("rejects crop with 0 width", async () => {
const res = await postTool("crop", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 0, height: 10 }),
},
]);
// z.number().positive() rejects 0
expect(res.statusCode).toBe(400);
});
it("rejects crop with 0 height", async () => {
const res = await postTool("crop", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 10, height: 0 }),
},
]);
expect(res.statusCode).toBe(400);
});
it("rejects crop with negative width", async () => {
const res = await postTool("crop", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: -10, height: 10 }),
},
]);
expect(res.statusCode).toBe(400);
});
it("rejects crop with negative left offset", async () => {
const res = await postTool("crop", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ left: -5, top: 0, width: 10, height: 10 }),
},
]);
// z.number().min(0) rejects negative left
expect(res.statusCode).toBe(400);
});
it("crops a 1x1 region from a normal image", async () => {
const res = await postTool("crop", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ left: 50, top: 50, width: 1, height: 1 }),
},
]);
expect([200, 422]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// EXTREME DIMENSIONS -- ROTATE 1x1 BY NON-ORTHOGONAL ANGLE
// ═══════════════════════════════════════════════════════════════════════════
describe("Extreme dimensions -- rotate 1x1 by non-orthogonal angles", () => {
it("rotates a 1x1 image by 45 degrees", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: 45 }) },
]);
// A 45-degree rotation of a 1x1 image produces a slightly larger image
// with transparent corners. Must not crash.
expect([200, 422]).toContain(res.statusCode);
});
it("rotates a 1x1 image by 1 degree", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: 1 }) },
]);
expect([200, 422]).toContain(res.statusCode);
});
it("rotates a 1x1 image by 359 degrees", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: 359 }) },
]);
expect([200, 422]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// PARAMETER BOUNDARY -- QUALITY VALUES
// ═══════════════════════════════════════════════════════════════════════════
describe("Parameter boundary -- compress quality values", () => {
it("rejects quality of -1", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ quality: -1 }) },
]);
// z.number().min(1) rejects -1
expect(res.statusCode).toBe(400);
});
it("rejects quality of 999", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ quality: 999 }) },
]);
// z.number().max(100) rejects 999
expect(res.statusCode).toBe(400);
});
it("accepts quality at exact minimum (1)", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ quality: 1 }) },
]);
expect(res.statusCode).toBe(200);
});
it("accepts quality at midpoint (50)", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
]);
expect(res.statusCode).toBe(200);
});
it("accepts quality at 99", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ quality: 99 }) },
]);
expect(res.statusCode).toBe(200);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// PARAMETER BOUNDARY -- WIDTH / HEIGHT VALUES
// ═══════════════════════════════════════════════════════════════════════════
describe("Parameter boundary -- resize width/height values", () => {
it("accepts width of 1 (minimum positive integer)", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 1 }) },
]);
expect(res.statusCode).toBe(200);
});
it("rejects width of -1", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: -1 }) },
]);
expect(res.statusCode).toBe(400);
});
it("rejects width as string 'abc'", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: '{"width": "abc"}' },
]);
// Zod z.number() rejects string
expect(res.statusCode).toBe(400);
});
it("rejects height as string 'NaN'", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: '{"height": "NaN"}' },
]);
expect(res.statusCode).toBe(400);
});
it("handles width of 99999 without crashing", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 99999 }) },
]);
// May succeed (Sharp allows large dims), fail at processing, or be
// rejected by validation. Must not crash.
// Note: Sharp will actually attempt to upscale, which can take significant
// time on a small CPU. The PROCESSING_TIMEOUT_S config may kill it.
expect([200, 400, 422]).toContain(res.statusCode);
}, 120_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// PARAMETER BOUNDARY -- ROTATION ANGLE VALUES
// ═══════════════════════════════════════════════════════════════════════════
describe("Parameter boundary -- rotation angle values", () => {
it("handles angle of -360 (full negative rotation)", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: -360 }) },
]);
// -360 is effectively 0 degrees; Sharp allows negative angles
expect([200, 400]).toContain(res.statusCode);
});
it("handles angle of -1 (slight counter-clockwise rotation)", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: -1 }) },
]);
// Sharp supports negative angles
expect([200, 400]).toContain(res.statusCode);
});
it("handles angle of 360 (full rotation, equivalent to 0)", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: 360 }) },
]);
expect([200, 400]).toContain(res.statusCode);
});
it("handles angle of 720 (double full rotation)", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: 720 }) },
]);
// Sharp may normalize or process as-is
expect([200, 400]).toContain(res.statusCode);
});
it("handles angle of 999 without crashing", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: 999 }) },
]);
// Sharp treats this as 999 mod 360 = 279 degrees effectively
expect([200, 400]).toContain(res.statusCode);
});
it("rejects angle as string 'abc'", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: '{"angle": "abc"}' },
]);
// z.number() rejects string
expect(res.statusCode).toBe(400);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// PIPELINE -- 21+ STEPS (OVER LIMIT)
// ═══════════════════════════════════════════════════════════════════════════
describe("Pipeline -- exceeding step limits", () => {
it("handles pipeline with 20 steps (many repeated resize operations)", async () => {
// MAX_PIPELINE_STEPS defaults to 0 (unlimited) in test env, so this should succeed
const steps = Array.from({ length: 20 }, () => ({
toolId: "resize",
settings: { percentage: 99 },
}));
const res = await executePipeline(PNG_200x150, "twenty-steps.png", { steps });
// With no MAX_PIPELINE_STEPS set, this should succeed
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(20);
}, 120_000);
it("handles pipeline with 25 steps (deep chain)", async () => {
// If there is a limit set, this tests it. If unlimited, it should succeed.
const steps = Array.from({ length: 25 }, () => ({
toolId: "compress",
settings: { quality: 99 },
}));
const res = await executePipeline(PNG_200x150, "twentyfive-steps.png", { steps });
// No MAX_PIPELINE_STEPS in test config, so should succeed
expect([200, 400]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(25);
} else {
// If rejected, should have a clear error about step limits
const json = JSON.parse(res.body);
expect(json.error).toMatch(/step|pipeline|max/i);
}
}, 120_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// PIPELINE -- SAME STEP REPEATED WITH IDENTICAL SETTINGS
// ═══════════════════════════════════════════════════════════════════════════
describe("Pipeline -- same step repeated 10 times with identical settings", () => {
it("handles 10 identical compress steps (quality=80) in a pipeline", async () => {
const steps = Array.from({ length: 10 }, () => ({
toolId: "compress",
settings: { quality: 80 },
}));
const res = await executePipeline(PNG_200x150, "repeat-compress.png", { steps });
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(10);
}, 60_000);
it("handles 10 identical rotate steps (angle=90) -- net result 2.5 full rotations", async () => {
const steps = Array.from({ length: 10 }, () => ({
toolId: "rotate",
settings: { angle: 90 },
}));
const res = await executePipeline(PNG_200x150, "repeat-rotate.png", { steps });
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(10);
}, 60_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// PIPELINE -- CONFLICTING OPERATIONS (RESIZE UP THEN DOWN)
// ═══════════════════════════════════════════════════════════════════════════
describe("Pipeline -- conflicting operations", () => {
it("handles resize up (500px) then resize down (50px)", async () => {
const res = await executePipeline(PNG_200x150, "up-then-down.png", {
steps: [
{ toolId: "resize", settings: { width: 500 } },
{ toolId: "resize", settings: { width: 50 } },
],
});
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(2);
// Final size should be small since we downsized to 50px
expect(json.processedSize).toBeLessThan(json.originalSize);
});
it("handles resize down (10px) then resize up (1000px)", async () => {
const res = await executePipeline(PNG_200x150, "down-then-up.png", {
steps: [
{ toolId: "resize", settings: { width: 10 } },
{ toolId: "resize", settings: { width: 1000 } },
],
});
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(2);
});
it("handles convert to webp, then convert to jpeg (format ping-pong)", async () => {
const res = await executePipeline(PNG_200x150, "format-pingpong.png", {
steps: [
{ toolId: "convert", settings: { format: "webp" } },
{ toolId: "convert", settings: { format: "jpg" } },
],
});
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(2);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// BATCH -- ALL IDENTICAL FILES
// ═══════════════════════════════════════════════════════════════════════════
describe("Batch -- all identical files", () => {
it("handles batch with 5 identical PNG files", async () => {
const res = await postBatch("resize", [
{ name: "file", filename: "same-1.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "same-2.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "same-3.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "same-4.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "same-5.png", contentType: "image/png", content: PNG_200x150 },
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(5);
});
it("handles batch with all identical files and same filename", async () => {
// All files have the exact same name -- deduplication should produce unique names in ZIP
const res = await postBatch("compress", [
{ name: "file", filename: "photo.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "photo.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "photo.png", contentType: "image/png", content: PNG_200x150 },
{ name: "settings", content: JSON.stringify({ quality: 60 }) },
]);
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
const names = Object.values(fileResults);
// All three entries should have unique names in the ZIP
expect(new Set(names).size).toBe(3);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// BATCH -- MIXED VALID/INVALID THROUGH COMPRESS AND ROTATE
// ═══════════════════════════════════════════════════════════════════════════
describe("Batch -- mixed valid/invalid through compress and rotate", () => {
it("handles batch compress with mix of valid images and garbage", async () => {
const garbage = Buffer.from("not a real image at all, just text");
const res = await postBatch("compress", [
{ name: "file", filename: "valid.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "garbage.jpg", contentType: "image/jpeg", content: garbage },
{ name: "file", filename: "valid.jpg", contentType: "image/jpeg", content: JPG_100x100 },
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
]);
// Batch processes valid files and skips invalid ones
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
it("handles batch rotate with mix of valid images and empty files", async () => {
const res = await postBatch("rotate", [
{ name: "file", filename: "valid.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "empty.png", contentType: "image/png", content: Buffer.alloc(0) },
{ name: "file", filename: "valid.jpg", contentType: "image/jpeg", content: JPG_100x100 },
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
]);
// Empty files are skipped during parsing; remaining valid files succeed
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// CONCURRENT -- 10 RESIZE WITH LARGE FILE, DATA INTEGRITY
// ═══════════════════════════════════════════════════════════════════════════
describe("Concurrent -- 10 simultaneous resize with large file", () => {
it("fires 10 simultaneous resize requests with stress-large.jpg -- all return 200 with valid data", async () => {
const results = await Promise.all(
Array.from({ length: 10 }, (_, i) =>
app.inject(
buildToolRequest(
"resize",
STRESS_LARGE,
`concurrent-large-${i}.jpg`,
{ width: 100 + i * 20 },
"image/jpeg",
),
),
),
);
for (const res of results) {
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
// All processed files should be smaller than original since we downsize
expect(json.processedSize).toBeLessThan(json.originalSize);
}
// All 10 must produce unique job IDs (no race condition corruption)
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
expect(new Set(jobIds).size).toBe(10);
// All download URLs must be unique
const urls = results.map((r) => JSON.parse(r.body).downloadUrl);
expect(new Set(urls).size).toBe(10);
}, 300_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// CONCURRENT -- RAPID SEQUENTIAL WITH NO DELAY
// ═══════════════════════════════════════════════════════════════════════════
describe("Concurrent -- rapid sequential requests with zero delay", () => {
it("fires 15 resize requests sequentially with no delay between them", async () => {
const results = [];
for (let i = 0; i < 15; i++) {
const res = await app.inject(
buildToolRequest("resize", PNG_200x150, `rapid-fire-${i}.png`, {
width: 30 + i * 5,
}),
);
results.push(res);
}
for (const res of results) {
expect(res.statusCode).toBe(200);
}
// All should produce unique job IDs
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
expect(new Set(jobIds).size).toBe(15);
}, 60_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// CONCURRENT -- PARALLEL BATCH + PIPELINE + SINGLE
// ═══════════════════════════════════════════════════════════════════════════
describe("Concurrent -- parallel batch, pipeline, and single requests", () => {
it("runs batch, pipeline, and single resize simultaneously without corruption", async () => {
// Single request
const singleReq = app.inject(
buildToolRequest("resize", PNG_200x150, "single.png", { width: 80 }),
);
// Batch request
const batchPayload = createMultipartPayload([
{ name: "file", filename: "b1.png", content: PNG_200x150, contentType: "image/png" },
{ name: "file", filename: "b2.jpg", content: JPG_100x100, contentType: "image/jpeg" },
{ name: "settings", content: JSON.stringify({ width: 40 }) },
]);
const batchReq = app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
"content-type": batchPayload.contentType,
authorization: `Bearer ${adminToken}`,
},
body: batchPayload.body,
});
// Pipeline request
const pipelinePayload = createMultipartPayload([
{ name: "file", filename: "pipe.png", content: PNG_200x150, contentType: "image/png" },
{
name: "pipeline",
content: JSON.stringify({
steps: [
{ toolId: "resize", settings: { width: 60 } },
{ toolId: "compress", settings: { quality: 70 } },
],
}),
},
]);
const pipelineReq = app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
"content-type": pipelinePayload.contentType,
authorization: `Bearer ${adminToken}`,
},
body: pipelinePayload.body,
});
const [singleRes, batchRes, pipelineRes] = await Promise.all([
singleReq,
batchReq,
pipelineReq,
]);
// Single request
expect(singleRes.statusCode).toBe(200);
const singleBody = JSON.parse(singleRes.body);
expect(singleBody.jobId).toBeDefined();
// Batch request
expect(batchRes.statusCode).toBe(200);
expect(batchRes.headers["content-type"]).toBe("application/zip");
// Pipeline request
expect(pipelineRes.statusCode).toBe(200);
const pipelineBody = JSON.parse(pipelineRes.body);
expect(pipelineBody.stepsCompleted).toBe(2);
expect(pipelineBody.jobId).toBeDefined();
// Job IDs across all three must be unique
expect(singleBody.jobId).not.toBe(pipelineBody.jobId);
}, 120_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// EMPTY FILE WITH VALID EXTENSION THROUGH MULTIPLE TOOLS
// ═══════════════════════════════════════════════════════════════════════════
describe("Empty file (0 bytes) with valid extension through various tools", () => {
it("rejects empty .jpg file through text-overlay with 400", async () => {
const res = await postTool("text-overlay", [
{
name: "file",
filename: "empty.jpg",
content: Buffer.alloc(0),
contentType: "image/jpeg",
},
{
name: "settings",
content: JSON.stringify({ text: "Hello", fontSize: 24 }),
},
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
it("rejects empty .webp file through watermark-text with 400", async () => {
const res = await postTool("watermark-text", [
{
name: "file",
filename: "empty.webp",
content: Buffer.alloc(0),
contentType: "image/webp",
},
{
name: "settings",
content: JSON.stringify({ text: "WM", fontSize: 12, opacity: 50 }),
},
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// SERVER STABILITY AFTER ADVERSARIAL BARRAGE
// ═══════════════════════════════════════════════════════════════════════════
describe("Server stability -- health check after adversarial barrage", () => {
it("server remains responsive after processing all preceding tests", async () => {
// This test runs last (due to describe ordering) and verifies the server
// did not leak memory or enter a degraded state from all the abuse above.
const healthRes = await app.inject({
method: "GET",
url: "/api/v1/health",
});
expect(healthRes.statusCode).toBe(200);
const json = JSON.parse(healthRes.body);
expect(json.status).toBe("healthy");
// Also verify a normal tool request still works
const normalRes = await postTool("resize", [
{
name: "file",
filename: "sanity-check.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(normalRes.statusCode).toBe(200);
expect(JSON.parse(normalRes.body).jobId).toBeDefined();
});
});