Files
SnapOtter/tests/integration/generated/format-matrix-ai.test.ts
T
SnapOtter 5ffa1d55ea Merge branch 'worktree-test+suite-overhaul-and-real-fixtures' into chore/consolidate-v2.0.0
# Conflicts:
#	tests/integration/generated/settings-matrix.test.ts
#	tests/integration/platform/api.test.ts
#	tests/integration/platform/concurrent.test.ts
#	tests/integration/platform/factory-multi-input.test.ts
#	tests/integration/security/adversarial-comprehensive.test.ts
#	tests/integration/security/adversarial-coverage-gaps.test.ts
#	tests/integration/security/adversarial-extended.test.ts
#	tests/integration/security/adversarial-final-gaps.test.ts
#	tests/integration/security/adversarial-matrix.test.ts
#	tests/integration/security/adversarial-security.test.ts
#	tests/integration/security/adversarial.test.ts
#	tests/integration/tools/image/color-adjustments.test.ts
2026-06-21 02:18:53 +08:00

780 lines
22 KiB
TypeScript

/**
* Cross-format matrix integration test for AI tools.
*
* Tests 13 AI tools x 17 input formats to verify:
* - No tool returns 500 (server crash) for any format
* - Validation layer works: missing file -> 400, bad settings -> 400
* - Auth enforcement: unauthenticated -> 401
* - Format acceptance/rejection is graceful (200, 202, 400, 422, or 501)
*
* The Python AI sidecar is NOT running during integration tests.
* AI tools will return 501 (FEATURE_NOT_INSTALLED) or 202 (accepted for
* async processing). This is expected -- we are testing the validation
* layer, NOT that AI processing succeeds.
*
* Some tools (smart-crop, content-aware-resize) use the factory pattern
* or process synchronously and may return 200 or 422 depending on whether
* the Go/Python binary is available.
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { apiToolPath } from "@snapotter/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtureDir, fixtures } from "../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../test-server.js";
// ---------------------------------------------------------------------------
// Format sample definitions (matches format-matrix.test.ts)
// ---------------------------------------------------------------------------
interface FormatSample {
name: string;
file: string;
mime: string;
needsCliDecoder: boolean;
needsHeifDecoder: boolean;
mayFailValidation: boolean;
}
const FORMAT_SAMPLES: FormatSample[] = [
{
name: "JPEG",
file: "sample.jpg",
mime: "image/jpeg",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "PNG",
file: "sample.png",
mime: "image/png",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "WebP",
file: "sample.webp",
mime: "image/webp",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "GIF",
file: "sample.gif",
mime: "image/gif",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "AVIF",
file: "sample.avif",
mime: "image/avif",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "TIFF",
file: "sample.tiff",
mime: "image/tiff",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "BMP",
file: "sample.bmp",
mime: "image/bmp",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: true,
},
{
name: "HEIC",
file: "sample.heic",
mime: "image/heic",
needsCliDecoder: false,
needsHeifDecoder: true,
mayFailValidation: false,
},
{
name: "HEIF",
file: "sample.heif",
mime: "image/heif",
needsCliDecoder: false,
needsHeifDecoder: true,
mayFailValidation: false,
},
{
name: "SVG",
file: "sample.svg",
mime: "image/svg+xml",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "ICO",
file: "sample.ico",
mime: "image/x-icon",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "PSD",
file: "sample.psd",
mime: "image/vnd.adobe.photoshop",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "EXR",
file: "sample.exr",
mime: "image/x-exr",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "HDR",
file: "sample.hdr",
mime: "image/vnd.radiance",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "TGA",
file: "sample.tga",
mime: "image/x-tga",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "DNG",
file: "sample.dng",
mime: "image/x-adobe-dng",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "JXL",
file: "sample.jxl",
mime: "image/jxl",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: true,
},
];
// ---------------------------------------------------------------------------
// AI tool definitions
// ---------------------------------------------------------------------------
interface AiToolDef {
/** Tool route name (maps to /api/v1/tools/<id>) */
id: string;
/** Display name for test output */
label: string;
/** Settings JSON sent as the "settings" multipart field */
settings: Record<string, unknown>;
/**
* Whether this tool uses the 501 FEATURE_NOT_INSTALLED guard.
* Tools using createToolRoute factory (smart-crop) do not have it.
*/
has501Guard: boolean;
/**
* Whether this tool requires a mask file (e.g. erase-object).
*/
requiresMask: boolean;
/**
* A settings payload that should trigger Zod validation failure.
* Used for the "invalid settings -> 400" tests.
*/
invalidSettings: Record<string, unknown>;
}
const AI_TOOLS: AiToolDef[] = [
{
id: "remove-background",
label: "Remove Background",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { blurIntensity: 999 },
},
{
id: "upscale",
label: "Upscale",
settings: { scale: 2 },
has501Guard: true,
requiresMask: false,
invalidSettings: { scale: "not-a-number", format: 12345 },
},
{
id: "ocr",
label: "OCR",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { quality: "invalid-enum", language: "xx" },
},
{
id: "blur-faces",
label: "Blur Faces",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { blurRadius: 999 },
},
{
id: "enhance-faces",
label: "Enhance Faces",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { model: "nonexistent-model" },
},
{
id: "smart-crop",
label: "Smart Crop",
settings: { mode: "subject", width: 100, height: 100 },
has501Guard: false,
requiresMask: false,
invalidSettings: { mode: "invalid-mode-xyz" },
},
{
id: "colorize",
label: "Colorize",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { model: "nonexistent-model", intensity: 999 },
},
{
id: "noise-removal",
label: "Noise Removal",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { tier: "nonexistent-tier" },
},
{
id: "red-eye-removal",
label: "Red Eye Removal",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { sensitivity: -5 },
},
{
id: "restore-photo",
label: "Restore Photo",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { mode: "nonexistent-mode" },
},
{
id: "passport-photo",
label: "Passport Photo (Analyze)",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: {},
},
{
id: "erase-object",
label: "Erase Object",
settings: {},
has501Guard: true,
requiresMask: true,
invalidSettings: {},
},
{
id: "content-aware-resize",
label: "Content-Aware Resize",
settings: { width: 50, square: false },
has501Guard: false,
requiresMask: false,
invalidSettings: { blurRadius: 999 },
},
{
id: "ai-canvas-expand",
label: "AI Canvas Expand",
settings: { extendTop: 50, extendRight: 0, extendBottom: 50, extendLeft: 0 },
has501Guard: true,
requiresMask: false,
invalidSettings: { extendTop: -1 },
},
];
// ---------------------------------------------------------------------------
// Valid status codes for AI tool responses during testing
// (no AI sidecar running, so processing won't complete)
// ---------------------------------------------------------------------------
/**
* Acceptable status codes from AI tools in test:
* - 200: synchronous success (e.g. smart-crop with subject mode)
* - 202: accepted for async processing (tools that reply early)
* - 400: format/validation rejected
* - 422: processing error (format decode failure, etc.)
* - 501: AI feature not installed (FEATURE_NOT_INSTALLED)
*/
const ACCEPTABLE_AI_CODES = [200, 202, 400, 422, 501];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Get the route URL for a given AI tool.
* passport-photo uses /analyze endpoint.
*/
function getToolUrl(toolId: string): string {
if (toolId === "passport-photo") {
return `${apiToolPath("passport-photo")}/analyze`;
}
return apiToolPath(toolId);
}
/**
* Build multipart payload for an AI tool request.
* Handles the mask requirement for erase-object.
*/
function buildAiPayload(
fmt: FormatSample,
tool: AiToolDef,
buffer: Buffer,
maskBuffer?: Buffer,
): { body: Buffer; contentType: string } {
const fields: Array<{
name: string;
filename?: string;
contentType?: string;
content: Buffer | string;
}> = [
{
name: "file",
filename: fmt.file,
contentType: fmt.mime,
content: buffer,
},
];
// erase-object needs a mask file
if (tool.requiresMask && maskBuffer) {
fields.push({
name: "mask",
filename: "mask.png",
contentType: "image/png",
content: maskBuffer,
});
}
// Add settings if non-empty
if (Object.keys(tool.settings).length > 0) {
fields.push({
name: "settings",
content: JSON.stringify(tool.settings),
});
}
return createMultipartPayload(fields);
}
// ---------------------------------------------------------------------------
// Shared state
// ---------------------------------------------------------------------------
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
/** Tiny PNG buffer used as mask for erase-object tests */
let maskBuffer: Buffer;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
// Use sample.png as the mask for erase-object
const maskPath = fixtures.image.formats("png");
maskBuffer = readFileSync(maskPath);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
// ---------------------------------------------------------------------------
// Cross-format matrix: every AI tool x every format
// ---------------------------------------------------------------------------
describe("AI tool cross-format matrix", () => {
for (const fmt of FORMAT_SAMPLES) {
describe(`${fmt.name} input (${fmt.file})`, () => {
const fixturePath = join(fixtureDir.formats, fmt.file);
for (const tool of AI_TOOLS) {
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
it(
`${tool.label}`,
async () => {
if (!existsSync(fixturePath)) return;
const buffer = readFileSync(fixturePath);
const { body: payload, contentType } = buildAiPayload(
fmt,
tool,
buffer,
tool.requiresMask ? maskBuffer : undefined,
);
const res = await app.inject({
method: "POST",
url: getToolUrl(tool.id),
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
// Must NEVER return 500 (server crash)
expect(res.statusCode, `${tool.label} + ${fmt.name}: got 500 server error`).not.toBe(
500,
);
// Must be one of the acceptable codes
expect(ACCEPTABLE_AI_CODES).toContain(res.statusCode);
// Response must always be valid JSON
const body = JSON.parse(res.body);
if (res.statusCode === 501) {
// Verify FEATURE_NOT_INSTALLED shape
expect(body.code).toBe("FEATURE_NOT_INSTALLED");
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
} else if (res.statusCode === 202) {
// Async processing accepted
expect(body.jobId).toBeDefined();
expect(typeof body.jobId).toBe("string");
expect(body.async).toBe(true);
} else if (res.statusCode === 200) {
// Synchronous success -- at minimum, response is valid JSON
expect(typeof body).toBe("object");
} else {
// 400 or 422 error -- verify clean error shape
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
expect(body.error.length).toBeGreaterThan(0);
}
},
perTestTimeout,
);
}
});
}
});
// ---------------------------------------------------------------------------
// Missing file -> 400 for every AI tool
// ---------------------------------------------------------------------------
describe("Missing file returns 400", () => {
for (const tool of AI_TOOLS) {
it(`${tool.label}: no file -> 400`, async () => {
// Send empty multipart with only settings
const fields: Array<{
name: string;
filename?: string;
contentType?: string;
content: Buffer | string;
}> = [];
if (Object.keys(tool.settings).length > 0) {
fields.push({
name: "settings",
content: JSON.stringify(tool.settings),
});
}
// Add at least one field so the multipart is valid
if (fields.length === 0) {
fields.push({
name: "settings",
content: JSON.stringify({}),
});
}
const { body: payload, contentType } = createMultipartPayload(fields);
const res = await app.inject({
method: "POST",
url: getToolUrl(tool.id),
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
// Tools with 501 guard may return 501 before checking for file
if (tool.has501Guard) {
expect([400, 501]).toContain(res.statusCode);
} else {
expect(res.statusCode).toBe(400);
}
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
});
}
});
// ---------------------------------------------------------------------------
// Unauthenticated -> 401 for every AI tool
// ---------------------------------------------------------------------------
describe("Unauthenticated requests return 401", () => {
for (const tool of AI_TOOLS) {
it(`${tool.label}: no auth -> 401`, async () => {
const fixturePath = fixtures.image.formats("png");
if (!existsSync(fixturePath)) return;
const buffer = readFileSync(fixturePath);
const fmt = FORMAT_SAMPLES.find((f) => f.file === "sample.png")!;
const { body: payload, contentType } = buildAiPayload(
fmt,
tool,
buffer,
tool.requiresMask ? maskBuffer : undefined,
);
const res = await app.inject({
method: "POST",
url: getToolUrl(tool.id),
headers: {
// No authorization header
"content-type": contentType,
},
body: payload,
});
expect(res.statusCode).toBe(401);
});
}
});
// ---------------------------------------------------------------------------
// Invalid settings -> 400 for tools with settings validation
// ---------------------------------------------------------------------------
describe("Invalid settings return 400", () => {
// Only test tools where we have specifically invalid settings that
// should trigger Zod validation errors
const TOOLS_WITH_INVALID_SETTINGS = AI_TOOLS.filter(
(t) => Object.keys(t.invalidSettings).length > 0,
);
for (const tool of TOOLS_WITH_INVALID_SETTINGS) {
it(`${tool.label}: invalid settings -> 400`, async () => {
const fixturePath = fixtures.image.formats("png");
if (!existsSync(fixturePath)) return;
const buffer = readFileSync(fixturePath);
const fields: Array<{
name: string;
filename?: string;
contentType?: string;
content: Buffer | string;
}> = [
{
name: "file",
filename: "sample.png",
contentType: "image/png",
content: buffer,
},
{
name: "settings",
content: JSON.stringify(tool.invalidSettings),
},
];
// erase-object needs a mask
if (tool.requiresMask) {
fields.push({
name: "mask",
filename: "mask.png",
contentType: "image/png",
content: maskBuffer,
});
}
const { body: payload, contentType } = createMultipartPayload(fields);
const res = await app.inject({
method: "POST",
url: getToolUrl(tool.id),
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
// Tools with 501 guard may return 501 before validating settings
if (tool.has501Guard) {
expect([400, 501]).toContain(res.statusCode);
} else {
expect(res.statusCode).toBe(400);
}
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
});
}
});
// ---------------------------------------------------------------------------
// Erase-object specific: missing mask -> 400
// ---------------------------------------------------------------------------
describe("Erase-object missing mask returns 400", () => {
it("erase-object without mask file -> 400 or 501", async () => {
const fixturePath = fixtures.image.formats("png");
if (!existsSync(fixturePath)) return;
const buffer = readFileSync(fixturePath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: "sample.png",
contentType: "image/png",
content: buffer,
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/erase-object",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
// 501 if AI not installed, 400 if validation catches missing mask
expect([400, 501]).toContain(res.statusCode);
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
});
});
// ---------------------------------------------------------------------------
// Content-aware-resize specific: missing dimensions -> 400
// ---------------------------------------------------------------------------
describe("Content-aware-resize without dimensions returns 400", () => {
it("content-aware-resize without width/height/square -> 400", async () => {
const fixturePath = fixtures.image.formats("png");
if (!existsSync(fixturePath)) return;
const buffer = readFileSync(fixturePath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: "sample.png",
contentType: "image/png",
content: buffer,
},
{
name: "settings",
content: JSON.stringify({}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/content-aware-resize",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
});
});
// ---------------------------------------------------------------------------
// Malformed JSON settings -> 400 for AI tools
// ---------------------------------------------------------------------------
describe("Malformed JSON settings return 400", () => {
// Test a representative subset of tools (one per pattern)
const REPRESENTATIVE_TOOLS = AI_TOOLS.filter(
(t) => !t.requiresMask && t.id !== "passport-photo",
).slice(0, 5);
for (const tool of REPRESENTATIVE_TOOLS) {
it(`${tool.label}: malformed JSON settings -> 400 or 501`, async () => {
const fixturePath = fixtures.image.formats("png");
if (!existsSync(fixturePath)) return;
const buffer = readFileSync(fixturePath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: "sample.png",
contentType: "image/png",
content: buffer,
},
{
name: "settings",
content: "{ this is not valid json }}}",
},
]);
const res = await app.inject({
method: "POST",
url: getToolUrl(tool.id),
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
// 501 if AI not installed, 400 for malformed JSON
if (tool.has501Guard) {
expect([400, 501]).toContain(res.statusCode);
} else {
expect(res.statusCode).toBe(400);
}
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
});
}
});