test(intake): public route invariants and bad-token 404s

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
This commit is contained in:
Dave 2026-09-21 06:20:06 +02:00
parent 30d3bf172f
commit 3d8bc12ed8

View file

@ -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<string, unknown> = {}) {
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);
});
});