Files
SnapOtter/tests/integration/security/adversarial-extended.test.ts
T
SnapOtterandGitHub 2d8b57c57f fix(api): gate every tool endpoint and stop ZIP streams failing quietly (#646)
Three defects from #645, all of which let the server report something
that was not true.

Tool access was enforced per route, so it drifted. createToolRoute calls
requireToolAccess and the factory tools were fine, but all 45
hand-written routes had to remember the same call and none of them did.
A role without tools:use could run image-to-pdf, svg-to-raster,
erase-object, favicon, qr-generate, upscale, sign-pdf and the rest. The
issue described this as affecting two routes; it was every one of them.

The check now lives in a single preHandler keyed off the tool the router
matched, so it covers sub-paths (/batch, /info, /preview, /analyze,
/inspect) and any route added later without that route opting in. Ids no
tool claims stay unresolved, which keeps an unknown or misfiled tool a
404 rather than telling an unauthorized caller which ids exist.

Resolution reads request.routeOptions.url, the pattern the app itself
registered, rather than parsing request.url a second time. find-my-way
decodes before matching, so an independent parse disagrees with the
router and the router wins: `/api/v1/tools/image/%66avicon` ran favicon
while the gate saw no tool at all. Absolute-form request targets slipped
it the same way. Taking the router's own answer removes the disagreement.

A ZIP stream that failed after the 200 headers were out called
reply.raw.end(). On a chunked response that is indistinguishable from
success, so a client kept an archive with no central directory believing
it whole. Worse, a source stream that errored had no listener: the
request hung until it timed out and the error surfaced as unhandled. A
poisoned-storage probe reproduced both. The socket is destroyed instead,
and every source stream is listened to. svg-to-raster additionally ran
its append loop past the hijack with no try/catch, where a throw leaves
Fastify logging and walking away with the socket neither ended nor
destroyed. pdf-to-image is fixed alongside the other two: it shipped in
#643 with the destroy half but not the listener, so it hung the same way.

A zero-byte upload was dropped during parsing. The client pairs results
with its own file list by index, so every later result shifted onto the
wrong file: one document's output was presented as another's, under
another's name, while the file that actually converted was marked "not
found in batch results". Empty parts now keep their slot and fail in
place with a reason.

Two tests in adversarial-extended.test.ts asserted the old zero-byte
behavior, including a comment that batch "silently skips zero-byte
parts". They now pin the replacement: still rejected, nothing processed,
but the caller is told which files were empty instead of being told it
sent none.

A guard walks the whole catalog and fails if any of the 241 tools answers
anything but 403 for a role without tools:use, so a tool cannot escape
the gate by being registered in a shape nobody thought to sample.

Fixes #645
2026-07-26 09:57:44 +08:00

1766 lines
60 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Extended adversarial integration tests for the SnapOtter image API.
*
* Covers: zero-byte uploads across tools, corrupted headers / wrong magic
* bytes, unicode filenames, extreme dimensions through various tools, batch
* edge cases, pipeline edge cases, and concurrent stress scenarios.
*
* Complements adversarial.test.ts (32 tests), edge-cases.test.ts (24 tests),
* and concurrent.test.ts.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { apiToolPath, TOOLS } from "@snapotter/shared";
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);
// Real catalog tools resolve to their section-prefixed path; unknown ids
// (used by the non-existent/injection negative tests below) fall back to a
// raw path that matches no route and 404s, instead of throwing in apiToolPath.
const toolUrl = (toolId: string): string =>
TOOLS.some((t) => t.id === toolId) ? apiToolPath(toolId) : `/api/v1/tools/${toolId}`;
/** 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: toolUrl(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: `${toolUrl(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>,
) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, content: image, contentType: "image/png" },
{ name: "settings", content: JSON.stringify(settings) },
]);
return {
method: "POST" as const,
url: apiToolPath(toolId),
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// ZERO-BYTE FILES ACROSS MULTIPLE TOOLS
// ═══════════════════════════════════════════════════════════════════════════
describe("Zero-byte file uploads across tools", () => {
const zeroBuffer = Buffer.alloc(0);
it("rejects a 0-byte file to /api/v1/tools/image/resize with 400", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "empty.png",
content: zeroBuffer,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
it("rejects a 0-byte file to /api/v1/tools/image/compress with 400", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "empty.jpg",
content: zeroBuffer,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ quality: 80 }) },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
it("rejects a 0-byte file to /api/v1/tools/image/convert with 400", async () => {
const res = await postTool("convert", [
{
name: "file",
filename: "empty.png",
content: zeroBuffer,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ format: "jpg" }) },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
it("rejects a 0-byte file to /api/v1/tools/image/rotate with 400", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "empty.webp",
content: zeroBuffer,
contentType: "image/webp",
},
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
it("rejects a 0-byte file to /api/v1/tools/image/crop with 400", async () => {
const res = await postTool("crop", [
{
name: "file",
filename: "empty.png",
content: zeroBuffer,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({
left: 0,
top: 0,
width: 10,
height: 10,
}),
},
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
it("rejects a 0-byte file to /api/v1/tools/image/border with 400", async () => {
const res = await postTool("border", [
{
name: "file",
filename: "empty.png",
content: zeroBuffer,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ borderWidth: 10 }) },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// CORRUPTED HEADERS / WRONG MAGIC BYTES
// ═══════════════════════════════════════════════════════════════════════════
describe("Corrupted headers and wrong magic bytes", () => {
it("handles PNG magic bytes followed by garbage gracefully", async () => {
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
const corruptedPng = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from(
"THIS IS GARBAGE CONTENT AFTER A VALID PNG HEADER " +
"AAAA BBBB CCCC DDDD EEEE FFFF 0000 1111 2222 3333",
),
]);
const res = await postTool("resize", [
{
name: "file",
filename: "corrupt-png.png",
content: corruptedPng,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
// Should fail gracefully with a JSON error, NOT crash the server
expect([400, 422]).toContain(res.statusCode);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
it("handles JPEG SOI marker followed by truncated data gracefully", async () => {
// JPEG starts with FF D8 (SOI), typically followed by FF E0 (APP0)
const truncatedJpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]);
const res = await postTool("compress", [
{
name: "file",
filename: "truncated.jpg",
content: truncatedJpeg,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
]);
// Must not crash; either 200 (partial decode), 400, or 422
expect([200, 400, 422]).toContain(res.statusCode);
});
it("handles a .jpg file containing actual PNG data (format mismatch)", async () => {
// Sharp auto-detects via magic bytes, so this should process fine
const res = await postTool("compress", [
{
name: "file",
filename: "actually-png.jpg",
content: PNG_200x150,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ quality: 75 }) },
]);
// Sharp detects the real format via magic bytes regardless of extension
expect(res.statusCode).toBe(200);
});
it("rejects a BMP header followed by garbage", async () => {
// BMP magic bytes: 42 4D
const fakeBmp = Buffer.concat([Buffer.from([0x42, 0x4d]), Buffer.alloc(100, 0xff)]);
const res = await postTool("resize", [
{
name: "file",
filename: "fake.bmp",
content: fakeBmp,
contentType: "image/bmp",
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
// BMP is not a supported format OR the garbage content fails validation
expect([400, 422]).toContain(res.statusCode);
});
it("rejects a WebP RIFF header followed by garbage", async () => {
// WebP starts with RIFF....WEBP
const fakeWebp = Buffer.concat([
Buffer.from("RIFF"),
Buffer.from([0x00, 0x00, 0x00, 0x00]),
Buffer.from("WEBP"),
Buffer.alloc(50, 0xab),
]);
const res = await postTool("resize", [
{
name: "file",
filename: "corrupt.webp",
content: fakeWebp,
contentType: "image/webp",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect([400, 422]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// UNICODE FILENAMES (EXTENDED)
// ═══════════════════════════════════════════════════════════════════════════
describe("Unicode filenames — extended adversarial", () => {
it("handles filename with emoji: photo_\u{1F389}.png", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "photo_\u{1F389}.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.downloadUrl).toBeDefined();
});
it("handles filename with CJK characters: 写真.png", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "写真.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(200);
});
it("handles filename with spaces and special chars: my photo (final).png", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "my photo (final).png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(200);
});
it("sanitizes path traversal: ../../../etc/passwd.png", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "../../../etc/passwd.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.downloadUrl).not.toContain("..");
expect(json.downloadUrl).not.toContain("etc/passwd");
});
it("handles filename with mixed RTL and LTR characters", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "ملف_photo_الصورة.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(200);
});
it("handles filename with null bytes stripped", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test\x00hidden.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
// Should succeed — null bytes are stripped by filename sanitization
expect([200, 400]).toContain(res.statusCode);
});
it("handles filename with only an extension", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: ".png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(200);
});
it("handles filename with double extensions", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "exploit.php.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
// The download URL retains "exploit.php" in the base name but the final
// extension is still a safe image format (.png). The key security check
// is that the URL ends with a recognized image extension.
expect(json.downloadUrl).toMatch(/\.(png|jpg|jpeg|webp|avif|tiff|gif)$/);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// EXTREME DIMENSIONS THROUGH VARIOUS TOOLS
// ═══════════════════════════════════════════════════════════════════════════
describe("1x1 pixel image through additional tools", () => {
it("upscales a 1x1 pixel image to 100x100 via resize", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ width: 100, height: 100 }),
},
]);
expect(res.statusCode).toBe(200);
});
it("applies crop to a 1x1 pixel image (1x1 crop region)", async () => {
const res = await postTool("crop", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 1, height: 1 }),
},
]);
// Should succeed or fail gracefully
expect([200, 422]).toContain(res.statusCode);
});
it("applies border to a 1x1 pixel image", async () => {
const res = await postTool("border", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({
borderWidth: 20,
borderColor: "#FF0000",
}),
},
]);
expect(res.statusCode).toBe(200);
});
it("applies watermark-text to a 1x1 pixel image", async () => {
const res = await postTool("watermark-text", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({
text: "WATERMARK",
fontSize: 12,
opacity: 50,
}),
},
]);
// The watermark will be much larger than the 1x1 image — may succeed
// or fail depending on how Sharp handles the composite. Must not crash.
expect([200, 400, 422]).toContain(res.statusCode);
});
it("applies sharpening to a 1x1 pixel image", async () => {
const res = await postTool("sharpening", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ method: "adaptive" }),
},
]);
// Sharpening a 1x1 image is a no-op but should not crash
expect([200, 422]).toContain(res.statusCode);
});
it("applies text-overlay to a 1x1 pixel image", async () => {
const res = await postTool("text-overlay", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({
text: "Hello",
fontSize: 24,
}),
},
]);
// Text overlay on 1x1 image — the SVG overlay will dwarf the image
expect([200, 422]).toContain(res.statusCode);
});
it("converts a 1x1 pixel image from PNG to WebP", async () => {
const res = await postTool("convert", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ format: "webp" }) },
]);
expect([200, 400]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// BATCH EDGE CASES (EXTENDED)
// ═══════════════════════════════════════════════════════════════════════════
describe("Batch edge cases — extended", () => {
it("rejects batch with 0 images uploaded to resize", async () => {
const res = await postBatch("resize", [
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/no file/i);
});
it("handles batch with duplicate filenames", async () => {
const res = await postBatch("resize", [
{
name: "file",
filename: "duplicate.png",
contentType: "image/png",
content: PNG_200x150,
},
{
name: "file",
filename: "duplicate.png",
contentType: "image/png",
content: PNG_200x150,
},
{
name: "file",
filename: "duplicate.png",
contentType: "image/png",
content: PNG_200x150,
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
// Should succeed and deduplicate filenames in the ZIP
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));
// All three entries should have unique names
const names = Object.values(fileResults);
expect(new Set(names).size).toBe(3);
});
it("handles batch with mix of valid and invalid files", async () => {
const garbage = Buffer.from("this is not an image at all");
const res = await postBatch("resize", [
{
name: "file",
filename: "valid.png",
contentType: "image/png",
content: PNG_200x150,
},
{
name: "file",
filename: "invalid.png",
contentType: "image/png",
content: garbage,
},
{
name: "file",
filename: "also-valid.jpg",
contentType: "image/jpeg",
content: JPG_100x100,
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
// Batch should process valid files and skip invalid ones.
// Since not all files failed, it should return 200 with a ZIP.
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
it("rejects batch where ALL files are invalid", async () => {
const garbage1 = Buffer.from("not an image 1");
const garbage2 = Buffer.from("not an image 2");
const res = await postBatch("resize", [
{
name: "file",
filename: "bad1.png",
contentType: "image/png",
content: garbage1,
},
{
name: "file",
filename: "bad2.png",
contentType: "image/png",
content: garbage2,
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
// All files failed — should return 422
expect(res.statusCode).toBe(422);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/all files failed/i);
expect(json.errors).toBeDefined();
expect(json.errors.length).toBe(2);
});
it("rejects a zero-byte file and names it (issue #645)", async () => {
// Zero-byte parts used to be dropped during parsing, which answered "No
// files provided" for a request that did provide one and, in a mixed
// batch, shifted every later result onto the wrong file. They now keep
// their slot and fail in place with a reason.
const res = await postBatch("resize", [
{
name: "file",
filename: "empty.png",
contentType: "image/png",
content: Buffer.alloc(0),
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
expect(res.statusCode).toBe(422);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/all files failed/i);
expect(json.errors).toHaveLength(1);
expect(json.errors[0].filename).toBe("empty.png");
expect(json.errors[0].error).toMatch(/empty/i);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// PIPELINE EDGE CASES (EXTENDED)
// ═══════════════════════════════════════════════════════════════════════════
describe("Pipeline edge cases — extended", () => {
it("rejects pipeline with 0 steps", async () => {
const res = await executePipeline(PNG_200x150, "test.png", {
steps: [],
});
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/pipeline/i);
});
it("handles pipeline with conflicting resize then crop larger than result", async () => {
// Resize to 50x50, then crop region 200x200 — should fail at crop step
const res = await executePipeline(PNG_200x150, "test.png", {
steps: [
{ toolId: "resize", settings: { width: 50, height: 50 } },
{
toolId: "crop",
settings: { left: 0, top: 0, width: 200, height: 200 },
},
],
});
// Crop exceeds resized dimensions — should fail gracefully (422)
// or succeed if sharp auto-clips. Must not crash.
expect([200, 422]).toContain(res.statusCode);
});
it("rejects pipeline step with unknown tool name", async () => {
const res = await executePipeline(PNG_200x150, "test.png", {
steps: [{ toolId: "resize", settings: { width: 100 } }, { toolId: "imaginary-tool-xyz" }],
});
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/not found/i);
});
it("handles pipeline with resize then border then compress chain", async () => {
const res = await executePipeline(PNG_200x150, "test.png", {
steps: [
{ toolId: "resize", settings: { width: 80 } },
{
toolId: "border",
settings: { borderWidth: 5, borderColor: "#FF0000" },
},
{ toolId: "compress", settings: { quality: 50 } },
],
});
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(3);
});
it("rejects pipeline with empty settings object for a required-field tool", async () => {
// watermark-text requires 'text' (min length 1)
const res = await executePipeline(PNG_200x150, "test.png", {
steps: [{ toolId: "watermark-text", settings: {} }],
});
// Zod validation should reject the missing required 'text' field
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/step 1/i);
});
it("handles pipeline that outputs to a different format mid-chain", async () => {
// Convert to webp, then resize — should work since Sharp handles webp
const res = await executePipeline(PNG_200x150, "test.png", {
steps: [
{ toolId: "convert", settings: { format: "webp" } },
{ toolId: "resize", settings: { width: 50 } },
],
});
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(2);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// CONCURRENT STRESS — EXTENDED
// ═══════════════════════════════════════════════════════════════════════════
describe("Concurrent stress — 10 simultaneous resize requests", () => {
it("fires 10 simultaneous resize requests with the same image — all return 200", async () => {
const results = await Promise.all(
Array.from({ length: 10 }, (_, i) =>
app.inject(
buildToolRequest("resize", PNG_200x150, `stress-${i}.png`, {
width: 50,
}),
),
),
);
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 10 must produce unique job IDs
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
expect(new Set(jobIds).size).toBe(10);
}, 120_000);
});
describe("Concurrent stress — 5 different tools simultaneously", () => {
it("fires resize, crop, rotate, compress, and border simultaneously — all return 200", async () => {
const [resizeRes, cropRes, rotateRes, compressRes, borderRes] = await Promise.all([
app.inject(
buildToolRequest("resize", PNG_200x150, "conc-resize.png", {
width: 100,
}),
),
app.inject(
buildToolRequest("crop", PNG_200x150, "conc-crop.png", {
left: 0,
top: 0,
width: 100,
height: 100,
}),
),
app.inject(
buildToolRequest("rotate", PNG_200x150, "conc-rotate.png", {
angle: 90,
}),
),
app.inject(
buildToolRequest("compress", PNG_200x150, "conc-compress.png", {
quality: 60,
}),
),
app.inject(
buildToolRequest("border", PNG_200x150, "conc-border.png", {
borderWidth: 10,
borderColor: "#0000FF",
}),
),
]);
expect(resizeRes.statusCode).toBe(200);
expect(cropRes.statusCode).toBe(200);
expect(rotateRes.statusCode).toBe(200);
expect(compressRes.statusCode).toBe(200);
expect(borderRes.statusCode).toBe(200);
// All must have unique job IDs
const ids = [resizeRes, cropRes, rotateRes, compressRes, borderRes].map(
(r) => JSON.parse(r.body).jobId,
);
expect(new Set(ids).size).toBe(5);
}, 120_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// CONCURRENT MIX OF ADVERSARIAL AND VALID REQUESTS
// ═══════════════════════════════════════════════════════════════════════════
describe("Concurrent adversarial and valid requests", () => {
it("processes valid requests correctly even when invalid ones are fired simultaneously", async () => {
const garbage = Buffer.from(
Array.from({ length: 1024 }, () => Math.floor(Math.random() * 256)),
);
const emptyBuf = Buffer.alloc(0);
const [valid1, valid2, invalid1, invalid2, valid3] = await Promise.all([
app.inject(
buildToolRequest("resize", PNG_200x150, "valid-1.png", {
width: 80,
}),
),
app.inject(
buildToolRequest("rotate", JPG_100x100, "valid-2.jpg", {
angle: 180,
}),
),
// Garbage data
app.inject(buildToolRequest("resize", garbage, "garbage.png", { width: 50 })),
// Zero-byte file — manually construct since buildToolRequest uses non-empty image
(() => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "empty.png",
content: emptyBuf,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ width: 50 }),
},
]);
return app.inject({
method: "POST",
url: "/api/v1/tools/image/resize",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
});
})(),
app.inject(
buildToolRequest("compress", PNG_200x150, "valid-3.png", {
quality: 70,
}),
),
]);
// Valid requests must succeed
expect(valid1.statusCode).toBe(200);
expect(valid2.statusCode).toBe(200);
expect(valid3.statusCode).toBe(200);
// Invalid requests must fail gracefully
expect([400, 422]).toContain(invalid1.statusCode);
expect(invalid2.statusCode).toBe(400);
// Valid results must have valid job IDs
expect(JSON.parse(valid1.body).jobId).toBeDefined();
expect(JSON.parse(valid2.body).jobId).toBeDefined();
expect(JSON.parse(valid3.body).jobId).toBeDefined();
}, 120_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// INJECTION IN VARIOUS SETTINGS FIELDS
// ═══════════════════════════════════════════════════════════════════════════
describe("Injection attacks in settings fields", () => {
it("handles command injection attempt in border color field", 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: "$(rm -rf /)",
}),
},
]);
// Zod hex color regex should reject this
expect(res.statusCode).toBe(400);
});
it("handles prototype pollution attempt in settings", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({
width: 100,
__proto__: { admin: true },
constructor: { prototype: { isAdmin: true } },
}),
},
]);
// Zod strips unknown keys — the extra fields should be ignored
expect(res.statusCode).toBe(200);
});
it("handles extremely long string in watermark text", async () => {
const longText = "A".repeat(501); // exceeds 500 char max
const res = await postTool("watermark-text", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({
text: longText,
fontSize: 12,
}),
},
]);
// z.string().max(500) should reject this
expect(res.statusCode).toBe(400);
});
it("handles NaN and Infinity in numeric settings", async () => {
// JSON.stringify(NaN) becomes null, JSON.stringify(Infinity) also becomes null
// So we pass them as strings which should fail Zod number validation
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: '{"width": "NaN"}',
},
]);
// Zod should reject string "NaN" for a z.number() field
expect(res.statusCode).toBe(400);
});
it("handles boolean where number expected", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ width: true }),
},
]);
expect(res.statusCode).toBe(400);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// CONTENT-TYPE MISMATCHES
// ═══════════════════════════════════════════════════════════════════════════
describe("Content-type header mismatches", () => {
it("processes image when content-type is application/octet-stream", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "application/octet-stream",
},
{ 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 content-type is completely wrong", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "text/plain",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
// Sharp should still detect the actual format
expect(res.statusCode).toBe(200);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// RAPID-FIRE SEQUENTIAL REQUESTS TO DIFFERENT TOOLS
// ═══════════════════════════════════════════════════════════════════════════
describe("Rapid sequential requests across tools", () => {
it("handles 8 sequential requests to alternating tools without errors", async () => {
const tools = [
{ id: "resize", settings: { width: 80 } },
{ id: "rotate", settings: { angle: 90 } },
{ id: "compress", settings: { quality: 70 } },
{ id: "border", settings: { borderWidth: 5 } },
{ id: "resize", settings: { width: 60 } },
{ id: "rotate", settings: { angle: 180 } },
{ id: "compress", settings: { quality: 50 } },
{ id: "border", settings: { borderWidth: 10 } },
];
for (let i = 0; i < tools.length; i++) {
const tool = tools[i];
const res = await app.inject(
buildToolRequest(tool.id, PNG_200x150, `rapid-${i}.png`, tool.settings),
);
expect(res.statusCode).toBe(200);
}
}, 60_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// SETTINGS BOUNDARY VALUES
// ═══════════════════════════════════════════════════════════════════════════
describe("Settings boundary values", () => {
it("accepts compress quality at minimum boundary (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 compress quality at maximum boundary (100)", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ quality: 100 }) },
]);
expect(res.statusCode).toBe(200);
});
it("rejects compress quality below minimum (0)", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ quality: 0 }) },
]);
expect(res.statusCode).toBe(400);
});
it("rejects compress quality above maximum (101)", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ quality: 101 }) },
]);
expect(res.statusCode).toBe(400);
});
it("accepts rotate at 0 degrees", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: 0 }) },
]);
// 0-degree rotation is a valid no-op
expect([200, 400]).toContain(res.statusCode);
});
it("accepts rotate at 359 degrees", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ angle: 359 }) },
]);
expect(res.statusCode).toBe(200);
});
it("handles border width at maximum (2000)", async () => {
const res = await postTool("border", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ borderWidth: 2000 }),
},
]);
// May succeed or fail at processing, but must not crash
expect([200, 400, 422]).toContain(res.statusCode);
});
it("rejects border width above maximum (2001)", async () => {
const res = await postTool("border", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ borderWidth: 2001 }),
},
]);
expect(res.statusCode).toBe(400);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// MULTI-FORMAT ZERO-BYTE BATCH
// ═══════════════════════════════════════════════════════════════════════════
describe("Batch where every file is zero-byte", () => {
it("rejects batch where all files are zero-byte", async () => {
const res = await postBatch("resize", [
{
name: "file",
filename: "empty1.png",
contentType: "image/png",
content: Buffer.alloc(0),
},
{
name: "file",
filename: "empty2.png",
contentType: "image/png",
content: Buffer.alloc(0),
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
// Still rejected outright, nothing processed, but the caller is now told
// which files were empty instead of being told it sent none (issue #645).
expect(res.statusCode).toBe(422);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/all files failed/i);
expect(json.errors.map((e: { filename: string }) => e.filename)).toEqual([
"empty1.png",
"empty2.png",
]);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// PIPELINE WITH 1x1 PIXEL IMAGE
// ═══════════════════════════════════════════════════════════════════════════
describe("Pipeline with 1x1 pixel image", () => {
it("processes 1x1 image through resize + border + compress pipeline", async () => {
const res = await executePipeline(PNG_1x1, "tiny.png", {
steps: [
{ toolId: "resize", settings: { width: 50, height: 50 } },
{
toolId: "border",
settings: { borderWidth: 5, borderColor: "#00FF00" },
},
{ toolId: "compress", settings: { quality: 80 } },
],
});
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(3);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// NON-EXISTENT TOOL ENDPOINTS
// ═══════════════════════════════════════════════════════════════════════════
describe("Non-existent tool and route handling", () => {
it("returns 404 for completely non-existent tool route", async () => {
const res = await postTool("this-tool-does-not-exist-at-all", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(404);
});
it("returns 404 for tool endpoint with SQL injection in URL", async () => {
const res = await postTool("resize'; DROP TABLE users; --", [
{
name: "file",
filename: "test.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(404);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// MEMORY PRESSURE — LARGE FILE SEQUENTIAL UPLOADS
// ═══════════════════════════════════════════════════════════════════════════
describe("Memory pressure — large file (6.7MB) sequential uploads", () => {
it("processes 5 sequential resize requests with stress-large.jpg — all succeed", async () => {
for (let i = 0; i < 5; i++) {
const res = await postTool("resize", [
{
name: "file",
filename: `stress-${i}.jpg`,
content: STRESS_LARGE,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ width: 200 }) },
]);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.processedSize).toBeLessThan(json.originalSize);
}
}, 120_000);
it("processes stress-large.jpg through compress", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "stress-compress.jpg",
content: STRESS_LARGE,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ quality: 40 }) },
]);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.processedSize).toBeLessThan(json.originalSize);
}, 60_000);
it("processes stress-large.jpg through convert (JPEG to WebP)", async () => {
const res = await postTool("convert", [
{
name: "file",
filename: "stress-convert.jpg",
content: STRESS_LARGE,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ format: "webp" }) },
]);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.downloadUrl).toBeDefined();
}, 60_000);
it("processes stress-large.jpg through info", async () => {
const res = await postTool("info", [
{
name: "file",
filename: "stress-info.jpg",
content: STRESS_LARGE,
contentType: "image/jpeg",
},
]);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.width).toBeGreaterThan(0);
expect(json.height).toBeGreaterThan(0);
expect(json.format).toBe("jpeg");
}, 60_000);
it("processes stress-large.jpg through rotate", async () => {
const res = await postTool("rotate", [
{
name: "file",
filename: "stress-rotate.jpg",
content: STRESS_LARGE,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
]);
expect(res.statusCode).toBe(200);
}, 60_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// PIPELINE LIMIT — MANY STEPS
// ═══════════════════════════════════════════════════════════════════════════
describe("Pipeline with many repeated steps", () => {
it("handles pipeline with 10 resize steps (repeated operation)", async () => {
const steps = Array.from({ length: 10 }, () => ({
toolId: "resize",
settings: { percentage: 95 },
}));
const res = await executePipeline(PNG_200x150, "multi-resize.png", { steps });
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(10);
}, 120_000);
it("handles pipeline with alternating resize and compress (circular-like)", async () => {
const steps = [
{ toolId: "resize", settings: { width: 180 } },
{ toolId: "compress", settings: { quality: 90 } },
{ toolId: "resize", settings: { width: 160 } },
{ toolId: "compress", settings: { quality: 80 } },
{ toolId: "resize", settings: { width: 140 } },
{ toolId: "compress", settings: { quality: 70 } },
];
const res = await executePipeline(PNG_200x150, "circular.png", { steps });
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(6);
// Each resize reduces size, so final should be smaller than original
expect(json.processedSize).toBeLessThan(json.originalSize);
}, 60_000);
it("handles pipeline with duplicate consecutive steps (same tool, same settings)", async () => {
const steps = Array.from({ length: 5 }, () => ({
toolId: "compress",
settings: { quality: 50 },
}));
const res = await executePipeline(PNG_200x150, "dupe-steps.png", { steps });
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(5);
}, 60_000);
it("rejects pipeline with only non-existent tool IDs", async () => {
const res = await executePipeline(PNG_200x150, "bad-pipeline.png", {
steps: [{ toolId: "fake-tool-a" }, { toolId: "fake-tool-b" }],
});
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/not found/i);
});
it("handles pipeline where middle step fails — returns partial step info", async () => {
// Resize to 10x10, then crop 200x200 (exceeds image), then border
const res = await executePipeline(PNG_200x150, "mid-fail.png", {
steps: [
{ toolId: "resize", settings: { width: 10, height: 10 } },
{ toolId: "crop", settings: { left: 0, top: 0, width: 200, height: 200 } },
{ toolId: "border", settings: { borderWidth: 5 } },
],
});
// Should fail at the crop step (step 2)
expect([200, 422]).toContain(res.statusCode);
if (res.statusCode === 422) {
const json = JSON.parse(res.body);
expect(json.completedSteps).toBeDefined();
expect(json.completedSteps.length).toBe(1); // only step 1 completed
}
}, 30_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// BATCH LIMITS — BOUNDARY TESTS
// ═══════════════════════════════════════════════════════════════════════════
describe("Batch limits — boundary tests", () => {
it("handles batch with exactly 1 image (minimum valid batch)", async () => {
const res = await postBatch("resize", [
{
name: "file",
filename: "single-batch.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(1);
});
it("handles batch at MAX_BATCH_SIZE limit (10 images)", async () => {
const fields = Array.from({ length: 10 }, (_, i) => ({
name: "file",
filename: `batch-max-${i}.png`,
contentType: "image/png",
content: PNG_200x150,
}));
fields.push({
name: "settings",
filename: undefined as unknown as string,
contentType: undefined as unknown as string,
content: JSON.stringify({ width: 30 }) as unknown as Buffer,
});
const res = await postBatch("resize", fields);
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(10);
}, 120_000);
it("rejects batch exceeding MAX_BATCH_SIZE (11 images, limit is 10)", async () => {
const fields = Array.from({ length: 11 }, (_, i) => ({
name: "file",
filename: `batch-over-${i}.png`,
contentType: "image/png",
content: PNG_200x150,
}));
fields.push({
name: "settings",
filename: undefined as unknown as string,
contentType: undefined as unknown as string,
content: JSON.stringify({ width: 30 }) as unknown as Buffer,
});
const res = await postBatch("resize", fields);
// @fastify/multipart enforces a files limit at the parser level (set to
// MAX_BATCH_SIZE in upload.ts), so the request fails at multipart parsing
// before the application-level batch size check runs.
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// UNICODE FILENAMES — ADDITIONAL EDGE CASES
// ═══════════════════════════════════════════════════════════════════════════
describe("Unicode filenames — specific requested patterns", () => {
it("handles filename with picture frame emoji: \u{1F5BC}.jpg", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "\u{1F5BC}.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 filename with Japanese katakana: テスト.png", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "テスト.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
expect(res.statusCode).toBe(200);
});
it("handles filename with ampersand and equals: file&name=special.jpg", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "file&name=special.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 filename with URL-encoded characters: %20photo%2F.png", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "%20photo%2F.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
expect(res.statusCode).toBe(200);
});
it("handles filename with backslashes: path\\to\\image.png", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "path\\to\\image.png",
content: PNG_200x150,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
// Backslashes should be stripped or sanitized
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.downloadUrl).not.toContain("\\");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// EXTREME DIMENSIONS — 1x1 IMAGE CROP LARGER THAN IMAGE
// ═══════════════════════════════════════════════════════════════════════════
describe("1x1 pixel image — crop larger than image", () => {
it("rejects crop of 100x100 on a 1x1 image", async () => {
const res = await postTool("crop", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 100, height: 100 }),
},
]);
// Crop region extends beyond image — should fail
expect([400, 422]).toContain(res.statusCode);
});
it("rejects crop with offset beyond 1x1 bounds", async () => {
const res = await postTool("crop", [
{
name: "file",
filename: "tiny.png",
content: PNG_1x1,
contentType: "image/png",
},
{
name: "settings",
content: JSON.stringify({ left: 5, top: 5, width: 1, height: 1 }),
},
]);
expect([400, 422]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// PIPELINE WITH LARGE FILE
// ═══════════════════════════════════════════════════════════════════════════
describe("Pipeline with large file (6.7MB)", () => {
it("processes stress-large.jpg through resize + compress pipeline", async () => {
const res = await executePipeline(STRESS_LARGE, "stress-pipeline.jpg", {
steps: [
{ toolId: "resize", settings: { width: 400 } },
{ toolId: "compress", settings: { quality: 50 } },
],
});
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.stepsCompleted).toBe(2);
expect(json.processedSize).toBeLessThan(json.originalSize);
}, 120_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// BATCH WITH LARGE FILES
// ═══════════════════════════════════════════════════════════════════════════
describe("Batch with large files", () => {
it("processes 2 large files in batch resize", async () => {
const res = await postBatch("resize", [
{
name: "file",
filename: "stress-1.jpg",
contentType: "image/jpeg",
content: STRESS_LARGE,
},
{
name: "file",
filename: "stress-2.jpg",
contentType: "image/jpeg",
content: STRESS_LARGE,
},
{ name: "settings", content: JSON.stringify({ width: 200 }) },
]);
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
}, 120_000);
});