Files
SnapOtter/tests/integration/hostile-inputs.test.ts
T

106 lines
4.1 KiB
TypeScript

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 { TOOL_DISPLAY_MODES } from "../../apps/web/src/lib/tool-display-modes.js";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
/**
* Hostile-input matrix: every tool route must reject malformed, truncated,
* lying, or bomb-shaped files with a clean 4xx (or 501 for uninstalled AI
* bundles). A 500, a hang, or a success response for garbage is a bug in the
* tool, not in this test.
*
* Fixtures come from scripts/generate-hostile-fixtures.mjs (committed).
*/
const HOSTILE_DIR = join(__dirname, "..", "fixtures", "hostile");
/** Fixtures that are unreadable garbage: the server must NOT report success. */
const GARBAGE_FIXTURES = ["truncated.jpg", "zero-byte.png", "garbage.jpg", "bomb-50000x50000.png"];
/** Valid PNG bytes behind a lying .jpg extension: success or clean 4xx are both
* fine (tools sniff content, some require a specific input type); 5xx is not. */
const MISMATCH_FIXTURE = "png-bytes.jpg";
const REJECT_STATUSES = new Set([400, 413, 415, 422, 501]);
// Tools that never decode the uploaded pixel data: no-dropzone generators take
// input from settings, bulk-rename zips bytes verbatim, and the metadata tools
// operate on metadata segments only. Succeeding on a file with a valid header
// but broken pixel data is correct behavior for them; everything else must
// reject.
const INPUT_AGNOSTIC = new Set(
TOOLS.filter((t) => TOOL_DISPLAY_MODES[t.id] === "no-dropzone").map((t) => t.id),
);
INPUT_AGNOSTIC.add("bulk-rename");
INPUT_AGNOSTIC.add("edit-metadata");
INPUT_AGNOSTIC.add("strip-metadata");
INPUT_AGNOSTIC.add("info");
INPUT_AGNOSTIC.add("image-to-base64");
/** Server-error statuses; 501 (feature not installed) is a clean rejection. */
const SERVER_ERRORS = [500, 502, 503, 504];
describe("hostile input matrix", () => {
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function postFile(toolId: string, fixtureName: string) {
const content = readFileSync(join(HOSTILE_DIR, fixtureName));
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: fixtureName, contentType: "application/octet-stream", content },
{ name: "settings", content: "{}" },
]);
const started = Date.now();
const res = await testApp.app.inject({
method: "POST",
url: apiToolPath(toolId),
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
return { res, elapsedMs: Date.now() - started };
}
for (const tool of TOOLS) {
const toolId = tool.id;
it(`${toolId} rejects hostile files cleanly`, async () => {
for (const fixture of GARBAGE_FIXTURES) {
const { res, elapsedMs } = await postFile(toolId, fixture);
expect(
SERVER_ERRORS.includes(res.statusCode),
`${toolId} returned ${res.statusCode} for ${fixture}: ${res.body.slice(0, 300)}`,
).toBe(false);
expect(elapsedMs, `${toolId} took ${elapsedMs}ms on ${fixture}`).toBeLessThan(15_000);
if (INPUT_AGNOSTIC.has(toolId)) continue;
expect(
REJECT_STATUSES.has(res.statusCode),
`${toolId} did not reject ${fixture} (got ${res.statusCode})`,
).toBe(true);
// Error responses must be structured JSON, not stack traces
const parsed = JSON.parse(res.body) as { error?: string };
expect(parsed.error, `${toolId} 4xx body has no error field for ${fixture}`).toBeTruthy();
}
// Lying extension with valid content: anything but a server error is fine
const { res } = await postFile(toolId, MISMATCH_FIXTURE);
expect(
SERVER_ERRORS.includes(res.statusCode),
`${toolId} returned ${res.statusCode} for ${MISMATCH_FIXTURE}: ${res.body.slice(0, 300)}`,
).toBe(false);
}, 120_000);
}
});