feat(tools): 2.0 phase 5 wave 4 - office, ebooks, data, archives (14 tools) (#224)

This commit is contained in:
SnapOtter
2026-06-13 10:19:11 +08:00
parent 638288e196
commit fc7c1f850e
93 changed files with 7636 additions and 817 deletions
+2
View File
@@ -0,0 +1,2 @@
id,name
1,alpha
1 id name
2 1 alpha
+2
View File
@@ -0,0 +1,2 @@
id,name
2,beta
1 id name
2 2 beta
+3
View File
@@ -0,0 +1,3 @@
id name
1 alpha
2 beta
1 id name
2 1 alpha
3 2 beta
+6
View File
@@ -0,0 +1,6 @@
name: SnapOtter
items:
- id: 1
label: alpha
- id: 2
label: beta
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
{\rtf1\ansi\deff4\adeflang1025
{\fonttbl{\f0\froman\fprq2\fcharset0 Times New Roman;}{\f1\froman\fprq2\fcharset2 Symbol;}{\f2\fswiss\fprq2\fcharset0 Arial;}{\f3\froman\fprq2\fcharset0 Liberation Serif{\*\falt Times New Roman};}{\f4\froman\fprq2\fcharset0 Calibri;}{\f5\fswiss\fprq2\fcharset0 Liberation Sans{\*\falt Arial};}{\f6\fnil\fprq2\fcharset0 WenQuanYi Zen Hei;}{\f7\fswiss\fprq0\fcharset128 FreeSans;}{\f8\fnil\fprq2\fcharset0 FreeSans;}}
{\colortbl;\red0\green0\blue0;\red0\green0\blue255;\red0\green255\blue255;\red0\green255\blue0;\red255\green0\blue255;\red255\green0\blue0;\red255\green255\blue0;\red255\green255\blue255;\red0\green0\blue128;\red0\green128\blue128;\red0\green128\blue0;\red128\green0\blue128;\red128\green0\blue0;\red128\green128\blue0;\red128\green128\blue128;\red192\green192\blue192;}
{\stylesheet{\s0\snext0\rtlch\af8\afs24\alang1081 \ltrch\lang1033\langfe2052\hich\af4\loch\nowidctlpar\hyphpar0\ltrpar\cf0\f4\fs22\lang1033\kerning1\dbch\af6\langfe2052 Normal;}
{\s15\sbasedon0\snext16\rtlch\af8\afs28 \ltrch\hich\af5\loch\sb240\sa120\keepn\f5\fs28\dbch\af6 Heading;}
{\s16\sbasedon0\snext16\loch\sl276\slmult1\sb0\sa140 Text Body;}
{\s17\sbasedon16\snext17\rtlch\af7 \ltrch\loch\sl276\slmult1\sb0\sa140 List;}
{\s18\sbasedon0\snext18\rtlch\af7\afs24\ai \ltrch\loch\sb120\sa120\noline\fs24\i Caption;}
{\s19\sbasedon0\snext19\rtlch\af7 \ltrch\loch\noline Index;}
}{\*\generator LibreOffice/7.4.7.2$Linux_AARCH64 LibreOffice_project/40$Build-2}{\info{\creatim\yr0\mo0\dy0\hr0\min0}{\revtim\yr0\mo0\dy0\hr0\min0}{\printim\yr0\mo0\dy0\hr0\min0}}{\*\userprops}\deftab709
\hyphauto1\viewscale100
{\*\pgdsctbl
{\pgdsc0\pgdscuse451\pgwsxn12240\pghsxn15840\marglsxn1440\margrsxn1440\margtsxn1440\margbsxn1440\pgdscnxt0 Default Page Style;}}
\formshade\paperh15840\paperw12240\margl1440\margr1440\margt1440\margb1440\sectd\sbknone\pgndec\sftnnar\saftnnrlc\sectunlocked1\pgwsxn12240\pghsxn15840\marglsxn1440\margrsxn1440\margtsxn1440\margbsxn1440\ftnbj\ftnstart1\ftnrstcont\ftnnar\aenddoc\aftnrstcont\aftnstart1\aftnnrlc
{\*\ftnsep\chftnsep}\pgndec\pard\plain \s0\rtlch\af8\afs24\alang1081 \ltrch\lang1033\langfe2052\hich\af4\loch\nowidctlpar\hyphpar0\ltrpar\cf0\f4\fs22\lang1033\kerning1\dbch\af6\langfe2052\ql\ltrpar{\loch
SnapOtter test document}
\par }
+4
View File
@@ -30,6 +30,10 @@ export const TOOL_SETTINGS_OVERRIDES: Record<string, unknown> = {
"resize-video": { preset: "720p" },
"watermark-video": { text: "CONFIDENTIAL" },
"audio-channels": { mode: "mono-to-stereo" },
"convert-document": { format: "odt" },
"epub-convert": { format: "html" },
"convert-presentation": { format: "odp" },
"convert-spreadsheet": { format: "ods" },
};
export function defaultSettingsFor(toolId: string): unknown {
+108
View File
@@ -0,0 +1,108 @@
// convert-document integration suite.
// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised LibreOffice install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sofficeAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const DOCX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.docx"));
const ODT = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.odt"));
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 runTool(filename: string, content: Buffer, settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-document",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
async function pollJob(jobId: string) {
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown; error: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().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));
}
return row;
}
describe.skipIf(!sofficeAvailable())("convert-document (requires soffice)", () => {
it("converts docx to odt (PK magic)", async () => {
const res = await runTool("tiny.docx", DOCX, { format: "odt" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
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);
// ODT is a ZIP (PK magic)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
it("converts odt to docx (PK magic)", async () => {
const res = await runTool("tiny.odt", ODT, { format: "docx" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
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);
// DOCX is a ZIP (PK magic)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
it("rejects same-format conversion (docx to docx)", async () => {
const res = await runTool("tiny.docx", DOCX, { format: "docx" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("failed");
const error = row?.error as { message: string } | null;
expect(error?.message).toMatch(/already in that format/i);
}, 90_000);
});
it("rejects missing format with 400", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.docx", contentType: "application/octet-stream", content: DOCX },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-document",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
});
@@ -0,0 +1,81 @@
// convert-presentation integration suite.
// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised LibreOffice install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sofficeAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const PPTX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.pptx"));
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 runTool(filename: string, content: Buffer, settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-presentation",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
async function pollJob(jobId: string) {
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown; error: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().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));
}
return row;
}
describe.skipIf(!sofficeAvailable())("convert-presentation (requires soffice)", () => {
it("converts pptx to odp (PK magic)", async () => {
const res = await runTool("tiny.pptx", PPTX, { format: "odp" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
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);
// ODP is a ZIP (PK magic)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
});
it("rejects missing format with 400", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.pptx", contentType: "application/octet-stream", content: PPTX },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-presentation",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
});
@@ -0,0 +1,98 @@
// convert-spreadsheet integration suite.
// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised LibreOffice install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sofficeAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const XLSX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.xlsx"));
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 runTool(filename: string, content: Buffer, settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-spreadsheet",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
async function pollJob(jobId: string) {
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown; error: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().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));
}
return row;
}
describe.skipIf(!sofficeAvailable())("convert-spreadsheet (requires soffice)", () => {
it("converts xlsx to ods (PK magic)", async () => {
const res = await runTool("tiny.xlsx", XLSX, { format: "ods" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
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);
// ODS is a ZIP (PK magic)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
it("converts csv to xlsx (PK magic)", async () => {
const csvContent = Buffer.from("id,name\n1,alpha\n");
const res = await runTool("data.csv", csvContent, { format: "xlsx" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
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);
// XLSX is a ZIP (PK magic)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
});
it("rejects missing format with 400", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.xlsx", contentType: "application/octet-stream", content: XLSX },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-spreadsheet",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
});
+66
View File
@@ -0,0 +1,66 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV_A = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-a.csv"));
const CSV_B = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-b.csv"));
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);
describe("create-zip (pure JS, no skipIf)", () => {
it("zips two fixture files into a valid ZIP with PK magic", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A },
{ name: "file", filename: "tiny-b.csv", contentType: "text/csv", content: CSV_B },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/create-zip",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
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);
// PK magic bytes
expect(dl.rawPayload[0]).toBe(0x50);
expect(dl.rawPayload[1]).toBe(0x4b);
// Non-trivial size (both fixture files + zip overhead)
expect(dl.rawPayload.length).toBeGreaterThan(CSV_A.length + CSV_B.length - 50);
}, 30_000);
it("rejects a single file with 422 'at least two'", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/create-zip",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/at least two/i);
}, 30_000);
});
+18
View File
@@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.csv"));
const TSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.tsv"));
const XLSX_FIXTURE = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.xlsx"));
let testApp: TestApp;
@@ -61,6 +62,23 @@ describe("csv-excel (pure JS, no skipIf)", () => {
expect(sstXml).toContain("name");
}, 30_000);
it("converts TSV to XLSX with PK magic", async () => {
const res = await runTool("tiny.tsv", TSV);
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);
// XLSX files start with PK zip magic
expect(dl.rawPayload[0]).toBe(0x50);
expect(dl.rawPayload[1]).toBe(0x4b);
}, 30_000);
it("converts XLSX to CSV containing the fixture content", async () => {
// Use the committed tiny.xlsx from tests/fixtures/documents/
// (Sheet1 with "SnapOtter" in A1)
+21
View File
@@ -4,6 +4,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.csv"));
const TSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.tsv"));
const JSON_FIXTURE = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.json"));
let testApp: TestApp;
@@ -68,6 +69,26 @@ describe("csv-json (pure JS, no skipIf)", () => {
expect(csvText).toContain("Ada");
}, 30_000);
it("converts TSV to JSON with the correct keys", async () => {
const res = await runTool("tiny.tsv", TSV, { pretty: true });
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 data = JSON.parse(dl.payload);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(2);
const first = data[0] as Record<string, string>;
expect(first.id).toBe("1");
expect(first.name).toBe("alpha");
}, 30_000);
it("rejects non-array JSON input for JSON-to-CSV", async () => {
const obj = Buffer.from(JSON.stringify({ key: "value" }));
const res = await runTool("obj.json", obj);
+206
View File
@@ -0,0 +1,206 @@
// epub-convert integration suite.
// Requires pandoc (and weasyprint for the pdf case). Skips locally
// (pandoc absent on dev Macs); the Docker compose smoke is the real proof.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pandocAvailable } from "@snapotter/doc-engine";
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { pythonWith } from "../helpers/python-gate.js";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const EPUB = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.epub"));
/** Build a minimal valid epub in memory containing a remote image reference. */
function buildRemoteRefEpub(): Buffer {
const zip = new AdmZip();
// mimetype must be first entry, stored (no compression)
zip.addFile("mimetype", Buffer.from("application/epub+zip"), "", 0o644);
zip.addFile(
"META-INF/container.xml",
Buffer.from(
`<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`,
),
);
zip.addFile(
"OEBPS/content.opf",
Buffer.from(
`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">urn:uuid:00000000-0000-0000-0000-000000000001</dc:identifier>
<dc:title>RemoteRefBook</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2024-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine><itemref idref="ch1"/></spine>
</package>`,
),
);
zip.addFile(
"OEBPS/chapter1.xhtml",
Buffer.from(
`<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>RemoteRefBook</title></head>
<body>
<h1>RemoteRefBook</h1>
<p>Content for remote-ref SSRF test.</p>
<img src="http://127.0.0.1:9/x.png" alt="remote"/>
</body>
</html>`,
),
);
return zip.toBuffer();
}
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 runTool(filename: string, content: Buffer, settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/epub-convert",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
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);
expect(dl.payload).toContain("SnapOtter test epub");
}, 30_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);
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 30_000);
it("html output passes remote refs through without fetching (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");
expect(dl.payload).toContain("RemoteRefBook");
}, 30_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);
expect(dl.payload.length).toBeGreaterThan(0);
expect(dl.payload).toContain("SnapOtter");
}, 30_000);
});
describe.skipIf(!pandocAvailable() || !pythonWith("weasyprint"))(
"epub-convert pdf (requires pandoc + weasyprint)",
() => {
it("pdf output rejects remote refs in epub content", async () => {
const remoteEpub = buildRemoteRefEpub();
const res = await runTool("remote.epub", remoteEpub, { format: "pdf" });
// Long hint: expects 202 with jobId
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; errorMessage: string | null } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db
.select({ status: schema.jobs.status, errorMessage: schema.jobs.errorMessage })
.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);
}, 90_000);
it("converts epub to PDF via the weasyprint chain", async () => {
const res = await runTool("tiny.epub", EPUB, { format: "pdf" });
// Long hint: expects 202 with jobId
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 } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().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("completed");
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);
expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-");
}, 90_000);
},
);
// Ungated tests: run locally without pandoc
it("rejects missing format with 400", async () => {
const res = await runTool("tiny.epub", EPUB, {});
expect(res.statusCode).toBe(400);
});
+81
View File
@@ -0,0 +1,81 @@
// excel-to-pdf integration suite.
// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised LibreOffice install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sofficeAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const XLSX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.xlsx"));
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 runTool(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/excel-to-pdf",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!sofficeAvailable())("excel-to-pdf (requires soffice)", () => {
it("returns 202 (long hint) and the job completes with a PDF", async () => {
const res = await runTool("tiny.xlsx", XLSX);
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
// Poll the durable row until terminal (the long hint skips the sync window).
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().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("completed");
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);
expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-");
}, 90_000);
});
it("rejects unsupported file types with 415", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "readme.txt",
contentType: "text/plain",
content: Buffer.from("hello"),
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/excel-to-pdf",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(415);
});
+141
View File
@@ -0,0 +1,141 @@
import { createWriteStream, readFileSync } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import AdmZip from "adm-zip";
import archiver from "archiver";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV_A = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-a.csv"));
const CSV_B = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-b.csv"));
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);
/** Build a zip buffer in memory using archiver. */
async function buildZipBuffer(entries: Array<{ name: string; content: Buffer }>): Promise<Buffer> {
const tmpDir = join(tmpdir(), `extract-zip-test-${Date.now()}`);
await mkdir(tmpDir, { recursive: true });
const zipPath = join(tmpDir, "test.zip");
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver("zip", { zlib: { level: 1 } });
output.on("close", () => resolve());
archive.on("error", (err: Error) => reject(err));
archive.pipe(output);
for (const entry of entries) {
archive.append(entry.content, { name: entry.name });
}
void archive.finalize();
});
const buf = readFileSync(zipPath);
await rm(tmpDir, { recursive: true, force: true });
return buf;
}
async function runExtract(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/zip", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/extract-zip",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe("extract-zip (pure JS, no skipIf)", () => {
it("extracts a two-entry zip into _extracted.zip with PK magic and resultPayload", async () => {
const zipBuf = await buildZipBuffer([
{ name: "tiny-a.csv", content: CSV_A },
{ name: "tiny-b.csv", content: CSV_B },
]);
const res = await runExtract("test.zip", zipBuf);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
// resultPayload is spread into the envelope
expect(envelope.entries).toBeDefined();
expect(envelope.entries).toHaveLength(2);
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
// Output should be a zip (_extracted.zip)
expect(dl.rawPayload[0]).toBe(0x50);
expect(dl.rawPayload[1]).toBe(0x4b);
}, 30_000);
it("extracts a single-entry zip returning the bare file content", async () => {
const content = Buffer.from("hello world from extract-zip test");
const zipBuf = await buildZipBuffer([{ name: "readme.txt", content }]);
const res = await runExtract("single.zip", zipBuf);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
// resultPayload is spread into the envelope
expect(envelope.entries).toBeDefined();
expect(envelope.entries).toHaveLength(1);
expect(envelope.entries[0].name).toBe("readme.txt");
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
// Content should match the original
expect(dl.payload).toBe("hello world from extract-zip test");
}, 30_000);
it("rejects a zip with path traversal (../evil.txt) with 422", async () => {
// Most zip libraries sanitize entry names, so we binary-patch
// a placeholder to inject "../evil.txt" into the raw zip bytes.
const zip = new AdmZip();
// Placeholder same length as "../evil.txt" (11 chars)
zip.addFile("XXXXXXXXXXX", Buffer.from("evil"));
const zipBuf = Buffer.from(zip.toBuffer());
const placeholder = Buffer.from("XXXXXXXXXXX");
const replacement = Buffer.from("../evil.txt");
let offset = zipBuf.indexOf(placeholder);
while (offset !== -1) {
replacement.copy(zipBuf, offset);
offset = zipBuf.indexOf(placeholder, offset + 1);
}
const res = await runExtract("traversal.zip", zipBuf);
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
// yauzl itself rejects "../" paths with "invalid relative path" (defense in depth);
// our guard also catches them if yauzl's validation is bypassed
expect(parsed.details).toMatch(/unsafe entry path|invalid relative path/i);
}, 30_000);
it("rejects a high-ratio zip bomb with 422", async () => {
// Create a 60 MiB zero buffer - compresses to a very small zip
const bomb = Buffer.alloc(60 * 1024 * 1024, 0);
const zipBuf = await buildZipBuffer([{ name: "bomb.bin", content: bomb }]);
// The zip itself should be very small due to compression
expect(zipBuf.length).toBeLessThan(1 * 1024 * 1024);
const res = await runExtract("bomb.zip", zipBuf);
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/suspicious compression/i);
}, 60_000);
});
@@ -0,0 +1,73 @@
// markdown-to-docx integration suite.
// Requires pandoc. Skips locally (pandoc absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised pandoc install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pandocAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const MD = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.md"));
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 runTool(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/markdown-to-docx",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!pandocAvailable())("markdown-to-docx (requires pandoc)", () => {
it("converts tiny.md to DOCX 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();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
// DOCX is a ZIP archive (PK magic bytes)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 30_000);
});
it("rejects unsupported file types with 415", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "readme.txt",
contentType: "text/plain",
content: Buffer.from("hello"),
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/markdown-to-docx",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(415);
});
@@ -0,0 +1,73 @@
// markdown-to-html integration suite.
// Requires pandoc. Skips locally (pandoc absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised pandoc install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pandocAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const MD = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.md"));
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 runTool(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/markdown-to-html",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!pandocAvailable())("markdown-to-html (requires pandoc)", () => {
it("converts tiny.md to HTML containing source text", async () => {
const res = await runTool("tiny.md", 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);
// The fixture contains "SnapOtter Markdown fixture" as a heading
expect(dl.payload).toContain("SnapOtter Markdown fixture");
}, 30_000);
});
it("rejects unsupported file types with 415", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "readme.txt",
contentType: "text/plain",
content: Buffer.from("hello"),
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/markdown-to-html",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(415);
});
+85
View File
@@ -0,0 +1,85 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV_A = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-a.csv"));
const CSV_B = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-b.csv"));
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);
describe("merge-csvs (pure JS, no skipIf)", () => {
it("merges tiny-a.csv and tiny-b.csv into one file with both rows", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A },
{ name: "file", filename: "tiny-b.csv", contentType: "text/csv", content: CSV_B },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/merge-csvs",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
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 text = dl.payload;
const lines = text.trim().split("\n");
// One header line + two data rows
expect(lines.length).toBe(3);
expect(text).toContain("alpha");
expect(text).toContain("beta");
}, 30_000);
it("rejects mismatched headers with 422", async () => {
const bad = Buffer.from("x,y\n10,20\n");
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A },
{ name: "file", filename: "bad.csv", contentType: "text/csv", content: bad },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/merge-csvs",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/different columns/i);
}, 30_000);
it("rejects a single file with 422", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/merge-csvs",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/at least two/i);
}, 30_000);
});
@@ -0,0 +1,81 @@
// powerpoint-to-pdf integration suite.
// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised LibreOffice install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sofficeAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const PPTX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.pptx"));
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 runTool(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/powerpoint-to-pdf",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!sofficeAvailable())("powerpoint-to-pdf (requires soffice)", () => {
it("returns 202 (long hint) and the job completes with a PDF", async () => {
const res = await runTool("tiny.pptx", PPTX);
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
// Poll the durable row until terminal (the long hint skips the sync window).
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().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("completed");
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);
expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-");
}, 90_000);
});
it("rejects unsupported file types with 415", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "readme.txt",
contentType: "text/plain",
content: Buffer.from("hello"),
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/powerpoint-to-pdf",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(415);
});
+40
View File
@@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.csv"));
const TSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.tsv"));
let testApp: TestApp;
let adminToken: string;
@@ -60,6 +61,45 @@ describe("split-csv (pure JS, no skipIf)", () => {
}
}, 30_000);
it("splits a TSV file correctly with rowsPerFile 1", async () => {
const { body: tsvBody, contentType: tsvCt } = createMultipartPayload([
{
name: "file",
filename: "tiny.tsv",
contentType: "text/tab-separated-values",
content: TSV,
},
{ name: "settings", content: JSON.stringify({ rowsPerFile: 1, keepHeader: true }) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/split-csv",
headers: { authorization: `Bearer ${adminToken}`, "content-type": tsvCt },
body: tsvBody,
});
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 zip = new AdmZip(Buffer.from(dl.rawPayload));
const entries = zip.getEntries();
// TSV has 2 data rows -> 2 parts
expect(entries.length).toBe(2);
// Each part should contain the header
for (const entry of entries) {
const text = entry.getData().toString("utf8");
expect(text).toContain("id");
expect(text).toContain("name");
}
}, 30_000);
it("splits with keepHeader false omits the header from parts", async () => {
const res = await runTool({ rowsPerFile: 2, keepHeader: false });
expect(res.statusCode).toBe(200);
+73
View File
@@ -0,0 +1,73 @@
// to-epub integration suite.
// Requires pandoc. Skips locally (pandoc absent on dev Macs);
// the Docker compose smoke is the real proof.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pandocAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const MD = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.md"));
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 runTool(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/to-epub",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
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();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
// EPUB is a ZIP archive (PK magic bytes)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 30_000);
});
// Ungated: runs locally without pandoc
it("rejects unsupported file types with 415", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "data.csv",
contentType: "text/csv",
content: Buffer.from("a,b\n1,2"),
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/to-epub",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(415);
});
+58
View File
@@ -0,0 +1,58 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
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 runTool(filename: string, content: Buffer, settings: Record<string, unknown> = {}) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/xml-to-csv",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe("xml-to-csv (pure JS, no skipIf)", () => {
it("converts XML with 2 repeated elements to CSV with 2 data rows", async () => {
const xml = Buffer.from(
'<?xml version="1.0"?><items><item><name>Ada</name><age>36</age></item><item><name>Grace</name><age>85</age></item></items>',
);
const res = await runTool("data.xml", xml);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
expect(envelope.rows).toBe(2);
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
const text = dl.payload;
const lines = text.trim().split("\n");
// header + 2 data rows
expect(lines.length).toBe(3);
expect(text).toContain("Ada");
expect(text).toContain("Grace");
}, 30_000);
it("rejects XML with no repeating elements", async () => {
const xml = Buffer.from('<?xml version="1.0"?><root><single>value</single></root>');
const res = await runTool("flat.xml", xml);
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/no repeating elements/i);
}, 30_000);
});
+76
View File
@@ -0,0 +1,76 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const YAML = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.yaml"));
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 runTool(filename: string, content: Buffer, settings: Record<string, unknown> = {}) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/yaml-json",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe("yaml-json (pure JS, no skipIf)", () => {
it("converts tiny.yaml to JSON containing 'alpha'", async () => {
const res = await runTool("tiny.yaml", YAML);
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 json = dl.payload;
expect(json).toContain("alpha");
const parsed = JSON.parse(json);
expect(parsed.name).toBe("SnapOtter");
}, 30_000);
it("round-trips: converts the produced JSON back to YAML", async () => {
// First: yaml -> json
const res1 = await runTool("tiny.yaml", YAML);
expect(res1.statusCode).toBe(200);
const env1 = JSON.parse(res1.body);
const dl1 = await testApp.app.inject({ method: "GET", url: env1.downloadUrl });
const jsonBuf = Buffer.from(dl1.payload, "utf8");
// Second: json -> yaml
const res2 = await runTool("tiny.json", jsonBuf);
expect(res2.statusCode).toBe(200);
const env2 = JSON.parse(res2.body);
const dl2 = await testApp.app.inject({ method: "GET", url: env2.downloadUrl });
expect(dl2.statusCode).toBe(200);
const yamlText = dl2.payload;
expect(yamlText).toContain("alpha");
expect(yamlText).toContain("SnapOtter");
}, 30_000);
it("rejects malformed YAML with 422", async () => {
const bad = Buffer.from("a: [unclosed");
const res = await runTool("bad.yaml", bad);
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/not valid yaml/i);
}, 30_000);
});
+67
View File
@@ -0,0 +1,67 @@
// Pure-regex unit test for the SSRF pre-scan in doc_html_pdf.py.
// Runs python3 against the actual module regexes (no weasyprint needed).
// Mirrors the step-3 verification matrix from the fix ticket.
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { hasPython } from "../../helpers/python-gate.js";
const SCRIPT_DIR = join(process.cwd(), "packages", "ai", "python");
/** Run a python3 snippet that imports regexes from the actual module and tests a case. */
function testRegex(html: string, patternName: string): boolean {
const code = [
"import sys, os",
`sys.path.insert(0, ${JSON.stringify(SCRIPT_DIR)})`,
"from doc_html_pdf import _REMOTE_REF_RE, _REMOTE_CSS_URL_RE",
`pat = ${patternName}`,
`m = pat.search(${JSON.stringify(html)})`,
"print('MATCH' if m else 'NO_MATCH')",
].join("; ");
const res = spawnSync("python3", ["-c", code], { encoding: "utf8", timeout: 5000 });
if (res.status !== 0) throw new Error(`python3 failed: ${res.stderr}`);
return res.stdout.trim() === "MATCH";
}
describe.skipIf(!hasPython)("SSRF pre-scan regexes (doc_html_pdf.py)", () => {
describe("_REMOTE_REF_RE", () => {
it("matches double-slash http img", () => {
expect(testRegex('<img src="http://h/x.png">', "_REMOTE_REF_RE")).toBe(true);
});
it("matches single-slash http img (pandoc rewrite)", () => {
expect(testRegex('<img src="http:/h/x.png">', "_REMOTE_REF_RE")).toBe(true);
});
it("matches double-slash https img", () => {
expect(testRegex('<img src="https://h/x.png">', "_REMOTE_REF_RE")).toBe(true);
});
it("matches single-slash https img", () => {
expect(testRegex('<img src="https:/h/x.png">', "_REMOTE_REF_RE")).toBe(true);
});
it("does NOT match data: URIs", () => {
expect(testRegex('<img src="data:image/png;base64,AA==">', "_REMOTE_REF_RE")).toBe(false);
});
it("does NOT match relative paths", () => {
expect(testRegex('<a href="page2.xhtml">', "_REMOTE_REF_RE")).toBe(false);
});
});
describe("_REMOTE_CSS_URL_RE", () => {
it("matches single-slash CSS url()", () => {
expect(testRegex("url(https:/h/a.css)", "_REMOTE_CSS_URL_RE")).toBe(true);
});
it("matches double-slash CSS url()", () => {
expect(testRegex("url(https://h/a.css)", "_REMOTE_CSS_URL_RE")).toBe(true);
});
it("does NOT match data: CSS url()", () => {
expect(testRegex("url(data:image/png;base64,AA==)", "_REMOTE_CSS_URL_RE")).toBe(false);
});
});
});
+27
View File
@@ -0,0 +1,27 @@
import { parseConvertTarget } from "@snapotter/doc-engine";
import { describe, expect, it } from "vitest";
describe("parseConvertTarget", () => {
it("returns a bare extension unchanged", () => {
const result = parseConvertTarget("pdf");
expect(result).toEqual({ ext: "pdf", convertTo: "pdf" });
});
it("splits on the first colon for qualified filter targets", () => {
const result = parseConvertTarget("docx:MS Word 2007 XML");
expect(result).toEqual({ ext: "docx", convertTo: "docx:MS Word 2007 XML" });
});
it("handles filter names with colons after the first", () => {
const result = parseConvertTarget("csv:Text - txt - csv (StarCalc):44,34,76,1");
expect(result).toEqual({
ext: "csv",
convertTo: "csv:Text - txt - csv (StarCalc):44,34,76,1",
});
});
it("handles single-character extensions", () => {
const result = parseConvertTarget("a:SomeFilter");
expect(result).toEqual({ ext: "a", convertTo: "a:SomeFilter" });
});
});
+34
View File
@@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pandocAvailable, runPandoc } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
describe("pandocAvailable", () => {
it("returns a boolean without throwing", () => {
expect(typeof pandocAvailable()).toBe("boolean");
});
});
describe.skipIf(!pandocAvailable())("runPandoc (requires pandoc)", () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "pandoc-test-"));
});
afterAll(async () => {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
it("converts a trivial markdown file to html", async () => {
const mdPath = join(tmpDir, "test.md");
const htmlPath = join(tmpDir, "test.html");
await writeFile(mdPath, "# Hello\n\nWorld\n");
await runPandoc(mdPath, htmlPath, { extraArgs: ["--standalone"] });
const html = readFileSync(htmlPath, "utf8");
expect(html).toContain("Hello");
expect(html).toContain("<");
}, 30_000);
});
+5 -1
View File
@@ -24,7 +24,11 @@ describe("modality metadata", () => {
for (const tool of TOOLS) {
expect(validModalities).toContain(tool.modality);
expect(Array.isArray(tool.acceptedInputs)).toBe(true);
expect(tool.acceptedInputs.length).toBeGreaterThan(0);
// Empty acceptedInputs is valid for file-modality tools that accept any file
// (e.g. create-zip); the factory 415 gate skips when the list is empty.
if (tool.modality !== "file" || tool.acceptedInputs.length > 0) {
expect(tool.acceptedInputs.length).toBeGreaterThan(0);
}
expect(["fast", "long"]).toContain(tool.executionHint);
}
});