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:
parent
30d3bf172f
commit
3d8bc12ed8
1 changed files with 275 additions and 0 deletions
275
tests/integration/intake-submit-public.test.ts
Normal file
275
tests/integration/intake-submit-public.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue