import { readdirSync } from "node:fs"; import type { AddressInfo } from "node:net"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { parseTokens } from "../src/config.ts"; import { createHttpServer } from "../src/http.ts"; import { extensionOf, safeName } from "../src/submissions.ts"; import { book, rig, type Rig } from "./helpers.ts"; const T = { reader: "r".repeat(24), agent: "a".repeat(24), editor: "e".repeat(24), admin: "d".repeat(24), intake: "i".repeat(24) }; const MAX = 1024; let r: Rig; let base: string; let server: ReturnType; beforeAll(async () => { r = rig(); r.put(book("Mistborn")); r.service.scan(); server = createHttpServer(r.service, { tokens: parseTokens(Object.entries(T).map(([k, v]) => `${k}:${v}`).join(",")), submissionMaxBytes: MAX, }); await new Promise((ok) => server.listen(0, "127.0.0.1", ok)); base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(() => { server.close(); r.done(); }); const call = (method: string, path: string, token: string, body?: unknown) => fetch(base + path, { method, headers: { authorization: `Bearer ${token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const inquiry = (extra: Record = {}) => ({ kind: "inquiry", type: "job", name: "Jane Doe", email: "jane@example.com", fields: [{ label: "Details", value: "A role on the platform team." }], ...extra, }); describe("submissions", () => { it("lets intake file a submission and nothing else", async () => { const res = await call("POST", "/api/submissions", T.intake, inquiry()); expect(res.status).toBe(201); const { id, receipt } = (await res.json()) as { id: number; receipt: string }; expect(id).toBeGreaterThan(0); expect(receipt.length).toBeGreaterThan(20); for (const path of ["/api/submissions", `/api/submissions/${id}`, `/api/submissions/${id}/attachment`, "/api/notes", "/api/note?type=library-item&slug=mistborn", "/api/schema", "/api/redirects", "/api/status", "/api/held", "/attachments/x.png"]) { expect((await call("GET", path, T.intake)).status, path).toBe(403); } expect((await call("POST", `/api/submissions/${id}/handled`, T.intake, { handled: true })).status).toBe(403); expect((await call("POST", "/api/changes", T.intake, { op: "create", data: book("X") })).status).toBe(403); expect((await call("POST", "/api/syncs", T.intake, { job: "x", ok: true })).status).toBe(403); expect((await call("POST", "/mcp", T.intake, {})).status).toBe(403); }); it("keeps readers, agents and editors out of the saved copies", async () => { for (const t of [T.reader, T.agent, T.editor]) { expect((await call("GET", "/api/submissions", t)).status).toBe(403); expect((await call("POST", "/api/submissions", t, inquiry())).status).toBe(403); expect((await call("GET", "/api/submissions/1", t)).status).toBe(403); } expect((await call("GET", "/api/submissions", "nope".repeat(8))).status).toBe(401); }); it("records delivery once, only with the matching receipt", async () => { const { id, receipt } = (await (await call("POST", "/api/submissions", T.intake, inquiry())).json()) as { id: number; receipt: string }; expect((await call("POST", `/api/submissions/${id}/delivery`, T.intake, { receipt: "wrong", status: "sent" })).status).toBe(403); expect((await call("POST", `/api/submissions/${id}/delivery`, T.intake, { status: "sent" })).status).toBe(403); expect((await call("POST", `/api/submissions/${id}/delivery`, T.intake, { receipt, status: "failed", error: "SMTP said no" })).status).toBe(200); expect((await call("POST", `/api/submissions/${id}/delivery`, T.intake, { receipt, status: "sent" })).status).toBe(409); const s = (await (await call("GET", `/api/submissions/${id}`, T.admin)).json()) as { delivery: string; deliveryError: string }; expect(s.delivery).toBe("failed"); expect(s.deliveryError).toBe("SMTP said no"); expect(JSON.stringify(s)).not.toContain(receipt); }); it("lists unhandled first, newest first, and marks handled", async () => { const list = async () => ((await (await call("GET", "/api/submissions", T.admin)).json()) as { counts: { unhandled: number }; submissions: { id: number; handled: boolean }[] }); const before = await list(); const ids = before.submissions.map((s) => s.id); expect(ids).toEqual([...ids].sort((a, b) => b - a)); const oldest = ids[ids.length - 1]!; const res = await call("POST", `/api/submissions/${oldest}/handled`, T.admin, { handled: true }); expect(res.status).toBe(200); const after = await list(); expect(after.submissions[after.submissions.length - 1]!.id).toBe(oldest); expect(after.submissions[after.submissions.length - 1]!.handled).toBe(true); expect(after.counts.unhandled).toBe(before.counts.unhandled - 1); expect((await call("POST", "/api/submissions/9999/handled", T.admin, { handled: true })).status).toBe(404); }); it("validates what it saves", async () => { expect((await call("POST", "/api/submissions", T.intake, inquiry({ email: "not an email" }))).status).toBe(400); expect((await call("POST", "/api/submissions", T.intake, inquiry({ kind: "spam" }))).status).toBe(400); expect((await call("POST", "/api/submissions", T.intake, inquiry({ name: "" }))).status).toBe(400); }); it("saves an attachment under a safe name and serves it as a download", async () => { const data = Buffer.from("1 Sol Ring\n1 Command Tower\n").toString("base64"); const res = await call("POST", "/api/submissions", T.intake, inquiry({ attachment: { name: "../../etc/deck list.txt", data } })); expect(res.status).toBe(201); const { id } = (await res.json()) as { id: number }; const files = readdirSync(join(r.dir, "submissions")); expect(files.some((f) => f.startsWith(`${id}-`) && f.endsWith(".txt"))).toBe(true); const s = (await (await call("GET", `/api/submissions/${id}`, T.admin)).json()) as { attachment: { name: string; size: number } }; expect(s.attachment.name).toBe("deck list.txt"); const dl = await call("GET", `/api/submissions/${id}/attachment`, T.admin); expect(dl.status).toBe(200); expect(dl.headers.get("content-disposition")).toContain("attachment;"); expect(dl.headers.get("x-content-type-options")).toBe("nosniff"); expect(await dl.text()).toBe("1 Sol Ring\n1 Command Tower\n"); }); it("turns down attachments that are too large, of an unknown type, or not base64", async () => { const big = Buffer.alloc(MAX + 1, 1).toString("base64"); const count = async () => ((await (await call("GET", "/api/submissions", T.admin)).json()) as { counts: { total: number } }).counts.total; const n = await count(); expect((await call("POST", "/api/submissions", T.intake, inquiry({ attachment: { name: "a.png", data: big } }))).status).toBe(413); expect((await call("POST", "/api/submissions", T.intake, inquiry({ attachment: { name: "a.exe", data: "AAAA" } }))).status).toBe(415); expect((await call("POST", "/api/submissions", T.intake, inquiry({ attachment: { name: "a.html", data: "AAAA" } }))).status).toBe(415); expect((await call("POST", "/api/submissions", T.intake, inquiry({ attachment: { name: "a.png", data: "