mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
e3b0cf60c5
- Sync scheduler in the content service with a per-job cache (sync_cache), sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek, cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading progress, Bambuddy filament and Homebox material stock. A note's own value always wins over a synced one. - PersonalReader: updates only Library notes with a matching anilistId, changes to Greg's notes are held for review; Reading series with no note show as suggestions on the admin Overview. - /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the content service (intake token) plus email over SMTP. Orders are re-checked against the live notes: accepted only when the category's ordersOpen is true and the line has capacity. - Admin Inbox for saved submissions. Options' inStock is optional so synced stock can fill it. All settings from process.env; .env.example per service. - Sources could not be reached from the build environment; parsers are tested against fixtures labelled as not live. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
146 lines
7.8 KiB
TypeScript
146 lines
7.8 KiB
TypeScript
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<typeof createHttpServer>;
|
|
|
|
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<void>((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<string, unknown> = {}) => ({
|
|
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: "<script>" } }))).status).toBe(400);
|
|
expect(await count()).toBe(n);
|
|
});
|
|
});
|
|
|
|
describe("attachment names", () => {
|
|
it("strips paths and odd characters", () => {
|
|
expect(safeName("C:\\Users\\x\\photo.png")).toBe("photo.png");
|
|
expect(safeName("..")).toBe("attachment");
|
|
expect(safeName("a\r\nb.txt")).toBe("a_b.txt");
|
|
expect(extensionOf("deck.DEK")).toBe("dek");
|
|
expect(extensionOf("x.svg")).toBeNull();
|
|
expect(extensionOf("noext")).toBeNull();
|
|
});
|
|
});
|