Files
STTracker/apps/server/test/api.test.ts
T

473 lines
26 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadPlugins, type PluginRegistry } from "@sttracker/core/registry";
import { forEachDialect } from "@sttracker/db/testing";
import { createApp } from "../src/app.ts";
import { loadConfig } from "../src/config.ts";
import { sessionCookie as buildSessionCookie } from "../src/http.ts";
import { safeFilename } from "../src/routes/attachments.ts";
const ECHO_DIR = join(import.meta.dir, "../../../packages/core/test/fixtures/plugins");
// The fixture dir also holds a deliberately broken plugin; silence its load warning.
const warn = spyOn(console, "warn").mockImplementation(() => {});
const registry: PluginRegistry = await loadPlugins([ECHO_DIR]);
warn.mockRestore();
const BASE = "http://localhost/api/v1";
type App = ReturnType<typeof createApp>;
interface CallOpts {
body?: unknown;
cookie?: string;
token?: string;
headers?: Record<string, string>;
form?: FormData;
/** Omit the X-Requested-With header that browsers (the web app) send on writes. */
noCsrf?: boolean;
}
async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise<Response> {
const headers: Record<string, string> = { ...o.headers };
if (o.cookie) headers.cookie = o.cookie;
if (o.token) headers.authorization = `Bearer ${o.token}`;
else if (!o.noCsrf) headers["x-requested-with"] ??= "sttracker";
let body: BodyInit | undefined;
if (o.form) body = o.form;
else if (o.body !== undefined) {
headers["content-type"] = "application/json";
body = JSON.stringify(o.body);
}
const url = path.startsWith("http") ? path : `${BASE}/${path}`;
return app.fetch(new Request(url, { method, headers, body }));
}
function sessionCookie(res: Response): string {
const set = res.headers.get("set-cookie") ?? "";
const m = /stt_session=([^;]*)/.exec(set);
if (!m) throw new Error(`no session cookie in ${set}`);
return `stt_session=${m[1]}`;
}
async function signUp(app: App, username: string, inviteCode?: string): Promise<string> {
const reg = await call(app, "POST", "auth/register", { body: { username, password: "correct horse", inviteCode } });
expect(reg.status).toBe(201);
const res = await call(app, "POST", "auth/login", { body: { username, password: "correct horse" } });
expect(res.status).toBe(200);
return sessionCookie(res);
}
test("booting without STTRACKER_SECRET throws the exact message", () => {
expect(() => loadConfig({})).toThrow("Set STTRACKER_SECRET to a long random string. It encrypts source credentials.");
});
test("safeFilename strips quotes, newlines and path characters", () => {
expect(safeFilename('a"b\r\nc/../d.png')).toBe("a_b__c_.._d.png");
expect(safeFilename("")).toBe("file");
});
test("session cookie is Secure only behind an https PUBLIC_URL", () => {
expect(buildSessionCookie("t", "https://st.example")).toContain("; Secure");
expect(buildSessionCookie("t", "http://localhost:3000")).not.toContain("Secure");
expect(buildSessionCookie("t", undefined)).toContain("HttpOnly; SameSite=Lax");
});
test("config defaults", () => {
const c = loadConfig({ STTRACKER_SECRET: "s" });
expect(c).toMatchObject({
dataDir: "./data",
databaseUrl: "sqlite://./data/sttracker.db",
port: 3000,
pluginDirs: ["plugins", "./data/plugins"],
});
const d = loadConfig({ STTRACKER_SECRET: "s", DATA_DIR: "/srv/st", PORT: "4000", PLUGIN_DIRS: "a, b" });
expect(d).toMatchObject({ databaseUrl: "sqlite:///srv/st/sttracker.db", port: 4000, pluginDirs: ["a", "b"] });
});
forEachDialect("api", (getDb) => {
let dataDir: string;
let app: App;
beforeEach(async () => {
dataDir = await mkdtemp(join(tmpdir(), "stt-api-"));
app = createApp({ db: getDb(), registry, secret: "test-secret", dataDir });
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
test("register, login cookie, GET me", async () => {
const reg = await call(app, "POST", "auth/register", { body: { username: "ana", password: "correct horse" } });
expect(reg.status).toBe(201);
const login = await call(app, "POST", "auth/login", { body: { username: "ana", password: "correct horse" } });
expect(login.status).toBe(200);
const set = login.headers.get("set-cookie")!;
expect(set).toContain("HttpOnly");
expect(set).toContain("SameSite=Lax");
expect(set).not.toContain("Secure");
const me = await call(app, "GET", "me", { cookie: sessionCookie(login) });
expect(me.status).toBe(200);
const body = (await me.json()) as Record<string, unknown>;
expect(body).toMatchObject({ username: "ana", role: "admin" });
expect(JSON.stringify(body)).not.toContain("password");
});
test("bad login is 401 JSON", async () => {
await call(app, "POST", "auth/register", { body: { username: "ana", password: "correct horse" } });
const res = await call(app, "POST", "auth/login", { body: { username: "ana", password: "nope nope" } });
expect(res.status).toBe(401);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("unauthenticated");
});
test("unauthenticated GET entries is 401 JSON", async () => {
const res = await call(app, "GET", "entries");
expect(res.status).toBe(401);
expect(res.headers.get("content-type")).toContain("application/json");
const body = (await res.json()) as { error: { code: string; message: string } };
expect(body.error.code).toBe("unauthenticated");
expect(typeof body.error.message).toBe("string");
});
test("quick add, then the list shows a draft", async () => {
const cookie = await signUp(app, "ana");
const add = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune", mediaType: "book" } });
expect(add.status).toBe(201);
const list = await call(app, "GET", "entries?draft=true", { cookie });
expect(list.status).toBe(200);
const body = (await list.json()) as { items: { draft: boolean; work: { title: string } }[] };
expect(body.items).toHaveLength(1);
expect(body.items[0]).toMatchObject({ draft: true, work: { title: "Dune" } });
});
test("PATCH rating 150 is 400 validation; 80 works", async () => {
const cookie = await signUp(app, "ana");
const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string };
const bad = await call(app, "PATCH", `entries/${add.id}`, { cookie, body: { rating: 150 } });
expect(bad.status).toBe(400);
const err = (await bad.json()) as { error: { code: string; message: string } };
expect(err.error.code).toBe("validation");
expect(err.error.message).toContain("rating");
const ok = await call(app, "PATCH", `entries/${add.id}`, { cookie, body: { rating: 80 } });
expect(ok.status).toBe(200);
expect(((await ok.json()) as { rating: number }).rating).toBe(80);
});
test("an app token with read can GET but cannot PATCH (403)", async () => {
const cookie = await signUp(app, "ana");
const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string };
const mk = await call(app, "POST", "me/tokens", { cookie, body: { name: "script", scopes: ["read"] } });
expect(mk.status).toBe(201);
const { token } = (await mk.json()) as { token: string };
expect((await call(app, "GET", `entries/${add.id}`, { token })).status).toBe(200);
const res = await call(app, "PATCH", `entries/${add.id}`, { token, body: { rating: 10 } });
expect(res.status).toBe(403);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("forbidden");
const list = (await (await call(app, "GET", "me/tokens", { cookie })).json()) as Record<string, unknown>[];
expect(list).toHaveLength(1);
expect(JSON.stringify(list)).not.toContain("hash");
expect((await call(app, "GET", "entries", { token: "stt_bogus" })).status).toBe(401);
});
test("another user's entry is 404", async () => {
const ana = await signUp(app, "ana");
const inv = await call(app, "POST", "admin/invites", { cookie: ana });
expect(inv.status).toBe(201);
const { code } = (await inv.json()) as { code: string };
const bob = await signUp(app, "bob", code);
const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string };
expect((await call(app, "GET", `entries/${add.id}`, { cookie: bob })).status).toBe(404);
const patch = await call(app, "PATCH", `entries/${add.id}`, { cookie: bob, body: { rating: 10 } });
expect(patch.status).toBe(404);
expect(((await patch.json()) as { error: { code: string } }).error.code).toBe("not_found");
expect((await call(app, "POST", "admin/invites", { cookie: bob })).status).toBe(403);
});
test("cookie-authenticated writes need the CSRF header", async () => {
const cookie = await signUp(app, "ana");
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, noCsrf: true });
expect(res.status).toBe(403);
// Login and register too (login CSRF).
const login = await call(app, "POST", "auth/login", { body: { username: "ana", password: "correct horse" }, noCsrf: true });
expect(login.status).toBe(403);
expect(login.headers.get("set-cookie")).toBeNull();
expect((await call(app, "POST", "auth/register", { body: { username: "zed", password: "correct horse" }, noCsrf: true })).status).toBe(403);
// Bearer requests are exempt.
const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "w", scopes: ["write"] } })).json()) as { token: string };
expect((await call(app, "POST", "entries/quick", { token, body: { title: "Dune" } })).status).toBe(201);
});
test("login is rate limited to 10 per minute", async () => {
let last = 0;
for (let i = 0; i < 11; i++) {
last = (await call(app, "POST", "auth/login", { body: { username: "x", password: "y" } })).status;
}
expect(last).toBe(429);
});
test("JSON bodies over 1 MB are rejected", async () => {
const cookie = await signUp(app, "ana");
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "x".repeat(1_100_000) } });
expect(res.status).toBe(413);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation");
});
test("sources: create with echo, run twice gives 409, secrets never returned", async () => {
const cookie = await signUp(app, "ana");
const plugins = (await (await call(app, "GET", "plugins", { cookie })).json()) as { id: string }[];
expect(plugins.map((p) => p.id)).toContain("echo");
const mk = await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E", config: { label: "x", token: "sekrit" } } });
expect(mk.status).toBe(201);
const { id } = (await mk.json()) as { id: string };
const listed = await (await call(app, "GET", "sources", { cookie })).text();
expect(listed).not.toContain("sekrit");
expect(listed).toContain('"hasCredentials":true');
expect((await call(app, "POST", `sources/${id}/run`, { cookie })).status).toBe(202);
const again = await call(app, "POST", `sources/${id}/run`, { cookie });
expect(again.status).toBe(409);
expect(await again.json()).toEqual({
error: { code: "conflict", message: "A sync for this source is already queued or running." },
});
expect((await call(app, "GET", `sources/${id}/runs`, { cookie })).status).toBe(200);
expect((await call(app, "DELETE", `sources/${id}`, { cookie })).status).toBe(204);
expect((await (await call(app, "GET", "sources", { cookie })).json()) as unknown[]).toHaveLength(0);
expect((await call(app, "POST", `sources/${id}/run`, { cookie })).status).toBe(404);
});
test("sources: unknown plugin is 400; file import without importFile is 400", async () => {
const cookie = await signUp(app, "ana");
const bad = await call(app, "POST", "sources", { cookie, body: { pluginId: "nope", name: "E" } });
expect(bad.status).toBe(400);
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
const form = new FormData();
form.set("file", new File(["a,b"], "x.csv", { type: "text/csv" }));
expect((await call(app, "POST", `sources/${id}/import`, { cookie, form })).status).toBe(400);
});
test("export bundle is a zip; csv picks a file", async () => {
const cookie = await signUp(app, "ana");
const res = await call(app, "GET", "export/bundle", { cookie });
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/zip");
const bytes = new Uint8Array(await res.arrayBuffer());
expect([bytes[0], bytes[1]]).toEqual([0x50, 0x4b]);
const csv = await call(app, "GET", "export/csv?file=entries.csv", { cookie });
expect(csv.status).toBe(200);
expect(csv.headers.get("content-type")).toContain("text/csv");
expect((await call(app, "GET", "export/csv?file=nope.csv", { cookie })).status).toBe(404);
});
test("attachments: upload to own entry, serve with safe headers; another user's entry is 404", async () => {
const ana = await signUp(app, "ana");
const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string };
const form = new FormData();
form.set("file", new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'we<ird>na;me?.png', { type: "image/png" }));
form.set("entity", "entry");
form.set("entityId", add.id);
const up = await call(app, "POST", "attachments", { cookie: ana, form });
expect(up.status).toBe(201);
const att = (await up.json()) as { id: string; links: { id: string }[] };
const got = await call(app, "GET", `attachments/${att.id}`, { cookie: ana });
expect(got.status).toBe(200);
expect(got.headers.get("content-type")).toBe("image/png");
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
expect(got.headers.get("content-disposition")).toBe('inline; filename="we_ird_na_me_.png"');
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
const bobs = (await (await call(app, "POST", "entries/quick", { cookie: bob, body: { title: "Bob's" } })).json()) as { id: string };
const form2 = new FormData();
form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" }));
form2.set("entity", "entry");
form2.set("entityId", bobs.id);
expect((await call(app, "POST", "attachments", { cookie: ana, form: form2 })).status).toBe(404);
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: bob })).status).toBe(404);
expect((await call(app, "DELETE", `attachments/${att.id}/links/${att.links[0]!.id}`, { cookie: ana })).status).toBe(204);
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404);
});
test("auth/status says whether any user exists, without auth", async () => {
expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: false });
await signUp(app, "ana");
expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: true });
});
test("attachments list: owner-scoped per entity; a foreign or unknown entity is 404", async () => {
const ana = await signUp(app, "ana");
const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string };
const q = `attachments?entity=entry&entityId=${add.id}`;
expect(await (await call(app, "GET", q, { cookie: ana })).json()).toEqual([]);
const form = new FormData();
form.set("file", new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" }));
form.set("entity", "entry");
form.set("entityId", add.id);
const up = (await (await call(app, "POST", "attachments", { cookie: ana, form })).json()) as { id: string };
const list = (await (await call(app, "GET", q, { cookie: ana })).json()) as { id: string; mime: string; filename: string; links: { id: string }[] }[];
expect(list.map((a) => [a.id, a.mime, a.filename])).toEqual([[up.id, "image/png", "shot.png"]]);
expect(list[0]!.links.length).toBe(1);
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
expect((await call(app, "GET", q, { cookie: bob })).status).toBe(404);
expect((await call(app, "GET", "attachments?entity=entry&entityId=nope", { cookie: ana })).status).toBe(404);
expect((await call(app, "GET", `attachments?entity=bogus&entityId=${add.id}`, { cookie: ana })).status).toBe(400);
expect((await call(app, "GET", q)).status).toBe(401);
});
test("me/preferences: defaults, validated PATCH, and quick add uses defaultVisibility", async () => {
const ana = await signUp(app, "ana");
expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "5half", defaultVisibility: "private" });
const bad = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "7" } });
expect(bad.status).toBe(400);
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { extra: 1 } })).status).toBe(400);
const ok = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "10" } });
expect(await ok.json()).toEqual({ ratingScale: "10", defaultVisibility: "private" });
await call(app, "PATCH", "me/preferences", { cookie: ana, body: { defaultVisibility: "friends" } });
expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "10", defaultVisibility: "friends" });
const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { visibility: string };
expect(e.visibility).toBe("friends");
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "5" }, noCsrf: true })).status).toBe(403);
});
test("log: engagements newest first with keyset paging; copies carry workTitle", async () => {
const ana = await signUp(app, "ana");
const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string; workId: string };
for (const value of ["2026-01-02", "2026-03-04"]) {
const r = await call(app, "POST", `entries/${e.id}/engagements`, { cookie: ana, body: { finishedAt: { value, precision: "day" } } });
expect(r.status).toBe(201);
}
const page1 = (await (await call(app, "GET", "log?limit=1", { cookie: ana })).json()) as { items: { at: string; title: string; kind: string }[]; nextCursor: string };
expect(page1.items).toEqual([expect.objectContaining({ kind: "engagement", at: "2026-03-04", title: "Dune" })]);
const page2 = (await (await call(app, "GET", `log?limit=1&cursor=${page1.nextCursor}`, { cookie: ana })).json()) as { items: { at: string }[]; nextCursor: string | null };
expect(page2.items.map((i) => i.at)).toEqual(["2026-01-02"]);
expect(page2.nextCursor).toBeNull();
expect((await call(app, "GET", "log?cursor=bad", { cookie: ana })).status).toBe(400);
expect((await call(app, "GET", "log")).status).toBe(401);
await call(app, "POST", "copies", { cookie: ana, body: { workId: e.workId, format: "Paperback" } });
expect(await (await call(app, "GET", "copies", { cookie: ana })).json()).toEqual([expect.objectContaining({ workTitle: "Dune", format: "Paperback" })]);
});
test("plugins: manifests say whether the source signs in with a device code", async () => {
const ana = await signUp(app, "ana");
const list = (await (await call(app, "GET", "plugins", { cookie: ana })).json()) as { id: string; deviceAuth: boolean; config: unknown[] }[];
expect(list.find((p) => p.id === "echo")).toEqual(expect.objectContaining({ deviceAuth: false, config: expect.any(Array) }));
});
test("JSON bodies need Content-Type application/json (415)", async () => {
const res = await app.fetch(
new Request(`${BASE}/auth/register`, {
method: "POST",
headers: { "content-type": "text/plain", "x-requested-with": "sttracker" },
body: JSON.stringify({ username: "ana", password: "correct horse" }),
}),
);
expect(res.status).toBe(415);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("unsupported_media_type");
});
test("a malformed session cookie is 401, not 500", async () => {
expect((await call(app, "GET", "me", { headers: { cookie: "stt_session=%" } })).status).toBe(401);
});
test("cookie plus a bogus Bearer on a write is 401", async () => {
const cookie = await signUp(app, "ana");
const res = await call(app, "POST", "entries/quick", { cookie, token: "stt_bogus", body: { title: "Dune" } });
expect(res.status).toBe(401);
});
test("a write-scope token can write; history records the token, field_sources the user", async () => {
const cookie = await signUp(app, "ana");
const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string };
const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "script", scopes: ["write", "read"] } })).json()) as { token: string };
const res = await call(app, "PATCH", `entries/${add.id}`, { token, body: { rating: 70 } });
expect(res.status).toBe(200);
expect(((await res.json()) as { fieldSources: Record<string, string> }).fieldSources.rating).toBe("user");
const hist = (await (await call(app, "GET", `entries/${add.id}/history`, { token })).json()) as { actor: string }[];
expect(hist[0]!.actor).toBe("token:script");
});
test("DELETE me/tokens/:id is 404 for unknown or foreign tokens", async () => {
const ana = await signUp(app, "ana");
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
const { id } = (await (await call(app, "POST", "me/tokens", { cookie: ana, body: { name: "t", scopes: ["read"] } })).json()) as { id: string };
expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: bob })).status).toBe(404);
expect((await call(app, "DELETE", "me/tokens/nope", { cookie: ana })).status).toBe(404);
expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: ana })).status).toBe(204);
});
test("admin-only routes are 403 for members", async () => {
const ana = await signUp(app, "ana");
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
expect((await call(app, "POST", "admin/plugins/reload", { cookie: bob })).status).toBe(403);
expect((await call(app, "POST", "admin/invites", { cookie: bob })).status).toBe(403);
const warn = spyOn(console, "warn").mockImplementation(() => {});
try {
expect((await call(app, "POST", "admin/plugins/reload", { cookie: ana })).status).toBe(200);
} finally {
warn.mockRestore();
}
});
test("a paused source without credentials stays listed and can be resumed", async () => {
const cookie = await signUp(app, "ana");
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
expect((await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "paused" } })).status).toBe(200);
const listed = (await (await call(app, "GET", "sources", { cookie })).json()) as { id: string; status: string }[];
expect(listed).toMatchObject([{ id, status: "paused" }]);
const resumed = await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "active" } });
expect(((await resumed.json()) as { status: string }).status).toBe("active");
});
test("a multipart upload over 50 MB is 413", async () => {
const cookie = await signUp(app, "ana");
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
const form = new FormData();
form.set("file", new File([new Uint8Array(50 * 1024 * 1024 + 1)], "big.csv", { type: "text/csv" }));
const res = await call(app, "POST", `sources/${id}/import`, { cookie, form });
expect(res.status).toBe(413);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation");
});
test("openapi.json lists /api/v1/entries", async () => {
const res = await call(app, "GET", "openapi.json");
expect(res.status).toBe(200);
const doc = (await res.json()) as { openapi: string; paths: Record<string, Record<string, { summary: string }>> };
expect(doc.openapi).toStartWith("3.");
expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy();
expect(doc.paths["/api/v1/entries/{id}"]?.patch).toBeTruthy();
const up = doc.paths["/api/v1/attachments"]?.post as unknown as {
requestBody: { content: Record<string, { schema: { properties: Record<string, unknown>; required: string[] } }> };
};
const mp = up.requestBody.content["multipart/form-data"]!.schema;
expect(Object.keys(mp.properties).sort()).toEqual(["caption", "entity", "entityId", "file"]);
expect(mp.required).toEqual(["file", "entity", "entityId"]);
});
test("about lists attributions; ingest and unknown routes are 404 JSON", async () => {
expect((await call(app, "GET", "about")).status).toBe(200);
const ing = await call(app, "POST", "http://localhost/ingest/echo/hook");
expect(ing.status).toBe(404);
expect(((await ing.json()) as { error: { code: string } }).error.code).toBe("not_found");
expect((await call(app, "GET", "nope")).status).toBe(404);
});
});
describe("internal errors", () => {
test("are 500 with a generic message", async () => {
const broken = { k: null, dialect: "sqlite", close: async () => {} } as never;
const app = createApp({ db: broken, registry, secret: "s", dataDir: "/nonexistent" });
const orig = console.error;
console.error = () => {};
try {
const res = await call(app, "POST", "auth/login", { body: { username: "a", password: "b" } });
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: { code: "internal", message: "Something went wrong on the server." } });
} finally {
console.error = orig;
}
});
});