From 3d8bc12ed87ed837a02a97dc6ffa10d6a9175017 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 06:20:06 +0200 Subject: [PATCH] test(intake): public route invariants and bad-token 404s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8a Task 17. Mirrors the existing intake-submit.test.ts pattern: - happy path: card lands in inbox with created_by_role=anonymous - counter bump: submission_count and last_submission_at update - hostile brief: server-set fields cannot be overridden, even when the body includes top-level priority/accepted/column/created_by - none of unknown/revoked/expired/malformed tokens reach the handler — they all 404 with the same wording - deleted-project token: link cascades away, 404 follows - activity log: card_submitted_anonymously row written --- .../integration/intake-submit-public.test.ts | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 tests/integration/intake-submit-public.test.ts diff --git a/tests/integration/intake-submit-public.test.ts b/tests/integration/intake-submit-public.test.ts new file mode 100644 index 0000000..122374a --- /dev/null +++ b/tests/integration/intake-submit-public.test.ts @@ -0,0 +1,275 @@ +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; + + anotherInboxId = ( + await admin + .collection("columns") + .getFirstListItem(`project = "${anotherProjectId}" && is_inbox = true`) + ).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); + }); +});