mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
106 lines
4.1 KiB
TypeScript
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);
|
|
}
|
|
});
|