mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* fix: make OCR portable and reliable * fix: harden OCR installation portability * fix: pin OCR partials across downloads * fix: make OCR execution reliably asynchronous * fix: harden OCR portability and docs routes * fix: preserve decoder and docs safeguards
186 lines
7.0 KiB
TypeScript
186 lines
7.0 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 { waitForJob } from "../../../apps/api/src/jobs/enqueue.js";
|
|
import { resolveToolPool } from "../../../apps/api/src/lib/pool.js";
|
|
import { TOOL_DISPLAY_MODES } from "../../../apps/web/src/lib/tool-display-modes.js";
|
|
import { fixtureDir } from "../../fixtures/index.js";
|
|
import { cancelAcceptedJobAndWait } from "../settle-job.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).
|
|
*/
|
|
|
|
/** 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(fixtureDir.hostile, 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 };
|
|
}
|
|
|
|
function acceptedJob(toolId: string, fixtureName: string, body: string) {
|
|
const accepted = JSON.parse(body) as { jobId?: unknown; async?: unknown };
|
|
expect(accepted, `${toolId} returned an invalid 202 body for ${fixtureName}`).toMatchObject({
|
|
jobId: expect.any(String),
|
|
async: true,
|
|
});
|
|
|
|
return { jobId: accepted.jobId as string, pool: resolveToolPool(toolId) };
|
|
}
|
|
|
|
async function settleAcceptedResponse(toolId: string, fixtureName: string, body: string) {
|
|
const { jobId, pool } = acceptedJob(toolId, fixtureName, body);
|
|
await cancelAcceptedJobAndWait(jobId, pool);
|
|
}
|
|
|
|
async function expectAsyncRejection(toolId: string, fixtureName: string, body: string) {
|
|
const { jobId, pool } = acceptedJob(toolId, fixtureName, body);
|
|
const started = Date.now();
|
|
const outcome = await waitForJob(pool, jobId, 15_000).then(
|
|
(result) => ({ kind: "finished" as const, result }),
|
|
(error: unknown) => ({ kind: "failed" as const, error }),
|
|
);
|
|
|
|
if (outcome.kind === "finished") {
|
|
if (outcome.result === null) {
|
|
await cancelAcceptedJobAndWait(jobId, pool);
|
|
expect.fail(`${toolId} did not reject ${fixtureName} within 15000ms`);
|
|
}
|
|
expect.fail(`${toolId} completed hostile input ${fixtureName}`);
|
|
}
|
|
|
|
expect(
|
|
Date.now() - started,
|
|
`${toolId} took too long to reject ${fixtureName} asynchronously`,
|
|
).toBeLessThan(15_000);
|
|
|
|
// Async tools report post-enqueue failures through their terminal SSE
|
|
// frame. Verify the public contract, including that it exposes a concise
|
|
// error rather than a worker stack trace.
|
|
const progress = await testApp.app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/jobs/${jobId}/progress`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(progress.statusCode).toBe(200);
|
|
const frames = progress.body
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.startsWith("data: "))
|
|
.map((line) => JSON.parse(line.slice(6)) as Record<string, unknown>);
|
|
const terminal = frames.at(-1);
|
|
expect(terminal, `${toolId} emitted no terminal SSE frame for ${fixtureName}`).toMatchObject({
|
|
jobId,
|
|
type: "single",
|
|
phase: "failed",
|
|
error: expect.any(String),
|
|
});
|
|
expect(String(terminal?.error)).not.toMatch(/\n\s*at\s/u);
|
|
}
|
|
|
|
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)) {
|
|
if (res.statusCode === 202) {
|
|
await settleAcceptedResponse(toolId, fixture, res.body);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (res.statusCode === 202) {
|
|
await expectAsyncRejection(toolId, fixture, res.body);
|
|
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);
|
|
if (res.statusCode === 202) {
|
|
await settleAcceptedResponse(toolId, MISMATCH_FIXTURE, res.body);
|
|
}
|
|
}, 120_000);
|
|
}
|
|
});
|