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 Deviation from plan: the second project needs an explicit Inbox column created in beforeAll — the schema does not auto-create one for new projects.
279 lines
8.5 KiB
TypeScript
279 lines
8.5 KiB
TypeScript
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;
|
|
|
|
// 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);
|
|
});
|
|
});
|