2026-05-09 09:02:29 +08:00
|
|
|
/**
|
|
|
|
|
* Comprehensive adversarial integration tests for the SnapOtter image API.
|
|
|
|
|
*
|
|
|
|
|
* Fills gaps not covered by existing adversarial/edge-case/concurrent test files:
|
|
|
|
|
*
|
|
|
|
|
* 1. Extreme aspect ratio images (1x1000 narrow, 1000x1 wide) through resize, crop
|
|
|
|
|
* 2. Under-tested tools with adversarial inputs (strip-metadata, adjust-colors,
|
|
|
|
|
* optimize-for-web, favicon, image-to-base64, color-palette, svg-to-raster)
|
|
|
|
|
* 3. HTTP method mismatches (GET/PUT/DELETE to tool POST endpoints)
|
|
|
|
|
* 4. SVG-specific attacks through svg-to-raster (XXE, script injection)
|
|
|
|
|
* 5. Double-processing idempotency (same tool twice on same file)
|
|
|
|
|
* 6. Rapid upload-process-reupload state bleed verification
|
|
|
|
|
* 7. Batch with mixed image formats (PNG + JPEG + WebP)
|
|
|
|
|
* 8. Concurrent pipelines with varied steps
|
|
|
|
|
* 9. Extreme settings for adjust-colors (all sliders at min/max)
|
|
|
|
|
* 10. Pipeline with format conversion mid-chain affecting subsequent tools
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Fixtures
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
const FIXTURES = join(__dirname, "..", "fixtures");
|
|
|
|
|
const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png"));
|
|
|
|
|
const PNG_1x1 = readFileSync(join(FIXTURES, "test-1x1.png"));
|
|
|
|
|
const JPG_100x100 = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
|
|
|
|
const WEBP_50x50 = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
|
|
|
|
const SVG_100x100 = readFileSync(join(FIXTURES, "test-100x100.svg"));
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Shared state
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
let testApp: TestApp;
|
|
|
|
|
let app: TestApp["app"];
|
|
|
|
|
let adminToken: string;
|
|
|
|
|
|
|
|
|
|
// Generated extreme-dimension images (created once in beforeAll)
|
|
|
|
|
let narrowImage: Buffer; // 1x1000
|
|
|
|
|
let wideImage: Buffer; // 1000x1
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
testApp = await buildTestApp();
|
|
|
|
|
app = testApp.app;
|
|
|
|
|
adminToken = await loginAsAdmin(app);
|
|
|
|
|
|
|
|
|
|
// Generate extreme-dimension test images
|
|
|
|
|
narrowImage = await sharp({
|
|
|
|
|
create: {
|
|
|
|
|
width: 1,
|
|
|
|
|
height: 1000,
|
|
|
|
|
channels: 3,
|
|
|
|
|
background: { r: 128, g: 0, b: 255 },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
.png()
|
|
|
|
|
.toBuffer();
|
|
|
|
|
|
|
|
|
|
wideImage = await sharp({
|
|
|
|
|
create: {
|
|
|
|
|
width: 1000,
|
|
|
|
|
height: 1,
|
|
|
|
|
channels: 3,
|
|
|
|
|
background: { r: 255, g: 128, b: 0 },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
.png()
|
|
|
|
|
.toBuffer();
|
|
|
|
|
}, 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// EXTREME ASPECT RATIO IMAGES (1x1000 AND 1000x1)
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Extreme aspect ratio images", () => {
|
|
|
|
|
describe("Very narrow image (1x1000)", () => {
|
|
|
|
|
it("resizes a 1x1000 image by width", async () => {
|
|
|
|
|
const res = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.jobId).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("resizes a 1x1000 image by height", async () => {
|
|
|
|
|
const res = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ height: 100 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("crops a 1x1000 image to 1x10 region", async () => {
|
|
|
|
|
const res = await postTool("crop", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ left: 0, top: 0, width: 1, height: 10 }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rotates a 1x1000 image by 90 degrees", async () => {
|
|
|
|
|
const res = await postTool("rotate", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Rotation should swap dimensions to 1000x1
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("compresses a 1x1000 image", async () => {
|
|
|
|
|
const res = await postTool("compress", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("converts a 1x1000 PNG to WebP", async () => {
|
|
|
|
|
const res = await postTool("convert", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ format: "webp" }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Very wide image (1000x1)", () => {
|
|
|
|
|
it("resizes a 1000x1 image by width", async () => {
|
|
|
|
|
const res = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "wide.png",
|
|
|
|
|
content: wideImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("resizes a 1000x1 image by height", async () => {
|
|
|
|
|
const res = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "wide.png",
|
|
|
|
|
content: wideImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ height: 50 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("crops a 1000x1 image to a 10x1 region", async () => {
|
|
|
|
|
const res = await postTool("crop", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "wide.png",
|
|
|
|
|
content: wideImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ left: 0, top: 0, width: 10, height: 1 }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rotates a 1000x1 image by 90 degrees", async () => {
|
|
|
|
|
const res = await postTool("rotate", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "wide.png",
|
|
|
|
|
content: wideImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds border to a 1000x1 image", async () => {
|
|
|
|
|
const res = await postTool("border", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "wide.png",
|
|
|
|
|
content: wideImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ borderWidth: 10 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Extreme aspect ratios through pipeline", () => {
|
|
|
|
|
it("processes 1x1000 image through resize + compress pipeline", async () => {
|
|
|
|
|
const res = await executePipeline(narrowImage, "narrow-pipe.png", {
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "resize", settings: { width: 10 } },
|
|
|
|
|
{ toolId: "compress", settings: { quality: 60 } },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.stepsCompleted).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("processes 1000x1 image through rotate + border pipeline", async () => {
|
|
|
|
|
const res = await executePipeline(wideImage, "wide-pipe.png", {
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "rotate", settings: { angle: 90 } },
|
|
|
|
|
{ toolId: "border", settings: { borderWidth: 5 } },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.stepsCompleted).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// UNDER-TESTED TOOLS WITH ADVERSARIAL INPUTS
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Under-tested tools -- adversarial inputs", () => {
|
|
|
|
|
describe("strip-metadata with adversarial inputs", () => {
|
|
|
|
|
it("strips metadata from a 1x1 pixel image", async () => {
|
|
|
|
|
const res = await postTool("strip-metadata", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "tiny.png",
|
|
|
|
|
content: PNG_1x1,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ stripAll: true }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects zero-byte file through strip-metadata", async () => {
|
|
|
|
|
const res = await postTool("strip-metadata", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "empty.png",
|
|
|
|
|
content: Buffer.alloc(0),
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ stripAll: true }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles garbage data through strip-metadata gracefully", async () => {
|
|
|
|
|
const garbage = Buffer.from(
|
|
|
|
|
Array.from({ length: 2048 }, () => Math.floor(Math.random() * 256)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const res = await postTool("strip-metadata", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "garbage.jpg",
|
|
|
|
|
content: garbage,
|
|
|
|
|
contentType: "image/jpeg",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ stripAll: true }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("adjust-colors with extreme settings", () => {
|
|
|
|
|
it("applies all sliders at maximum values", async () => {
|
|
|
|
|
const res = await postTool("adjust-colors", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "test.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
brightness: 100,
|
|
|
|
|
contrast: 100,
|
|
|
|
|
exposure: 100,
|
|
|
|
|
saturation: 100,
|
|
|
|
|
temperature: 100,
|
|
|
|
|
tint: 100,
|
|
|
|
|
hue: 180,
|
|
|
|
|
sharpness: 100,
|
|
|
|
|
red: 200,
|
|
|
|
|
green: 200,
|
|
|
|
|
blue: 200,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("applies all sliders at minimum values", async () => {
|
|
|
|
|
const res = await postTool("adjust-colors", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "test.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
brightness: -100,
|
|
|
|
|
contrast: -100,
|
|
|
|
|
exposure: -100,
|
|
|
|
|
saturation: -100,
|
|
|
|
|
temperature: -100,
|
|
|
|
|
tint: -100,
|
|
|
|
|
hue: -180,
|
|
|
|
|
sharpness: 0,
|
|
|
|
|
red: 0,
|
|
|
|
|
green: 0,
|
|
|
|
|
blue: 0,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("applies grayscale effect to a 1x1 pixel image", async () => {
|
|
|
|
|
const res = await postTool("adjust-colors", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "tiny.png",
|
|
|
|
|
content: PNG_1x1,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ effect: "grayscale" }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("applies invert effect to a narrow image", async () => {
|
|
|
|
|
const res = await postTool("adjust-colors", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ effect: "invert" }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects out-of-range brightness value", async () => {
|
|
|
|
|
const res = await postTool("adjust-colors", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "test.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ brightness: 101 }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects invalid effect name", async () => {
|
|
|
|
|
const res = await postTool("adjust-colors", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "test.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ effect: "hyperbolic" }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("image-to-base64 with adversarial inputs", () => {
|
|
|
|
|
it("converts a valid PNG to base64", async () => {
|
|
|
|
|
const res = await postTool("image-to-base64", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "test.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({}) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
// Should contain base64 data
|
|
|
|
|
expect(json.results || json.base64 || json.dataUri).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("converts a 1x1 pixel image to base64", async () => {
|
|
|
|
|
const res = await postTool("image-to-base64", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "tiny.png",
|
|
|
|
|
content: PNG_1x1,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({}) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("color-palette with adversarial inputs", () => {
|
|
|
|
|
it("extracts palette from a 1x1 pixel image", async () => {
|
|
|
|
|
const res = await postTool("color-palette", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "tiny.png",
|
|
|
|
|
content: PNG_1x1,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// 1x1 image has exactly one color -- should work
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("extracts palette from an extreme narrow image", async () => {
|
|
|
|
|
const res = await postTool("color-palette", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects garbage data through color-palette", async () => {
|
|
|
|
|
const garbage = Buffer.from("not an image");
|
|
|
|
|
|
|
|
|
|
const res = await postTool("color-palette", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "garbage.png",
|
|
|
|
|
content: garbage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("favicon with adversarial inputs", () => {
|
|
|
|
|
it("generates favicons from a 1x1 pixel image", async () => {
|
|
|
|
|
const res = await postTool("favicon", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "tiny.png",
|
|
|
|
|
content: PNG_1x1,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({}) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Should upscale the 1x1 image to all favicon sizes
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("generates favicons from an extreme narrow image", async () => {
|
|
|
|
|
const res = await postTool("favicon", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({}) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Should handle non-square image
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// HTTP METHOD MISMATCHES
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("HTTP method mismatches on tool endpoints", () => {
|
|
|
|
|
it("returns 404 for GET request to tool endpoint", async () => {
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: "/api/v1/tools/resize",
|
|
|
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns 404 for PUT request to tool endpoint", async () => {
|
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "test.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: "PUT",
|
|
|
|
|
url: "/api/v1/tools/resize",
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": contentType,
|
|
|
|
|
authorization: `Bearer ${adminToken}`,
|
|
|
|
|
},
|
|
|
|
|
body,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns 404 for DELETE request to tool endpoint", async () => {
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
url: "/api/v1/tools/resize",
|
|
|
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns 404 for PATCH request to tool endpoint", async () => {
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
url: "/api/v1/tools/resize",
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
authorization: `Bearer ${adminToken}`,
|
|
|
|
|
},
|
|
|
|
|
payload: { width: 100 },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// SVG-SPECIFIC ATTACKS THROUGH svg-to-raster
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("SVG-specific attacks through svg-to-raster", () => {
|
|
|
|
|
it("sanitizes SVG with embedded script tag", async () => {
|
|
|
|
|
const maliciousSvg = Buffer.from(
|
|
|
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">' +
|
|
|
|
|
'<script>alert("xss")</script>' +
|
|
|
|
|
'<rect width="100" height="100" fill="red"/>' +
|
|
|
|
|
"</svg>",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const res = await postTool("svg-to-raster", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "xss.svg",
|
|
|
|
|
content: maliciousSvg,
|
|
|
|
|
contentType: "image/svg+xml",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ outputFormat: "png" }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Should either strip the script and succeed, or reject
|
|
|
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sanitizes SVG with onload event handler", async () => {
|
|
|
|
|
const maliciousSvg = Buffer.from(
|
|
|
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" onload="alert(1)">' +
|
|
|
|
|
'<rect width="100" height="100" fill="blue"/>' +
|
|
|
|
|
"</svg>",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const res = await postTool("svg-to-raster", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "onload.svg",
|
|
|
|
|
content: maliciousSvg,
|
|
|
|
|
contentType: "image/svg+xml",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ outputFormat: "png" }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles SVG with external image reference (SSRF attempt)", async () => {
|
|
|
|
|
const ssrfSvg = Buffer.from(
|
|
|
|
|
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">' +
|
|
|
|
|
'<image xlink:href="http://169.254.169.254/latest/meta-data/" width="100" height="100"/>' +
|
|
|
|
|
"</svg>",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const res = await postTool("svg-to-raster", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "ssrf.svg",
|
|
|
|
|
content: ssrfSvg,
|
|
|
|
|
contentType: "image/svg+xml",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ outputFormat: "png" }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Should sanitize external references or reject
|
|
|
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles SVG with extremely large dimensions attribute", async () => {
|
|
|
|
|
const hugeSvg = Buffer.from(
|
|
|
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="999999" height="999999">' +
|
|
|
|
|
'<rect width="100" height="100" fill="green"/>' +
|
|
|
|
|
"</svg>",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const res = await postTool("svg-to-raster", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "huge-dims.svg",
|
|
|
|
|
content: hugeSvg,
|
|
|
|
|
contentType: "image/svg+xml",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
outputFormat: "png",
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 100,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Must not crash. Either succeeds with constrained output or returns error.
|
|
|
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles an empty SVG document", async () => {
|
|
|
|
|
const emptySvg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
|
|
|
|
|
|
|
|
|
const res = await postTool("svg-to-raster", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "empty.svg",
|
|
|
|
|
content: emptySvg,
|
|
|
|
|
contentType: "image/svg+xml",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ outputFormat: "png" }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Empty SVG has no viewBox or dimensions -- may fail or produce empty output
|
|
|
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("converts a valid SVG to raster", async () => {
|
|
|
|
|
const res = await postTool("svg-to-raster", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "test.svg",
|
|
|
|
|
content: SVG_100x100,
|
|
|
|
|
contentType: "image/svg+xml",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ outputFormat: "png" }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// DOUBLE-PROCESSING IDEMPOTENCY
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Double-processing idempotency", () => {
|
|
|
|
|
it("resizes same image twice sequentially -- produces consistent results", async () => {
|
|
|
|
|
const first = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "double-1.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 80 }) },
|
|
|
|
|
]);
|
|
|
|
|
const second = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "double-2.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 80 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(first.statusCode).toBe(200);
|
|
|
|
|
expect(second.statusCode).toBe(200);
|
|
|
|
|
|
|
|
|
|
const firstBody = JSON.parse(first.body);
|
|
|
|
|
const secondBody = JSON.parse(second.body);
|
|
|
|
|
|
|
|
|
|
// Same input + same settings should produce same processed size
|
|
|
|
|
expect(firstBody.processedSize).toBe(secondBody.processedSize);
|
|
|
|
|
// But different job IDs
|
|
|
|
|
expect(firstBody.jobId).not.toBe(secondBody.jobId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("compresses same image twice -- same output size", async () => {
|
|
|
|
|
const first = await postTool("compress", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "comp-1.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
|
|
|
]);
|
|
|
|
|
const second = await postTool("compress", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "comp-2.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(first.statusCode).toBe(200);
|
|
|
|
|
expect(second.statusCode).toBe(200);
|
|
|
|
|
|
|
|
|
|
const firstBody = JSON.parse(first.body);
|
|
|
|
|
const secondBody = JSON.parse(second.body);
|
|
|
|
|
expect(firstBody.processedSize).toBe(secondBody.processedSize);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("strips metadata twice from same image -- second is no-op", async () => {
|
|
|
|
|
const first = await postTool("strip-metadata", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "strip-1.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ stripAll: true }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(first.statusCode).toBe(200);
|
|
|
|
|
const firstBody = JSON.parse(first.body);
|
|
|
|
|
|
|
|
|
|
// Second strip on the same image
|
|
|
|
|
const second = await postTool("strip-metadata", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "strip-2.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ stripAll: true }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(second.statusCode).toBe(200);
|
|
|
|
|
const secondBody = JSON.parse(second.body);
|
|
|
|
|
|
|
|
|
|
// Both should produce same size (PNG input has minimal metadata)
|
|
|
|
|
expect(firstBody.processedSize).toBe(secondBody.processedSize);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// RAPID UPLOAD-PROCESS-REUPLOAD STATE BLEED VERIFICATION
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Rapid upload-process-reupload -- no state bleed", () => {
|
|
|
|
|
it("processes PNG then JPEG on resize endpoint -- sizes differ correctly", async () => {
|
|
|
|
|
const pngRes = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "first.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(pngRes.statusCode).toBe(200);
|
|
|
|
|
const pngBody = JSON.parse(pngRes.body);
|
|
|
|
|
|
|
|
|
|
const jpgRes = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "second.jpg",
|
|
|
|
|
content: JPG_100x100,
|
|
|
|
|
contentType: "image/jpeg",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(jpgRes.statusCode).toBe(200);
|
|
|
|
|
const jpgBody = JSON.parse(jpgRes.body);
|
|
|
|
|
|
|
|
|
|
// Original sizes must reflect the actual inputs (not the previous request)
|
|
|
|
|
expect(pngBody.originalSize).toBe(PNG_200x150.length);
|
|
|
|
|
expect(jpgBody.originalSize).toBe(JPG_100x100.length);
|
|
|
|
|
// They should be different images
|
|
|
|
|
expect(pngBody.downloadUrl).not.toBe(jpgBody.downloadUrl);
|
|
|
|
|
expect(pngBody.jobId).not.toBe(jpgBody.jobId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("processes different widths rapidly -- output sizes differ", async () => {
|
|
|
|
|
const res50 = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "w50.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
|
|
|
]);
|
|
|
|
|
const res150 = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "w150.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 150 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res50.statusCode).toBe(200);
|
|
|
|
|
expect(res150.statusCode).toBe(200);
|
|
|
|
|
|
|
|
|
|
const body50 = JSON.parse(res50.body);
|
|
|
|
|
const body150 = JSON.parse(res150.body);
|
|
|
|
|
|
|
|
|
|
// Width 50 output should be smaller than width 150 output
|
|
|
|
|
expect(body50.processedSize).toBeLessThan(body150.processedSize);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("alternates between resize and compress with no state leak", async () => {
|
|
|
|
|
const resize1 = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "r1.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 80 }) },
|
|
|
|
|
]);
|
|
|
|
|
const compress1 = await postTool("compress", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "c1.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ quality: 30 }) },
|
|
|
|
|
]);
|
|
|
|
|
const resize2 = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "r2.png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 80 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(resize1.statusCode).toBe(200);
|
|
|
|
|
expect(compress1.statusCode).toBe(200);
|
|
|
|
|
expect(resize2.statusCode).toBe(200);
|
|
|
|
|
|
|
|
|
|
// Both resize operations with same input/settings should produce same size
|
|
|
|
|
const r1Body = JSON.parse(resize1.body);
|
|
|
|
|
const r2Body = JSON.parse(resize2.body);
|
|
|
|
|
expect(r1Body.processedSize).toBe(r2Body.processedSize);
|
|
|
|
|
|
|
|
|
|
// Compress should have a different URL path (different tool)
|
|
|
|
|
const c1Body = JSON.parse(compress1.body);
|
|
|
|
|
expect(c1Body.downloadUrl).toContain("compress");
|
|
|
|
|
expect(r1Body.downloadUrl).toContain("resize");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// BATCH WITH MIXED IMAGE FORMATS
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Batch with mixed image formats (PNG + JPEG + WebP)", () => {
|
|
|
|
|
it("handles batch resize with PNG, JPEG, and WebP files", async () => {
|
|
|
|
|
const res = await postBatch("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "photo.png",
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "photo.jpg",
|
|
|
|
|
contentType: "image/jpeg",
|
|
|
|
|
content: JPG_100x100,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "photo.webp",
|
|
|
|
|
contentType: "image/webp",
|
|
|
|
|
content: WEBP_50x50,
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ width: 30 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.headers["content-type"]).toBe("application/zip");
|
2026-05-12 23:19:20 +08:00
|
|
|
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
|
2026-05-09 09:02:29 +08:00
|
|
|
expect(Object.keys(fileResults).length).toBe(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles batch compress with mixed formats", async () => {
|
|
|
|
|
const res = await postBatch("compress", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "a.png",
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "b.jpg",
|
|
|
|
|
contentType: "image/jpeg",
|
|
|
|
|
content: JPG_100x100,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "c.webp",
|
|
|
|
|
contentType: "image/webp",
|
|
|
|
|
content: WEBP_50x50,
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.headers["content-type"]).toBe("application/zip");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles batch rotate with mixed formats", async () => {
|
|
|
|
|
const res = await postBatch("rotate", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "rotate-a.png",
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "rotate-b.jpg",
|
|
|
|
|
contentType: "image/jpeg",
|
|
|
|
|
content: JPG_100x100,
|
|
|
|
|
},
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.headers["content-type"]).toBe("application/zip");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// CONCURRENT PIPELINES WITH VARIED STEPS
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Concurrent pipelines with varied steps", () => {
|
|
|
|
|
it("runs 5 concurrent pipelines with different tool chains", async () => {
|
|
|
|
|
const pipelines = [
|
|
|
|
|
{
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "resize", settings: { width: 80 } },
|
|
|
|
|
{ toolId: "compress", settings: { quality: 50 } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "rotate", settings: { angle: 90 } },
|
|
|
|
|
{ toolId: "border", settings: { borderWidth: 5 } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "convert", settings: { format: "webp" } },
|
|
|
|
|
{ toolId: "resize", settings: { width: 60 } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "resize", settings: { width: 100 } },
|
|
|
|
|
{ toolId: "rotate", settings: { angle: 180 } },
|
|
|
|
|
{ toolId: "compress", settings: { quality: 70 } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "border", settings: { borderWidth: 10 } },
|
|
|
|
|
{ toolId: "compress", settings: { quality: 40 } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const results = await Promise.all(
|
|
|
|
|
pipelines.map((pipeline, i) => {
|
|
|
|
|
const payload = createMultipartPayload([
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: `concurrent-pipe-${i}.png`,
|
|
|
|
|
content: PNG_200x150,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{ name: "pipeline", content: JSON.stringify(pipeline) },
|
|
|
|
|
]);
|
|
|
|
|
return app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/api/v1/pipeline/execute",
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": payload.contentType,
|
|
|
|
|
authorization: `Bearer ${adminToken}`,
|
|
|
|
|
},
|
|
|
|
|
body: payload.body,
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// All 5 must succeed
|
|
|
|
|
for (let i = 0; i < results.length; i++) {
|
|
|
|
|
expect(results[i].statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(results[i].body);
|
|
|
|
|
expect(json.stepsCompleted).toBe(pipelines[i].steps.length);
|
|
|
|
|
expect(json.jobId).toBeDefined();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// All job IDs must be unique
|
|
|
|
|
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
|
|
|
|
|
expect(new Set(jobIds).size).toBe(5);
|
|
|
|
|
}, 120_000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// PIPELINE WITH FORMAT CONVERSION MID-CHAIN
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Pipeline with format conversion mid-chain", () => {
|
|
|
|
|
it("converts PNG to WebP mid-chain then resizes -- format is carried", async () => {
|
|
|
|
|
const res = await executePipeline(PNG_200x150, "mid-convert.png", {
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "convert", settings: { format: "webp" } },
|
|
|
|
|
{ toolId: "resize", settings: { width: 50 } },
|
|
|
|
|
{ toolId: "compress", settings: { quality: 60 } },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.stepsCompleted).toBe(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("converts PNG to JPEG to WebP -- double format change", async () => {
|
|
|
|
|
const res = await executePipeline(PNG_200x150, "double-convert.png", {
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "convert", settings: { format: "jpg" } },
|
|
|
|
|
{ toolId: "resize", settings: { width: 80 } },
|
|
|
|
|
{ toolId: "convert", settings: { format: "webp" } },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.stepsCompleted).toBe(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("converts to AVIF then adds border -- AVIF intermediate handling", async () => {
|
|
|
|
|
const res = await executePipeline(PNG_200x150, "avif-border.png", {
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "convert", settings: { format: "avif" } },
|
|
|
|
|
{ toolId: "border", settings: { borderWidth: 10 } },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.stepsCompleted).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// BODY CONTENT-TYPE MISMATCHES (NON-MULTIPART)
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Non-multipart content types to tool endpoint", () => {
|
|
|
|
|
it("rejects JSON body sent to tool endpoint", async () => {
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/api/v1/tools/resize",
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
authorization: `Bearer ${adminToken}`,
|
|
|
|
|
},
|
|
|
|
|
payload: { width: 100 },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Fastify/multipart plugin should reject non-multipart
|
|
|
|
|
expect([400, 415]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects plain text body sent to tool endpoint", async () => {
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/api/v1/tools/resize",
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "text/plain",
|
|
|
|
|
authorization: `Bearer ${adminToken}`,
|
|
|
|
|
},
|
|
|
|
|
payload: "width=100",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect([400, 415]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// TOOL-SPECIFIC EDGE: INFO WITH EXTREME IMAGES
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Info tool with extreme images", () => {
|
|
|
|
|
it("returns info for a 1x1000 narrow image", async () => {
|
|
|
|
|
const res = await postTool("info", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.width).toBe(1);
|
|
|
|
|
expect(json.height).toBe(1000);
|
|
|
|
|
expect(json.format).toBe("png");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns info for a 1000x1 wide image", async () => {
|
|
|
|
|
const res = await postTool("info", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "wide.png",
|
|
|
|
|
content: wideImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.width).toBe(1000);
|
|
|
|
|
expect(json.height).toBe(1);
|
|
|
|
|
expect(json.format).toBe("png");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns info for a WebP image", async () => {
|
|
|
|
|
const res = await postTool("info", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "test.webp",
|
|
|
|
|
content: WEBP_50x50,
|
|
|
|
|
contentType: "image/webp",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.width).toBe(50);
|
|
|
|
|
expect(json.height).toBe(50);
|
|
|
|
|
expect(json.format).toBe("webp");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// CONCURRENT MIXED: ADVERSARIAL + EXTREME ASPECT + VALID
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Concurrent adversarial + extreme aspect ratio + valid requests", () => {
|
|
|
|
|
it("fires narrow image, wide image, garbage, and valid simultaneously", async () => {
|
|
|
|
|
const garbage = Buffer.from(Array.from({ length: 512 }, () => Math.floor(Math.random() * 256)));
|
|
|
|
|
|
|
|
|
|
const [narrowRes, wideRes, garbageRes, validRes] = await Promise.all([
|
|
|
|
|
app.inject(buildToolRequest("resize", narrowImage, "narrow.png", { width: 10 })),
|
|
|
|
|
app.inject(buildToolRequest("resize", wideImage, "wide.png", { width: 100 })),
|
|
|
|
|
app.inject(buildToolRequest("resize", garbage, "garbage.png", { width: 50 })),
|
|
|
|
|
app.inject(buildToolRequest("resize", PNG_200x150, "valid.png", { width: 80 })),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Valid and extreme-aspect images should succeed
|
|
|
|
|
expect(narrowRes.statusCode).toBe(200);
|
|
|
|
|
expect(wideRes.statusCode).toBe(200);
|
|
|
|
|
expect(validRes.statusCode).toBe(200);
|
|
|
|
|
|
|
|
|
|
// Garbage should fail
|
|
|
|
|
expect([400, 422]).toContain(garbageRes.statusCode);
|
|
|
|
|
|
|
|
|
|
// All successful requests must have unique job IDs
|
|
|
|
|
const validJobs = [narrowRes, wideRes, validRes].map((r) => JSON.parse(r.body).jobId);
|
|
|
|
|
expect(new Set(validJobs).size).toBe(3);
|
|
|
|
|
}, 60_000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// EXTREME CROP REGIONS ON EXTREME IMAGES
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Extreme crop regions on extreme images", () => {
|
|
|
|
|
it("rejects crop wider than a narrow image", async () => {
|
|
|
|
|
const res = await postTool("crop", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "narrow.png",
|
|
|
|
|
content: narrowImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ left: 0, top: 0, width: 100, height: 100 }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// 1x1000 image -- width 100 exceeds image width of 1
|
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects crop taller than a wide image", async () => {
|
|
|
|
|
const res = await postTool("crop", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "wide.png",
|
|
|
|
|
content: wideImage,
|
|
|
|
|
contentType: "image/png",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "settings",
|
|
|
|
|
content: JSON.stringify({ left: 0, top: 0, width: 100, height: 100 }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// 1000x1 image -- height 100 exceeds image height of 1
|
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// PIPELINE WITH EXTREME ASPECT RATIO + FORMAT CONVERSION
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Pipeline with extreme aspect ratio and format conversion", () => {
|
|
|
|
|
it("narrow image: convert to JPEG then resize then compress", async () => {
|
|
|
|
|
const res = await executePipeline(narrowImage, "narrow-chain.png", {
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "convert", settings: { format: "jpg" } },
|
|
|
|
|
{ toolId: "resize", settings: { height: 100 } },
|
|
|
|
|
{ toolId: "compress", settings: { quality: 50 } },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.stepsCompleted).toBe(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("wide image: resize then convert to WebP", async () => {
|
|
|
|
|
const res = await executePipeline(wideImage, "wide-to-webp.png", {
|
|
|
|
|
steps: [
|
|
|
|
|
{ toolId: "resize", settings: { width: 200 } },
|
|
|
|
|
{ toolId: "convert", settings: { format: "webp" } },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const json = JSON.parse(res.body);
|
|
|
|
|
expect(json.stepsCompleted).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
// SERVER STABILITY AFTER ALL TESTS
|
|
|
|
|
// ###########################################################################
|
|
|
|
|
describe("Server stability -- health check after comprehensive adversarial tests", () => {
|
|
|
|
|
it("server remains responsive after entire test suite", async () => {
|
|
|
|
|
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");
|
|
|
|
|
|
|
|
|
|
// Verify a normal request still works
|
|
|
|
|
const normalRes = await postTool("resize", [
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
filename: "final-sanity.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();
|
|
|
|
|
});
|
|
|
|
|
});
|