From 5f7d54b805c65035cc132fbd7b4e90a62f14ce20 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:30:43 +0100 Subject: [PATCH] fix(server): token actor, login CSRF, 415, cookie decoding, admin invites, production default - API writes pass token: for Bearer requests and "user" for sessions. - X-Requested-With: sttracker on every non-Bearer write, login and register included. - JSON-body routes reject non-JSON content types with 415. - A malformed stt_session cookie counts as no cookie (401), not 500. - DELETE me/tokens/:id is 404 for unknown or foreign ids; admin/invites is admin-only. - Bun.serve development mode only with NODE_ENV=development or --hot. - OpenAPI documents multipart fields. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/http.ts | 44 ++++++++--- apps/server/src/main.ts | 4 +- apps/server/src/openapi.ts | 10 ++- apps/server/src/routes/attachments.ts | 1 + apps/server/src/routes/auth.ts | 4 +- apps/server/src/routes/copies.ts | 8 +- apps/server/src/routes/engagements.ts | 12 +-- apps/server/src/routes/entries.ts | 12 +-- apps/server/src/routes/lists.ts | 18 ++--- apps/server/test/api.test.ts | 107 ++++++++++++++++++++++++-- 10 files changed, 174 insertions(+), 46 deletions(-) diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 77ec8ba..27b20e4 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,5 +1,6 @@ -import { getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth"; +import { authenticateAppToken, getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth"; import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors"; +import type { Actor } from "@sttracker/core/history"; import { isUniqueViolation } from "@sttracker/core/matching"; import type { PluginRegistry } from "@sttracker/core/registry"; import type { Db } from "@sttracker/db/db"; @@ -33,6 +34,8 @@ export interface Ctx { query: Q; /** The authenticated user. Throws on `auth: "none"` routes, which have none. */ user: User; + /** Who writes: `token:` for Bearer requests, `"user"` for sessions. Pass it to every core write. */ + actor: Actor; /** The session token, when the request was authenticated by cookie. */ sessionToken: string | null; /** Parsed multipart body, for `multipart: true` routes. */ @@ -51,6 +54,8 @@ export interface RouteDef { query?: z.ZodType; /** Parse the body as multipart form data (upload limit) instead of JSON. */ multipart?: boolean; + /** Non-file multipart fields, for the OpenAPI document (handlers validate them). Every multipart route also takes `file`. */ + formFields?: z.ZodObject; /** Rate limit per client IP (login, register). */ rateLimited?: boolean; /** Only admins may call it (403 otherwise). */ @@ -132,7 +137,13 @@ function readCookie(req: Request, name: string): string | null { if (!header) return null; for (const part of header.split(";")) { const i = part.indexOf("="); - if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null; + if (i > 0 && part.slice(0, i).trim() === name) { + try { + return decodeURIComponent(part.slice(i + 1).trim()) || null; + } catch { + return null; // malformed percent-encoding: treat as no cookie + } + } } return null; } @@ -185,16 +196,20 @@ export function rateLimiter(limit: number, windowMs: number): (key: string) => b const SCOPES: TokenScope[] = ["read", "write", "ingest"]; /** Resolve the caller for `mode`. Returns the user and the session token used, if any. */ -async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ user: User | null; sessionToken: string | null }> { - if (mode === "none") return { user: null, sessionToken: null }; +async function authenticate( + req: Request, + mode: AuthMode, + db: Db, +): Promise<{ user: User | null; sessionToken: string | null; actor: Actor }> { + if (mode === "none") return { user: null, sessionToken: null, actor: "user" }; const authz = req.headers.get("authorization"); const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null; const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined]; if (kind !== "session" && bearer) { const scope = scopeName!; - const user = await verifyAppToken(db, bearer, scope); - if (user) return { user, sessionToken: null }; + const auth = await authenticateAppToken(db, bearer, scope); + if (auth) return { user: auth.user, sessionToken: null, actor: `token:${auth.tokenName}` }; for (const other of SCOPES) { if (other !== scope && (await verifyAppToken(db, bearer, other))) { throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`); @@ -207,11 +222,7 @@ async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ use const token = readCookie(req, SESSION_COOKIE); const user = token ? await getSessionUser(db, token) : null; if (!user) throw new HttpError(401, "unauthenticated", "Sign in first."); - // CSRF: browsers cannot set this header cross-site without a CORS preflight, which we never grant. - if (req.method !== "GET" && req.method !== "HEAD" && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) { - throw new HttpError(403, "forbidden", `Cookie-authenticated requests must send the header X-Requested-With: ${CSRF_VALUE}.`); - } - return { user, sessionToken: token }; + return { user, sessionToken: token, actor: "user" }; } interface Compiled { @@ -261,7 +272,13 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/ if (def.rateLimited && !limit(`${def.path}|${ip}`)) { return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" }); } - const { user, sessionToken } = await authenticate(req, def.auth, deps.db); + // CSRF: every non-Bearer write (login and register included) must carry this header. Browsers cannot add it + // cross-site without a CORS preflight, which the server never grants. Bearer requests are not ambient, so exempt. + const bearer = req.headers.get("authorization")?.startsWith("Bearer ") ?? false; + if (req.method !== "GET" && req.method !== "HEAD" && !bearer && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) { + throw new HttpError(403, "forbidden", `Browser requests that change data must send the header X-Requested-With: ${CSRF_VALUE}.`); + } + const { user, sessionToken, actor } = await authenticate(req, def.auth, deps.db); if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that."); let body: unknown; @@ -279,6 +296,8 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/ const bytes = await readLimited(req, MAX_JSON_BYTES); let raw: unknown; if (bytes.byteLength > 0) { + const type = (req.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase(); + if (type !== "application/json") throw new HttpError(415, "unsupported_media_type", "Send the body as Content-Type: application/json."); try { raw = JSON.parse(new TextDecoder().decode(bytes)); } catch { @@ -299,6 +318,7 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/ if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`); return user; }, + actor, sessionToken, get form(): FormData { if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index c4ebf9a..b4c0420 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -40,7 +40,9 @@ const backupTimer = setInterval(() => void ensureBackup(), BACKUP_CHECK_MS); const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl }); const server = Bun.serve({ port: config.port, - development: process.env.NODE_ENV !== "production", + // Production unless asked: dev mode adds HMR and verbose error pages. + // `bun --hot` puts the flag in execArgv, not Bun.argv. + development: process.env.NODE_ENV === "development" || process.execArgv.includes("--hot"), routes: { "/api/*": (req, srv) => app.fetch(req, srv), "/ingest/*": (req, srv) => app.fetch(req, srv), diff --git a/apps/server/src/openapi.ts b/apps/server/src/openapi.ts index 3012d2f..291b71a 100644 --- a/apps/server/src/openapi.ts +++ b/apps/server/src/openapi.ts @@ -46,7 +46,15 @@ export function buildOpenApi(routes: RouteDef[], prefix = "/api/v1"): JsonSchema }; if (parameters.length > 0) op.parameters = parameters; if (r.body) op.requestBody = { required: !r.body.safeParse(undefined).success, content: { "application/json": { schema: toSchema(r.body) } } }; - if (r.multipart) op.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } }; + if (r.multipart) { + const fields = r.formFields ? (toSchema(r.formFields) as { properties?: JsonSchema; required?: string[] }) : {}; + const schema = { + type: "object", + properties: { file: { type: "string", format: "binary" }, ...fields.properties }, + required: ["file", ...(fields.required ?? [])], + }; + op.requestBody = { required: true, content: { "multipart/form-data": { schema } } }; + } (paths[path] ??= {})[r.method.toLowerCase()] = op; } return { diff --git a/apps/server/src/routes/attachments.ts b/apps/server/src/routes/attachments.ts index f759980..cf6ad43 100644 --- a/apps/server/src/routes/attachments.ts +++ b/apps/server/src/routes/attachments.ts @@ -24,6 +24,7 @@ export const attachmentRoutes = [ summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.", auth: "any:write", multipart: true, + formFields: fields, handler: async ({ deps, user, form }) => { const file = formFile(form); const f = fields.parse({ diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index f691def..3032b22 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -1,4 +1,5 @@ import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth"; +import { NotFoundError } from "@sttracker/core/errors"; import { z } from "zod"; import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts"; @@ -61,7 +62,7 @@ export const authRoutes = [ summary: "Revoke an app token.", auth: "session", handler: async ({ deps, user, params }) => { - await revokeAppToken(deps.db, user.id, params.id!); + if (!(await revokeAppToken(deps.db, user.id, params.id!))) throw new NotFoundError("app token"); return noContent(); }, }), @@ -70,6 +71,7 @@ export const authRoutes = [ path: "admin/invites", summary: "Create an invite code (admin). The code is returned once.", auth: "session", + admin: true, body: z.object({ ttlDays: z.number().int().min(1).max(365).optional() }).strict().optional(), handler: async ({ deps, user, body }) => json({ code: await createInvite(deps.db, user.id, body?.ttlDays) }, 201), }), diff --git a/apps/server/src/routes/copies.ts b/apps/server/src/routes/copies.ts index 6de9f3b..ee62ddf 100644 --- a/apps/server/src/routes/copies.ts +++ b/apps/server/src/routes/copies.ts @@ -35,7 +35,7 @@ export const copyRoutes = [ summary: "Record an owned copy.", auth: "any:write", body: copyPatch, - handler: async ({ deps, user, body }) => json(await createCopy(deps.db, user.id, body as CopyArg), 201), + handler: async ({ deps, user, body, actor }) => json(await createCopy(deps.db, user.id, body as CopyArg, actor), 201), }), route({ method: "PATCH", @@ -43,15 +43,15 @@ export const copyRoutes = [ summary: "Edit an owned copy.", auth: "any:write", body: copyPatch, - handler: async ({ deps, user, params, body }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg), + handler: async ({ deps, user, params, body, actor }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg, actor), }), route({ method: "DELETE", path: "copies/:id", summary: "Soft-delete an owned copy.", auth: "any:write", - handler: async ({ deps, user, params }) => { - await softDeleteCopy(deps.db, user.id, params.id!); + handler: async ({ deps, user, params, actor }) => { + await softDeleteCopy(deps.db, user.id, params.id!, actor); return noContent(); }, }), diff --git a/apps/server/src/routes/engagements.ts b/apps/server/src/routes/engagements.ts index a239947..5ff5ca3 100644 --- a/apps/server/src/routes/engagements.ts +++ b/apps/server/src/routes/engagements.ts @@ -30,8 +30,8 @@ export const engagementRoutes = [ summary: "Log a read, watch or play.", auth: "any:write", body: engagementPatch, - handler: async ({ deps, user, params, body }) => - json(await addEngagement(deps.db, user.id, params.id!, body as Parameters[3], "user"), 201), + handler: async ({ deps, user, params, body, actor }) => + json(await addEngagement(deps.db, user.id, params.id!, body as Parameters[3], actor), 201), }), route({ method: "PATCH", @@ -39,16 +39,16 @@ export const engagementRoutes = [ summary: "Edit an engagement.", auth: "any:write", body: engagementPatch, - handler: async ({ deps, user, params, body }) => - updateEngagement(deps.db, user.id, params.id!, body as Parameters[3], "user"), + handler: async ({ deps, user, params, body, actor }) => + updateEngagement(deps.db, user.id, params.id!, body as Parameters[3], actor), }), route({ method: "DELETE", path: "engagements/:id", summary: "Soft-delete an engagement.", auth: "any:write", - handler: async ({ deps, user, params }) => { - await softDeleteEngagement(deps.db, user.id, params.id!, "user"); + handler: async ({ deps, user, params, actor }) => { + await softDeleteEngagement(deps.db, user.id, params.id!, actor); return noContent(); }, }), diff --git a/apps/server/src/routes/entries.ts b/apps/server/src/routes/entries.ts index c8c5657..6b81c21 100644 --- a/apps/server/src/routes/entries.ts +++ b/apps/server/src/routes/entries.ts @@ -60,9 +60,9 @@ export const entryRoutes = [ summary: "Edit an entry. Edited fields become user-owned (syncs stop overwriting them).", auth: "any:write", body: entryPatch, - handler: async ({ deps, user, params, body }) => { + handler: async ({ deps, user, params, body, actor }) => { const cur = await getEntry(deps.db, user.id, params.id!); - await upsertEntry(deps.db, user.id, cur.workId, body as Parameters[3], "user"); + await upsertEntry(deps.db, user.id, cur.workId, body as Parameters[3], actor); return getEntry(deps.db, user.id, params.id!); }, }), @@ -71,8 +71,8 @@ export const entryRoutes = [ path: "entries/:id", summary: "Soft-delete an entry.", auth: "any:write", - handler: async ({ deps, user, params }) => { - await softDeleteEntry(deps.db, user.id, params.id!, "user"); + handler: async ({ deps, user, params, actor }) => { + await softDeleteEntry(deps.db, user.id, params.id!, actor); return noContent(); }, }), @@ -99,8 +99,8 @@ export const entryRoutes = [ summary: "Move an entry to another work (for you only).", auth: "any:write", body: z.object({ workId: z.string().min(1) }).strict(), - handler: async ({ deps, user, params, body }) => { - const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, "user"); + handler: async ({ deps, user, params, body, actor }) => { + const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, actor); return getEntry(deps.db, user.id, moved.id); }, }), diff --git a/apps/server/src/routes/lists.ts b/apps/server/src/routes/lists.ts index b260d38..98d6f0d 100644 --- a/apps/server/src/routes/lists.ts +++ b/apps/server/src/routes/lists.ts @@ -25,7 +25,7 @@ export const listRoutes = [ summary: "Create a list.", auth: "any:write", body: listPatch.required({ name: true }), - handler: async ({ deps, user, body }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }), 201), + handler: async ({ deps, user, body, actor }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }, actor), 201), }), route({ method: "GET", @@ -40,15 +40,15 @@ export const listRoutes = [ summary: "Edit a list.", auth: "any:write", body: listPatch, - handler: async ({ deps, user, params, body }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg), + handler: async ({ deps, user, params, body, actor }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg, actor), }), route({ method: "DELETE", path: "lists/:id", summary: "Soft-delete a list.", auth: "any:write", - handler: async ({ deps, user, params }) => { - await softDeleteList(deps.db, user.id, params.id!); + handler: async ({ deps, user, params, actor }) => { + await softDeleteList(deps.db, user.id, params.id!, actor); return noContent(); }, }), @@ -58,7 +58,7 @@ export const listRoutes = [ summary: "Add a work to a list.", auth: "any:write", body: z.object({ workId: z.string().min(1), note: text(10_000).nullable().optional(), position: z.number().int().min(0).optional() }).strict(), - handler: async ({ deps, user, params, body }) => json(await addListItem(deps.db, user.id, params.id!, body), 201), + handler: async ({ deps, user, params, body, actor }) => json(await addListItem(deps.db, user.id, params.id!, body, actor), 201), }), route({ method: "PATCH", @@ -66,8 +66,8 @@ export const listRoutes = [ summary: "Move a list item to a new 0-based position.", auth: "any:write", body: z.object({ position: z.number().int().min(0) }).strict(), - handler: async ({ deps, user, params, body }) => { - await moveListItem(deps.db, user.id, params.id!, body.position); + handler: async ({ deps, user, params, body, actor }) => { + await moveListItem(deps.db, user.id, params.id!, body.position, actor); return noContent(); }, }), @@ -76,8 +76,8 @@ export const listRoutes = [ path: "list-items/:id", summary: "Remove an item from a list.", auth: "any:write", - handler: async ({ deps, user, params }) => { - await removeListItem(deps.db, user.id, params.id!); + handler: async ({ deps, user, params, actor }) => { + await removeListItem(deps.db, user.id, params.id!, actor); return noContent(); }, }), diff --git a/apps/server/test/api.test.ts b/apps/server/test/api.test.ts index e18fa97..e32299d 100644 --- a/apps/server/test/api.test.ts +++ b/apps/server/test/api.test.ts @@ -23,15 +23,15 @@ interface CallOpts { token?: string; headers?: Record; 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 { const headers: Record = { ...o.headers }; - if (o.cookie) { - headers.cookie = o.cookie; - headers["x-requested-with"] ??= "sttracker"; - } + 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) { @@ -184,8 +184,16 @@ forEachDialect("api", (getDb) => { 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": "" } }); + 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 () => { @@ -264,16 +272,97 @@ forEachDialect("api", (getDb) => { 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", "0190f000-0000-7000-8000-000000000000"); + 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("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 }).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); @@ -281,6 +370,12 @@ forEachDialect("api", (getDb) => { 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; 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 () => {