mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
110 lines
3.9 KiB
TypeScript
110 lines
3.9 KiB
TypeScript
/**
|
|
* Pipeline batch ZIP streaming failure path.
|
|
*
|
|
* The batch route writes the 200 header before it streams ZIP entries from
|
|
* object storage. If a read fails mid-stream the route must abort the archive
|
|
* and end the response instead of hanging the connection. That branch cannot
|
|
* be reached through public inputs (the manifest only lists outputs the worker
|
|
* just wrote), so this file swaps getObjectStream for a passthrough that fails
|
|
* ONLY for the per-file output keys of one designated batch parent id. Workers
|
|
* read inputs from uploads/ keys and the pipeline finalize copy uses
|
|
* getObjectBuffer, so normal processing is untouched.
|
|
*/
|
|
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { fixtures, readFixture } from "../../fixtures/index.js";
|
|
import {
|
|
buildTestApp,
|
|
createMultipartPayload,
|
|
loginAsAdmin,
|
|
type TestApp,
|
|
} from "../test-server.js";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
// When set, getObjectStream throws for keys matching outputs/<id>-f<N>/...
|
|
failZipStreamsForParentId: null as string | null,
|
|
}));
|
|
|
|
vi.mock("../../../apps/api/src/lib/object-storage.js", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("../../../apps/api/src/lib/object-storage.js")>();
|
|
return {
|
|
...actual,
|
|
getObjectStream: async (key: string, range?: { start: number; end?: number }) => {
|
|
const parentId = mocks.failZipStreamsForParentId;
|
|
if (parentId && new RegExp(`^outputs/${parentId}-f\\d+/`).test(key)) {
|
|
throw new Error("simulated object storage outage");
|
|
}
|
|
return actual.getObjectStream(key, range);
|
|
},
|
|
};
|
|
});
|
|
|
|
const PNG_200x150 = readFixture(fixtures.image.base.png200);
|
|
|
|
// ZIP end-of-central-directory signature: only written by archive.finalize().
|
|
const ZIP_EOCD = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
|
|
let testApp: TestApp;
|
|
let app: TestApp["app"];
|
|
let adminToken: string;
|
|
|
|
beforeAll(async () => {
|
|
testApp = await buildTestApp();
|
|
app = testApp.app;
|
|
adminToken = await loginAsAdmin(app);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await testApp.cleanup();
|
|
}, 10_000);
|
|
|
|
afterEach(() => {
|
|
mocks.failZipStreamsForParentId = null;
|
|
});
|
|
|
|
function postBatch(clientJobId: string) {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "photo.png", content: PNG_200x150, contentType: "image/png" },
|
|
{
|
|
name: "pipeline",
|
|
content: JSON.stringify({ steps: [{ toolId: "resize", settings: { width: 50 } }] }),
|
|
},
|
|
{ name: "clientJobId", content: clientJobId },
|
|
]);
|
|
return app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/pipeline/batch",
|
|
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
|
|
body,
|
|
});
|
|
}
|
|
|
|
describe("Pipeline batch ZIP streaming", () => {
|
|
it("streams a complete ZIP when object storage is healthy", async () => {
|
|
const res = await postBatch("zip-stream-healthy-parent");
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toBe("application/zip");
|
|
expect(res.headers["x-job-id"]).toBe("zip-stream-healthy-parent");
|
|
// finalize() ran, so the payload carries the end-of-central-directory record
|
|
expect(res.rawPayload.includes(ZIP_EOCD)).toBe(true);
|
|
}, 30_000);
|
|
|
|
it("aborts the archive and ends the response when an entry read fails", async () => {
|
|
const parentId = "zip-stream-failure-parent";
|
|
mocks.failZipStreamsForParentId = parentId;
|
|
|
|
const res = await postBatch(parentId);
|
|
|
|
// Headers were already committed before streaming began.
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toBe("application/zip");
|
|
expect(res.headers["x-job-id"]).toBe(parentId);
|
|
// The stream failed before finalize(), so the body must terminate without
|
|
// a complete archive (no end-of-central-directory record), and the
|
|
// request must resolve rather than hang.
|
|
expect(res.rawPayload.includes(ZIP_EOCD)).toBe(false);
|
|
}, 30_000);
|
|
});
|