import { describe, it, expect, beforeAll, afterAll } from "vitest"; import PocketBase from "pocketbase"; import { randomBytes } from "node:crypto"; import { APP_URL, DEMO, adminPb, requireServices } from "./helpers"; let admin: PocketBase; let projectId: string; let inboxId: string; let anotherProjectId: string; let anotherInboxId: string; const createdLinks: string[] = []; const createdCards: string[] = []; function makeToken(): string { return randomBytes(24).toString("base64url"); } async function newLink(project: string, overrides: Record = {}) { const token = makeToken(); const link = await admin.collection("intake_links").create({ project, label: "test", token, active: true, expires_at: "", max_submissions_per_day: 100, submission_count: 0, submission_count_today: 0, submission_count_today_date: "", last_submission_at: "", notes: "", ...overrides, }); createdLinks.push(link.id); return link; } beforeAll(async () => { await requireServices(); admin = await adminPb(); const project = await admin .collection("projects") .getFirstListItem(`slug = "${DEMO.projectSlug}"`); projectId = project.id; inboxId = ( await admin .collection("columns") .getFirstListItem(`project = "${projectId}" && is_inbox = true`) ).id; // A second project under the same demo client, so "another client's // project" can be tested. Uses the same admin pb; we delete at the end. const another = await admin.collection("projects").create({ client: project.client, name: "Other", slug: `other-${Date.now()}`, status: "active", kind: "project", }); anotherProjectId = another.id; // The schema doesn't auto-create an Inbox column for new projects; the test // owns the fixture it relies on. const anotherColumn = await admin.collection("columns").create({ project: anotherProjectId, name: "Inbox", position: 1000, is_inbox: true, }); anotherInboxId = anotherColumn.id; }); afterAll(async () => { for (const id of createdCards) { await admin.collection("cards").delete(id).catch(() => {}); } for (const id of createdLinks) { await admin.collection("intake_links").delete(id).catch(() => {}); } await admin.collection("projects").delete(anotherProjectId).catch(() => {}); }); const brief = { title: "Add a public-facing intake", summary: "Anonymous clients should be able to file requests.", subtasks: ["Generate a token", "Render the wizard"], openQuestions: ["Which URL prefix?"], area: "", degraded: false, }; describe("a public token turns a brief into an Inbox card", () => { it("creates the card with created_by_role = anonymous", async () => { const link = await newLink(projectId); const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, brief, transcript: [{ question: "What?", answer: "A public form." }], sizeBand: "medium", }), }); expect(response.status).toBe(201); const { card } = await response.json(); createdCards.push(card.id); const created = await admin.collection("cards").getOne(card.id); expect(created.column).toBe(inboxId); expect(created.accepted).toBe(false); expect(created.created_by_role).toBe("anonymous"); expect(created.created_by).toBe(""); expect(created.title).toBe(brief.title); expect(created.size_band).toBe("medium"); }); it("increments the lifetime counter", async () => { const link = await newLink(projectId); await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, brief, transcript: [], }), }); const reloaded = await admin.collection("intake_links").getOne(link.id); expect(reloaded.submission_count).toBe(1); expect(reloaded.last_submission_at).not.toBe(""); }); }); describe("the public route decides the fields that matter", () => { it("ignores an escalating brief entirely", async () => { const link = await newLink(projectId); const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, brief: { ...brief, priority: "critical", accepted: true, column: anotherInboxId }, transcript: [], sizeBand: "large", priority: "critical", accepted: true, column: anotherInboxId, created_by_role: "owner", created_by: "fake-user-id", }), }); expect(response.status).toBe(201); const { card } = await response.json(); createdCards.push(card.id); const created = await admin.collection("cards").getOne(card.id); expect(created.column).toBe(inboxId); expect(created.priority).toBe("medium"); expect(created.accepted).toBe(false); expect(created.created_by_role).toBe("anonymous"); expect(created.created_by).toBe(""); }); }); describe("bad tokens are 404, not 403", () => { it("refuses an unknown token", async () => { const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: makeToken(), brief, transcript: [], }), }); expect(response.status).toBe(404); }); it("refuses a revoked token", async () => { const link = await newLink(projectId, { active: false }); const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, brief, transcript: [] }), }); expect(response.status).toBe(404); }); it("refuses an expired token", async () => { const link = await newLink(projectId, { expires_at: "2000-01-01 00:00:00.000Z" }); const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, brief, transcript: [] }), }); expect(response.status).toBe(404); }); it("refuses a malformed token shape", async () => { const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "not-a-real-token", brief, transcript: [], }), }); expect(response.status).toBe(404); }); }); describe("the public route is not a project-scope escape hatch", () => { it("refuses to use a token that points at a project whose inbox is gone", async () => { // Re-create a link to a project, then delete that project. The link // cascades away, so a follow-up submission gets 404 just like any // unknown token. const victim = await admin.collection("projects").create({ client: (await admin.collection("projects").getOne(projectId)).client, name: "Victim", slug: `victim-${Date.now()}`, status: "active", kind: "project", }); const token = makeToken(); await admin.collection("intake_links").create({ project: victim.id, label: "x", token, active: true, expires_at: "", max_submissions_per_day: 100, submission_count: 0, submission_count_today: 0, submission_count_today_date: "", last_submission_at: "", notes: "", }); await admin.collection("projects").delete(victim.id); const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, brief, transcript: [] }), }); expect(response.status).toBe(404); }); }); describe("the activity log records anonymous submissions", () => { it("writes a card_submitted_anonymously row", async () => { const link = await newLink(projectId); const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, brief, transcript: [] }), }); const { card } = await response.json(); createdCards.push(card.id); const rows = await admin .collection("activity") .getFullList({ filter: `card = "${card.id}" && action = "card_submitted_anonymously"` }); expect(rows.length).toBe(1); }); });