test: fix stale epub-convert/to-epub async tests

These suites only ever skipped (pandoc is absent in every test env), which hid three latent bugs once the binaries are reachable:

- epub-convert and to-epub are executionHint "long" (async: 202 + jobId), but the tests asserted a synchronous 200 and read the response downloadUrl. Rewrite to the 202 -> poll job row -> download pattern.
- The job-row reads selected schema.jobs.errorMessage, which is not a column (the field is `error` jsonb {message,details}); drizzle threw on the bad select. Read error/error.message instead. (The pdf-chain test had the same bug.)
- The SSRF test asserted the remote <img> URL passes through, but pandoc strips the unmanifested remote ref. Assert the security-relevant property instead: the conversion completes from the book's own content and never fetches+inlines the remote resource (no data: URI).

Verified by building the test image with pandoc: all epub/to-epub tests pass.
This commit is contained in:
SnapOtter
2026-06-17 14:28:41 +08:00
parent 7a205eeb12
commit 7e3249a998
2 changed files with 84 additions and 56 deletions
+60 -50
View File
@@ -88,66 +88,76 @@ async function runTool(filename: string, content: Buffer, settings: Record<strin
});
}
/**
* epub-convert is a "long" tool: it returns 202 + jobId and runs async. Poll the
* job row to completion, then download the output via the job download route.
* Mirrors the pattern the pdf-chain tests below already use.
*/
async function convertAndDownload(
filename: string,
content: Buffer,
settings: Record<string, unknown>,
) {
const res = await runTool(filename, content, settings);
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown; error: { message: string } | null } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db
.select({
status: schema.jobs.status,
outputRefs: schema.jobs.outputRefs,
error: schema.jobs.error,
})
.from(schema.jobs)
.where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
if (row?.status !== "completed") {
throw new Error(
`epub-convert job ${jobId} ${row?.status}: ${row?.error?.message ?? "unknown"}`,
);
}
const outName = (row.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
return dl;
}
describe.skipIf(!pandocAvailable())("epub-convert (requires pandoc)", () => {
it("converts epub to HTML containing source text", async () => {
const res = await runTool("tiny.epub", EPUB, { format: "html" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
const dl = await convertAndDownload("tiny.epub", EPUB, { format: "html" });
expect(dl.payload).toContain("SnapOtter test epub");
}, 30_000);
}, 90_000);
it("converts epub to DOCX with PK magic", async () => {
const res = await runTool("tiny.epub", EPUB, { format: "docx" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
const dl = await convertAndDownload("tiny.epub", EPUB, { format: "docx" });
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 30_000);
}, 90_000);
it("html output passes remote refs through without fetching (no SSRF)", async () => {
it("converts an epub with a remote ref without fetching it (no SSRF)", async () => {
const remoteEpub = buildRemoteRefEpub();
const res = await runTool("remote.epub", remoteEpub, { format: "html" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
// The literal remote URL must still be present (pandoc did not inline it)
expect(dl.payload).toContain("http://127.0.0.1:9/x.png");
// The book references a remote image on a closed port (127.0.0.1:9). The
// conversion must complete from the book's own content and never fetch that
// ref (resource embedding is off in the route). pandoc strips the
// unmanifested remote <img> rather than inlining it; either way it must not
// be fetched and inlined as a data: URI.
const dl = await convertAndDownload("remote.epub", remoteEpub, { format: "html" });
expect(dl.payload).toContain("RemoteRefBook");
}, 30_000);
expect(dl.payload).not.toContain("base64");
}, 90_000);
it("converts epub to Markdown containing source text", async () => {
const res = await runTool("tiny.epub", EPUB, { format: "md" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
const dl = await convertAndDownload("tiny.epub", EPUB, { format: "md" });
expect(dl.payload.length).toBeGreaterThan(0);
expect(dl.payload).toContain("SnapOtter");
}, 30_000);
}, 90_000);
});
describe.skipIf(!pandocAvailable() || !pythonWith("weasyprint"))(
@@ -161,17 +171,17 @@ describe.skipIf(!pandocAvailable() || !pythonWith("weasyprint"))(
const { jobId } = JSON.parse(res.body);
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; errorMessage: string | null } | undefined;
let row: { status: string; error: { message: string } | null } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db
.select({ status: schema.jobs.status, errorMessage: schema.jobs.errorMessage })
.select({ status: schema.jobs.status, error: schema.jobs.error })
.from(schema.jobs)
.where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(row?.status).toBe("failed");
expect(row?.errorMessage).toMatch(/remote resources are disabled/i);
expect(row?.error?.message).toMatch(/remote resources are disabled/i);
}, 90_000);
it("converts epub to PDF via the weasyprint chain", async () => {
+24 -6
View File
@@ -38,18 +38,36 @@ async function runTool(filename: string, content: Buffer) {
describe.skipIf(!pandocAvailable())("to-epub (requires pandoc)", () => {
it("converts tiny.md to EPUB with PK magic", async () => {
const res = await runTool("tiny.md", MD);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
// to-epub is a "long" tool: it returns 202 + jobId and runs async.
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown; error: { message: string } | null } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db
.select({
status: schema.jobs.status,
outputRefs: schema.jobs.outputRefs,
error: schema.jobs.error,
})
.from(schema.jobs)
.where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
if (row?.status !== "completed") {
throw new Error(`to-epub job ${jobId} ${row?.status}: ${row?.error?.message ?? "unknown"}`);
}
const outName = (row.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
// EPUB is a ZIP archive (PK magic bytes)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 30_000);
}, 90_000);
});
// Ungated: runs locally without pandoc