Files
SnapOtter/tests/integration/platform/zip-stream-failure.test.ts
T

186 lines
7.3 KiB
TypeScript
Raw Normal View History

/**
* What every ZIP-streaming route does when object storage fails AFTER the 200
* headers are already on the wire (issue #645).
*
* The response is hijacked and chunked by then, so the status cannot change.
* Ending the response cleanly is therefore indistinguishable from success and
* hands the client a ZIP with no central directory; worse, a source stream
* that errors with no listener leaves the request hanging and raises an
* unhandled error. The routes must instead destroy the connection so the
* client sees a transport failure it can act on.
*/
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { fixtures, readFixture } from "../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../test-server.js";
/** Prefixes whose getObjectStream should fail once a consumer starts reading. */
const storageMock = vi.hoisted(() => ({ poison: new Set<string>() }));
vi.mock("../../../apps/api/src/lib/object-storage.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../apps/api/src/lib/object-storage.js")>();
const { Readable } = await import("node:stream");
return {
...actual,
getObjectStream: async (key: string) => {
for (const prefix of storageMock.poison) {
if (key.startsWith(prefix)) {
// Fail on read, not on open: that is how a real backend behaves
// (createReadStream resolves, then emits ENOENT), and it is the case
// the route's try/catch cannot see.
return new Readable({
read() {
this.destroy(new Error(`test poison: stream error for ${key}`));
},
});
}
}
return actual.getObjectStream(key);
},
};
});
const PNG = readFixture(fixtures.image.base.png200);
const JPG = readFixture(fixtures.image.base.jpg100);
const SVG = readFixture(fixtures.image.base.svg100);
const PDF_3PAGE = readFixture(fixtures.document.pdf3);
const PDF_2PAGE = readFixture(fixtures.document.pdf2);
let testApp: TestApp;
let token: string;
beforeAll(async () => {
testApp = await buildTestApp();
token = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/**
* Run a request whose ZIP entries all come from poisoned storage and report
* how it ended. A route that handles this correctly must terminate promptly
* and must not hand back something that parses as a complete archive.
*/
async function runWithPoisonedOutputs(
url: string,
parts: Array<{ name: string; filename?: string; contentType?: string; content: Buffer | string }>,
): Promise<{ settledWithin: boolean; deliveredCompleteZip: boolean }> {
const { body, contentType } = createMultipartPayload(parts);
storageMock.poison.add("outputs/");
// Race a real deadline rather than leaning on the test timeout: a hang is
// the failure mode being tested, and "the suite timed out" is a much weaker
// signal than a named assertion.
const HANG_BUDGET_MS = 10_000;
let timer: NodeJS.Timeout | undefined;
const deadline = new Promise<"timeout">((resolve) => {
timer = setTimeout(() => resolve("timeout"), HANG_BUDGET_MS);
});
try {
const request = testApp.app
.inject({
method: "POST",
url,
headers: { authorization: `Bearer ${token}`, "content-type": contentType },
body,
})
// A destroyed connection surfaces as a rejected request, which is
// exactly the signal the client should get.
.then(
(res) => res,
() => null,
);
const outcome = await Promise.race([request, deadline]);
if (outcome === "timeout") return { settledWithin: false, deliveredCompleteZip: false };
if (outcome === null) return { settledWithin: true, deliveredCompleteZip: false };
try {
new AdmZip(outcome.rawPayload).getEntries();
return { settledWithin: true, deliveredCompleteZip: true };
} catch {
return { settledWithin: true, deliveredCompleteZip: false };
}
} finally {
if (timer) clearTimeout(timer);
storageMock.poison.clear();
}
}
describe("ZIP streaming failure after headers are sent (issue #645)", () => {
// Healthy path first: a hung request from a later case would otherwise
// starve it and make an unrelated timeout look like a regression.
it("returns a complete ZIP when storage is healthy", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/image/resize/batch",
headers: { authorization: `Bearer ${token}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
expect(new AdmZip(res.rawPayload).getEntries()).toHaveLength(2);
}, 60_000);
it("svg-to-raster still returns a complete ZIP (it never reads storage mid-stream)", async () => {
// Kept as a guard rather than a failure case: svg-to-raster appends
// in-memory buffers, so it has no post-header storage read to poison.
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "a.svg", contentType: "image/svg+xml", content: SVG },
{ name: "file", filename: "b.svg", contentType: "image/svg+xml", content: SVG },
{ name: "settings", content: JSON.stringify({ outputFormat: "png" }) },
]);
storageMock.poison.add("outputs/");
try {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/image/svg-to-raster/batch",
headers: { authorization: `Bearer ${token}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
expect(new AdmZip(res.rawPayload).getEntries()).toHaveLength(2);
} finally {
storageMock.poison.clear();
}
}, 60_000);
it("generic /batch terminates instead of hanging, and delivers no complete ZIP", async () => {
const result = await runWithPoisonedOutputs("/api/v1/tools/image/resize/batch", [
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
{ name: "settings", content: JSON.stringify({ width: 50 }) },
{ name: "clientJobId", content: "zipfail-batch" },
]);
expect(result.settledWithin, "request hung instead of terminating").toBe(true);
expect(result.deliveredCompleteZip).toBe(false);
}, 60_000);
it("pdf-to-image /batch terminates instead of hanging", async () => {
const result = await runWithPoisonedOutputs("/api/v1/tools/pdf/pdf-to-jpg/batch", [
{ name: "file", filename: "a.pdf", contentType: "application/pdf", content: PDF_3PAGE },
{ name: "file", filename: "b.pdf", contentType: "application/pdf", content: PDF_2PAGE },
{ name: "settings", content: JSON.stringify({ dpi: 72 }) },
{ name: "clientJobId", content: "zipfail-pdf" },
]);
expect(result.settledWithin, "request hung instead of terminating").toBe(true);
expect(result.deliveredCompleteZip).toBe(false);
}, 60_000);
});