import { afterEach, beforeEach, describe, expect, 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"); const registry: PluginRegistry = await loadPlugins([ECHO_DIR]); const BASE = "http://localhost/api/v1"; type App = ReturnType; interface CallOpts { body?: unknown; cookie?: string; token?: string; headers?: Record; form?: FormData; } async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise { const headers: Record = { ...o.headers }; if (o.cookie) { headers.cookie = o.cookie; headers["x-requested-with"] ??= "sttracker"; } if (o.token) headers.authorization = `Bearer ${o.token}`; 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 { 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; 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[]; 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" }, headers: { "x-requested-with": "" } }); expect(res.status).toBe(403); }); 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])], 'wena;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 form2 = new FormData(); form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" })); form2.set("entity", "entry"); form2.set("entityId", "0190f000-0000-7000-8000-000000000000"); expect((await call(app, "POST", "attachments", { cookie: ana, form: form2 })).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("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> }; expect(doc.openapi).toStartWith("3."); expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy(); expect(doc.paths["/api/v1/entries/{id}"]?.patch).toBeTruthy(); }); 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; } }); });