mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'worktree-test+suite-overhaul-and-real-fixtures' into chore/consolidate-v2.0.0
# Conflicts: # tests/integration/generated/settings-matrix.test.ts # tests/integration/platform/api.test.ts # tests/integration/platform/concurrent.test.ts # tests/integration/platform/factory-multi-input.test.ts # tests/integration/security/adversarial-comprehensive.test.ts # tests/integration/security/adversarial-coverage-gaps.test.ts # tests/integration/security/adversarial-extended.test.ts # tests/integration/security/adversarial-final-gaps.test.ts # tests/integration/security/adversarial-matrix.test.ts # tests/integration/security/adversarial-security.test.ts # tests/integration/security/adversarial.test.ts # tests/integration/tools/image/color-adjustments.test.ts
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Integration tests for the chart-maker tool (/api/v1/tools/files/chart-maker).
|
||||
*
|
||||
* Factory FILE tool that consumes CSV/JSON and renders hand-rolled SVG
|
||||
* rasterized to PNG via sharp. Tests cover bar/line/pie kinds, CSV and JSON
|
||||
* inputs, numeric validation, SVG label escaping, and invalid kind rejection.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
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);
|
||||
|
||||
function makeCsv(rows: [string, number][]): Buffer {
|
||||
const lines = ["label,value", ...rows.map(([l, v]) => `${l},${v}`)];
|
||||
return Buffer.from(lines.join("\n"), "utf8");
|
||||
}
|
||||
|
||||
describe("Chart Maker", () => {
|
||||
it("generates a bar chart from a 4-row CSV", async () => {
|
||||
const csv = makeCsv([
|
||||
["Apples", 10],
|
||||
["Bananas", 25],
|
||||
["Cherries", 15],
|
||||
["Dates", 30],
|
||||
]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
|
||||
// Download and verify PNG
|
||||
const dlRes = await app.inject({ method: "GET", url: result.downloadUrl });
|
||||
expect(dlRes.statusCode).toBe(200);
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.format).toBe("png");
|
||||
expect(meta.width).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("generates a chart from JSON object input", async () => {
|
||||
const jsonData = JSON.stringify({ Apples: 10, Bananas: 25, Cherries: 15 });
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "data.json",
|
||||
contentType: "application/json",
|
||||
content: Buffer.from(jsonData),
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("generates a pie chart", async () => {
|
||||
const csv = makeCsv([
|
||||
["Red", 40],
|
||||
["Blue", 30],
|
||||
["Green", 30],
|
||||
]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "pie" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("generates a line chart", async () => {
|
||||
const csv = makeCsv([
|
||||
["Jan", 10],
|
||||
["Feb", 20],
|
||||
["Mar", 15],
|
||||
]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "line" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects CSV with non-numeric column 2", async () => {
|
||||
const csv = Buffer.from("label,value\nApples,lots\nBananas,many\n", "utf8");
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "bad.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.error + " " + (result.details ?? "")).toMatch(/numeric/i);
|
||||
});
|
||||
|
||||
it("escapes SVG-injection labels and produces valid PNG", async () => {
|
||||
const csv = makeCsv([["<script>alert(1)</script>", 10]]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "xss.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
// Download and verify it decodes as PNG (the escape proof: no SVG parse error)
|
||||
const dlRes = await app.inject({ method: "GET", url: result.downloadUrl });
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.format).toBe("png");
|
||||
});
|
||||
|
||||
it("rejects invalid chart kind", async () => {
|
||||
const csv = makeCsv([["A", 1]]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "donut" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("uses default kind bar when no kind specified", async () => {
|
||||
const csv = makeCsv([
|
||||
["A", 10],
|
||||
["B", 20],
|
||||
]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("generates a chart from JSON array input", async () => {
|
||||
const jsonData = JSON.stringify([
|
||||
{ label: "A", value: 10 },
|
||||
{ label: "B", value: 20 },
|
||||
{ label: "C", value: 30 },
|
||||
]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "data.json",
|
||||
contentType: "application/json",
|
||||
content: Buffer.from(jsonData),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("respects custom title", async () => {
|
||||
const csv = makeCsv([["A", 10]]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ title: "Sales Report" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
const csv = makeCsv([["A", 10]]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/chart-maker",
|
||||
headers: { "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const CSV_A = readFixture(fixtures.data.csvA);
|
||||
const CSV_B = readFixture(fixtures.data.csvB);
|
||||
|
||||
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/files/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 400 '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/files/create-zip",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.error).toMatch(/at least 2/i);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const CSV = readFixture(fixtures.data.csv);
|
||||
const TSV = readFixture(fixtures.data.tsv);
|
||||
const XLSX_FIXTURE = readFixture(fixtures.document.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/files/csv-excel",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe("csv-excel (pure JS, no skipIf)", () => {
|
||||
it("converts CSV to XLSX with PK magic and reloadable content", async () => {
|
||||
const res = await runTool("tiny.csv", CSV);
|
||||
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);
|
||||
|
||||
// XLSX is a ZIP containing xl/worksheets/sheet1.xml.
|
||||
// Parse the zip and verify the first sheet's XML has the header "name".
|
||||
const zip = new AdmZip(Buffer.from(dl.rawPayload));
|
||||
const sheetEntry = zip.getEntry("xl/worksheets/sheet1.xml");
|
||||
expect(sheetEntry).toBeDefined();
|
||||
// The shared strings table stores cell values; look there
|
||||
const sst = zip.getEntry("xl/sharedStrings.xml");
|
||||
expect(sst).toBeDefined();
|
||||
const sstXml = sst?.getData().toString("utf8") ?? "";
|
||||
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/document/formats/
|
||||
// (Sheet1 with "SnapOtter" in A1)
|
||||
const res = await runTool("tiny.xlsx", XLSX_FIXTURE, { sheet: 1 });
|
||||
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 csvText = dl.payload;
|
||||
expect(csvText).toContain("SnapOtter");
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const CSV = readFixture(fixtures.data.csv);
|
||||
const TSV = readFixture(fixtures.data.tsv);
|
||||
const JSON_FIXTURE = readFixture(fixtures.data.json);
|
||||
|
||||
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/files/csv-json",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe("csv-json (pure JS, no skipIf)", () => {
|
||||
it("converts CSV to JSON with the Ada row present", async () => {
|
||||
const res = await runTool("tiny.csv", CSV, { 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);
|
||||
const ada = data.find((r: Record<string, string>) => r.name === "Ada");
|
||||
expect(ada).toBeDefined();
|
||||
expect(ada.age).toBe("36");
|
||||
}, 30_000);
|
||||
|
||||
it("converts JSON to CSV containing name,age header", async () => {
|
||||
const res = await runTool("tiny.json", JSON_FIXTURE);
|
||||
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 csvText = dl.payload;
|
||||
expect(csvText).toContain("name,age");
|
||||
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);
|
||||
expect(res.statusCode).toBe(422);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
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 { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const CSV_A = readFixture(fixtures.data.csvA);
|
||||
const CSV_B = readFixture(fixtures.data.csvB);
|
||||
|
||||
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/files/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 400", 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(400);
|
||||
const parsed = JSON.parse(res.body);
|
||||
// Path traversal is now rejected pre-enqueue with a clean 400 InputValidationError,
|
||||
// whose message is surfaced in `error` (the legacy worker path used `details`/422).
|
||||
expect(parsed.error).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,75 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const JSON_FIXTURE = readFixture(fixtures.data.json);
|
||||
const XML_FIXTURE = readFixture(fixtures.data.xml);
|
||||
|
||||
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/files/json-xml",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe("json-xml (pure JS, no skipIf)", () => {
|
||||
it("converts JSON to XML containing <name>Ada</name>", async () => {
|
||||
const res = await runTool("tiny.json", JSON_FIXTURE, { 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 xmlText = dl.payload;
|
||||
expect(xmlText).toContain("<name>Ada</name>");
|
||||
}, 30_000);
|
||||
|
||||
it("converts XML to JSON with the people structure", async () => {
|
||||
const res = await runTool("tiny.xml", XML_FIXTURE, { 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);
|
||||
// fast-xml-parser parses into a structure with a "people" key
|
||||
expect(data.people).toBeDefined();
|
||||
const persons = data.people.person;
|
||||
expect(Array.isArray(persons)).toBe(true);
|
||||
const ada = persons.find((p: Record<string, string>) => p.name === "Ada");
|
||||
expect(ada).toBeDefined();
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const CSV_A = readFixture(fixtures.data.csvA);
|
||||
const CSV_B = readFixture(fixtures.data.csvB);
|
||||
|
||||
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/files/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/files/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 400", 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/files/merge-csvs",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.error).toMatch(/at least 2/i);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const CSV = readFixture(fixtures.data.csv);
|
||||
const TSV = readFixture(fixtures.data.tsv);
|
||||
|
||||
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(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.csv", contentType: "text/csv", content: CSV },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/files/split-csv",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe("split-csv (pure JS, no skipIf)", () => {
|
||||
it("splits with rowsPerFile 1 into a zip with 3 entries each containing the header", async () => {
|
||||
const res = await runTool({ rowsPerFile: 1, keepHeader: 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);
|
||||
|
||||
// Verify zip magic (PK header: 0x50 0x4B)
|
||||
expect(dl.rawPayload[0]).toBe(0x50);
|
||||
expect(dl.rawPayload[1]).toBe(0x4b);
|
||||
|
||||
// Verify 3 entries via adm-zip (3 data rows = 3 parts)
|
||||
const zip = new AdmZip(Buffer.from(dl.rawPayload));
|
||||
const entries = zip.getEntries();
|
||||
expect(entries.length).toBe(3);
|
||||
|
||||
// Each part should contain the header row "name,age"
|
||||
for (const entry of entries) {
|
||||
const text = entry.getData().toString("utf8");
|
||||
expect(text).toContain("name,age");
|
||||
}
|
||||
}, 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/files/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);
|
||||
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();
|
||||
// 4 rows (including header treated as data) / 2 = 2 parts
|
||||
expect(entries.length).toBe(2);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
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/files/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("converts single non-repeating record to a 1-row CSV", 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(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
expect(envelope.rows).toBe(1);
|
||||
|
||||
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 + 1 data row
|
||||
expect(lines.length).toBe(2);
|
||||
expect(text).toContain("single");
|
||||
expect(text).toContain("value");
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const YAML = readFixture(fixtures.data.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/files/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);
|
||||
});
|
||||
Reference in New Issue
Block a user