/** * AI Software Factory — Integration: Forms (Phase 10, TASK-0106/0107/0108) * * The builder API (form + question CRUD, the compatibility matrix, the * publish gate, slugs), the public payload (the exact TASK-0107 whitelist — * zero workspace-data leak), and the submission pipeline (the TASK-0108 * dynamic validation → atomic record + submission → `form.submitted` event, * including anonymous + attributed, honeypot, rate limit, and file answers). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** * A database with Title + a few mappable properties (property ids by name). * * Databases are created THROUGH THE PAGES SURFACE (`POST * /workspaces/:id/pages` with `type: "database"` — the record-a-page * invariant; there is no `POST /databases` route in the API). Custom * properties are added with `POST /databases/:id/properties`. */ async function setupDb( extraProperties: { name: string; type: string; config?: unknown }[] = [], ): Promise<{ headers: Record; workspaceId: string; databaseId: string; props: Record }> { const user = await createUser(app.request, { name: "Form Owner" }); const headers = authHeaders(user.accessToken!); const { workspaceId } = await createWorkspace(app.request, headers, "Form WS"); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title: "Feedback", type: "database" }, }); expect([200, 201]).toContain(res.status); const { databaseId } = (await json(res)) as { databaseId: string }; for (const p of extraProperties) { const propRes = await app.request("POST", `/api/v1/databases/${databaseId}/properties`, { headers, body: { name: p.name, type: p.type, config: p.config ?? {} }, }); expect([200, 201]).toContain(propRes.status); } const rows = await app.prisma.databaseProperty.findMany({ where: { databaseId, deletedAt: null } }); const props: Record = {}; for (const r of rows) props[r.name] = r.id; return { headers, workspaceId, databaseId, props }; } /** Create a form (with inline questions) + return the form id. */ async function makeForm( headers: Record, databaseId: string, name: string, questions: unknown[] = [], config: Record = {}, ) { const res = await app.request("POST", `/api/v1/databases/${databaseId}/forms`, { headers, body: { name, config, questions }, }); expect([200, 201]).toContain(res.status); return (await json(res)) as { id: string; slug: string; status: string }; } describe("Forms builder (TASK-0106)", () => { it("create: auto-generated kebab slug + a second same-name form gets the -2 suffix", async () => { const { headers, databaseId } = await setupDb(); const a = await makeForm(headers, databaseId, "Customer Feedback"); expect(a.slug).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); expect(a.status).toBe("DRAFT"); const b = await makeForm(headers, databaseId, "Customer Feedback"); expect(b.slug).toBe(a.slug + "-2"); }); it("create with an explicit slug keeps it (and a colliding explicit slug 409s)", async () => { const { headers, databaseId } = await setupDb(); await makeForm(headers, databaseId, "A", [], {}); const res1 = await app.request("POST", `/api/v1/databases/${databaseId}/forms`, { headers, body: { name: "Named", slug: "my-form" }, }); expect([200, 201]).toContain(res1.status); expect(((await json(res1)) as { slug: string }).slug).toBe("my-form"); const res2 = await app.request("POST", `/api/v1/databases/${databaseId}/forms`, { headers, body: { name: "Named Again", slug: "my-form" }, }); // A client-provided slug that is already taken is a conflict. expect(res2.status).toBe(409); }); it("question mapping: SHORT_TEXT → NUMBER property is 422 PROPERTY_TYPE_MISMATCH with the allowed types", async () => { const { headers, databaseId, props } = await setupDb([ { name: "Count", type: "NUMBER" }, ]); const { id: formId } = await makeForm(headers, databaseId, "Mismatch"); const res = await app.request("POST", `/api/v1/forms/${formId}/questions`, { headers, body: { type: "SHORT_TEXT", label: "Notes", propertyId: props.Count }, }); expect(res.status).toBe(422); const { error } = await json(res); const details = (error as { details?: { issue?: string; allowedTypes?: string[] }[] }).details ?? []; expect(details.some((d) => d.issue === "PROPERTY_TYPE_MISMATCH")).toBe(true); expect(details.find((d) => d.issue === "PROPERTY_TYPE_MISMATCH")?.allowedTypes).toContain("TEXT"); }); it("a compatible mapping (SHORT_TEXT → TEXT property) succeeds", async () => { const { headers, databaseId, props } = await setupDb([{ name: "Notes", type: "TEXT" }]); const { id: formId } = await makeForm(headers, databaseId, "Compat"); const res = await app.request("POST", `/api/v1/forms/${formId}/questions`, { headers, body: { type: "SHORT_TEXT", label: "Tell us", propertyId: props.Notes, required: true }, }); expect([200, 201]).toContain(res.status); }); it("publishing a DRAFT with 0 questions → 422 issues[] containing the at-least-one-question issue", async () => { const { headers, databaseId } = await setupDb(); const { id: formId } = await makeForm(headers, databaseId, "Empty", []); const res = await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" }, }); expect(res.status).toBe(422); const body = await json(res); const text = JSON.stringify(body); expect(text).toContain("NO_QUESTIONS"); }); it("add a question then publish → 200 and the public endpoint serves it", async () => { const { headers, databaseId, props } = await setupDb([{ name: "Rating", type: "NUMBER" }]); const { id: formId, slug } = await makeForm(headers, databaseId, "Publishable"); const add = await app.request("POST", `/api/v1/forms/${formId}/questions`, { headers, body: { type: "RATING", label: "Rate us", propertyId: props.Rating, validation: { max: 5 } }, }); expect([200, 201]).toContain(add.status); const pub = await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } }); expect(pub.status).toBe(200); const pubRes = await app.request("GET", `/api/v1/public/forms/${slug}`); expect(pubRes.status).toBe(200); }); it("reorder: 5 questions reordered → positions strictly ordered, API returns them in the new order", async () => { const { headers, databaseId } = await setupDb(); const { id: formId } = await makeForm(headers, databaseId, "Order"); const ids: string[] = []; for (let i = 0; i < 5; i++) { const r = await app.request("POST", `/api/v1/forms/${formId}/questions`, { headers, body: { type: "SHORT_TEXT", label: `Q${i + 1}` }, }); ids.push(((await json(r)) as { id: string }).id); } // Move Q1 (ids[0]) before Q4 (ids[3]). const reorder = await app.request("POST", `/api/v1/forms/${formId}/questions/reorder`, { headers, body: { fromId: ids[0], toId: ids[3] }, }); // @Post (Nest default 201) — matches the sibling property/view reorder // endpoints (databases.controller), which are also 201 mutations. expect([200, 201]).toContain(reorder.status); const list = ((await json(reorder)) as unknown as { id: string }[]); expect(list.map((q) => q.id)).toEqual([ids[1], ids[2], ids[0], ids[3], ids[4]]); // Positions must be strictly increasing in returned order. const pos = await app.prisma.formQuestion.findMany({ where: { formId }, orderBy: { position: "asc" } }); const order = pos.map((p) => p.position); for (let i = 1; i < order.length; i++) expect(order[i - 1] < order[i]).toBe(true); }); it("question validation: SINGLE_SELECT with a duplicate option is 422", async () => { const { headers, databaseId } = await setupDb(); const { id: formId } = await makeForm(headers, databaseId, "DupOpts"); const res = await app.request("POST", `/api/v1/forms/${formId}/questions`, { headers, body: { type: "SINGLE_SELECT", label: "Pick", validation: { options: ["a", "a"] } }, }); expect(res.status).toBe(422); }); it("delete question with existing submissions → answers JSON retained; form still lists remaining", async () => { const { headers, databaseId, props } = await setupDb([{ name: "Cat", type: "SELECT", config: { options: [{ name: "x" }] } }]); const { id: formId, slug } = await makeForm(headers, databaseId, "DelQ"); const q = await app.request("POST", `/api/v1/forms/${formId}/questions`, { headers, body: { type: "SINGLE_SELECT", label: "Category", propertyId: props.Cat, validation: { options: ["x"] } }, }); const qid = ((await json(q)) as { id: string }).id; await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } }); // A submission that carries an answer for the to-be-deleted question. await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body: { answers: { [qid]: "x" } }, }); const subBefore = await app.prisma.formSubmission.findFirst({ where: { formId } }); expect(subBefore).toBeTruthy(); const del = await app.request("DELETE", `/api/v1/forms/${formId}/questions/${qid}`, { headers }); expect(del.status).toBe(200); // The orphaned answer survives in the submission row. const subAfter = await app.prisma.formSubmission.findFirst({ where: { formId } }); expect((subAfter?.answers as Record)[qid]).toBe("x"); }); }); describe("Public form payload (TASK-0107)", () => { it("PUBLISHED → the exact whitelist; DRAFT → 404; unknown slug → 404", async () => { // A SENTINEL property name (unique, high-entropy) — if it appears ANYWHERE // in the public payload, a property name leaked. const SENTINEL = "SSN-9f3a1c-SECRET-COLUMN"; const { headers, databaseId, props } = await setupDb([ { name: "Email", type: "EMAIL" }, { name: SENTINEL, type: "TEXT" }, ]); const { id: formId, slug } = await makeForm( headers, databaseId, "Leak Check", [{ type: "SHORT_TEXT", label: "Your email", propertyId: props.Email, required: true }], { intro: "Hello **world**", accent: "#059669" }, ); // DRAFT → 404 (no status leak). const draft = await app.request("GET", `/api/v1/public/forms/${slug}`); expect(draft.status).toBe(404); await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } }); const pub = await app.request("GET", `/api/v1/public/forms/${slug}`); expect(pub.status).toBe(200); const { form, questions } = (await json(pub)) as { form: { slug: string; name: string; description: string | null; status: string; config: Record }; questions: Record[]; }; // The whitelist: no workspace/database/property id or name, no propertyId. expect(form.status).toBe("published"); expect(form).toHaveProperty("slug"); expect(form).toHaveProperty("name"); expect(form.config).toHaveProperty("intro"); // The sanitized intro (markdown → html), NOT the raw markdown. expect(String(form.config.intro)).not.toContain("**world**"); const raw = JSON.stringify({ form, questions }); expect(raw).not.toContain("propertyId"); expect(raw).not.toContain(props.Email); // property id leaked? expect(raw).not.toContain(props[SENTINEL]); // sentinel property id leaked? expect(raw).not.toContain(databaseId); // database id leaked? // SENTINEL property-name scan: a property NAME must never leave the // server (the DoD's "sentinel property-name scan"). expect(raw).not.toContain(SENTINEL); // The workspace name is out of the whitelist too — the form must not // reveal which workspace it belongs to by name. expect(raw).not.toContain("Form WS"); // Response-key test: the question object is the exact public shape — no // server-only keys (propertyId / position / formId / timestamps). expect(Object.keys(questions[0]).sort()).toEqual( ["description", "id", "label", "placeholder", "required", "type", "validation"].sort(), ); expect(questions[0]).toHaveProperty("label"); expect(questions[0]).toHaveProperty("type"); }); it("CLOSED form → 200 with status closed + closedMessage; submit → 403 FORM_CLOSED", async () => { const { headers, databaseId, props } = await setupDb([{ name: "T", type: "TEXT" }]); const { id: formId, slug } = await makeForm( headers, databaseId, "Will Close", [{ type: "SHORT_TEXT", label: "Name", propertyId: props.T }], { closedMessage: "Thanks, we are closed for now." }, ); await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } }); await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "CLOSED" } }); const pub = await app.request("GET", `/api/v1/public/forms/${slug}`); expect(pub.status).toBe(200); const body = (await json(pub)) as { form: { status: string; config: { closedMessage: string | null } } }; expect(body.form.status).toBe("closed"); expect(body.form.config.closedMessage).toContain("closed for now"); const sub = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body: { answers: {} } }); expect(sub.status).toBe(403); expect(JSON.stringify(await json(sub))).toContain("FORM_CLOSED"); }); }); describe("Submission pipeline (TASK-0108)", () => { it("anonymous valid submit → 201; record + submission exist, event on the stream", async () => { const { headers, databaseId, props } = await setupDb([ { name: "Score", type: "NUMBER" }, { name: "Category", type: "SELECT", config: { options: [{ name: "good" }] } }, ]); const { id: formId, slug } = await makeForm( headers, databaseId, "Live Submit", [ { type: "SHORT_TEXT", label: "Title", propertyId: props.Title, required: true }, { type: "RATING", label: "Score", propertyId: props.Score, validation: { max: 10 } }, { type: "SINGLE_SELECT", label: "Category", propertyId: props.Category, validation: { options: ["good"] } }, ], ); await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } }); // The stream event is asserted via the test-only debug endpoint // (`GET /api/v1/debug/events` — TASK-0109; double-gated on // NODE_ENV=test + the per-run X-Debug-Token), not by importing redis // directly (the integration package does not depend on it). const debugEvents = async (): Promise<{ event: string; payload: Record }[]> => { const r = await app.request("GET", "/api/v1/debug/events?name=form.submitted&limit=200", { headers: { "X-Debug-Token": app.debugToken ?? "" }, }); if (r.status !== 200) throw new Error(`debug events endpoint failed (${r.status}): ${await r.text()}`); return ((await json(r)) as { items: { event: string; payload: Record }[] }).items; }; // Answers are keyed by QUESTION id (the public contract). const questions = await app.prisma.formQuestion.findMany({ where: { formId } }); const byLabel = (label: string) => questions.find((q) => q.label === label)!; const real = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body: { answers: { [byLabel("Title").id]: "A feedback entry", [byLabel("Score").id]: 7, [byLabel("Category").id]: "good" } } }); expect(real.status).toBe(201); const { submissionId } = (await json(real)) as { submissionId: string }; expect(submissionId).toBeTruthy(); expect(real.headers.get("X-Submission-Id")).toBe(submissionId); const sub = await app.prisma.formSubmission.findUniqueOrThrow({ where: { id: submissionId } }); expect(sub.submittedBy).toBeNull(); // anonymous expect(sub.recordId).toBeTruthy(); expect(sub.ipAddress).toBeTruthy(); // The record exists with the coerced property values. const rec = await app.prisma.databaseRecord.findUniqueOrThrow({ where: { id: sub.recordId! }, include: { properties: true }, }); const scoreProp = rec.properties.find((p) => p.propertyId === props.Score); expect((scoreProp?.value as { value: number }).value).toBe(7); // NUMBER → { value } // The event appears on the stream with the documented payload (bounded poll). let ev: Record | null = null; const deadline = Date.now() + 5000; while (!ev && Date.now() < deadline) { for (const item of await debugEvents()) { if (item.payload.submissionId === submissionId) { ev = item.payload; break; } } if (!ev) await new Promise((r) => setTimeout(r, 250)); } expect(ev, "form.submitted event on the stream").toBeTruthy(); expect(ev!.anonymous).toBe(true); expect(ev!.recordId).toBe(sub.recordId); // IP redacted to /24 (`1.2.3.***`) or `***` for a non-IPv4 loopback. expect(String(ev!.ip)).toContain("***"); expect(String(ev!.ip)).not.toMatch(/\d+\.\d+\.\d+\.\d+$/); // full IP never in the event }); it("invalid input → 422 with ALL violations (questionId each), nothing persisted", async () => { const { headers, databaseId, props } = await setupDb([ { name: "N", type: "NUMBER" }, { name: "Sel", type: "SELECT", config: { options: [{ name: "only" }] } }, ]); const { id: formId, slug } = await makeForm( headers, databaseId, "Invalid", [ { type: "SHORT_TEXT", label: "Req", propertyId: props.Title, required: true }, { type: "NUMBER", label: "Num", propertyId: props.N, validation: { min: 1, max: 5 } }, { type: "SINGLE_SELECT", label: "Sel", propertyId: props.Sel, validation: { options: ["only"] } }, ], ); await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } }); const questions = await app.prisma.formQuestion.findMany({ where: { formId } }); const byLabel = (label: string) => questions.find((q) => q.label === label)!; const before = await app.prisma.formSubmission.count({ where: { formId } }); const res = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body: { answers: { // "Req" missing (required), "Num" out of range, "Sel" not in options [byLabel("Num").id]: 99, [byLabel("Sel").id]: "nope" } }, }); expect(res.status).toBe(422); const { error } = await json(res); const details = (error as { details?: { questionId?: string; code?: string }[] }).details ?? []; const codes = details.map((d) => d.code).filter(Boolean) as string[]; // All three violations present in ONE response. expect(details.some((d) => d.questionId === byLabel("Req").id)).toBe(true); // required expect(codes.some((c) => c.includes("max") || c.includes("range"))).toBe(true); // number out of range expect(details.some((d) => d.questionId === byLabel("Sel").id)).toBe(true); // option const after = await app.prisma.formSubmission.count({ where: { formId } }); expect(after).toBe(before); // nothing persisted }); it("honeypot value → 400 generic, no submission", async () => { const { headers, databaseId, props } = await setupDb([{ name: "T", type: "TEXT" }]); const { id: formId, slug } = await makeForm(headers, databaseId, "Honey", [ { type: "SHORT_TEXT", label: "T", propertyId: props.T }, ]); await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } }); const before = await app.prisma.formSubmission.count({ where: { formId } }); const res = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body: { answers: {}, website: "http://spam.example" }, }); expect(res.status).toBe(400); const msg = ((await json(res)) as { error: { message: string } }).error.message; expect(msg).toBe("Submission failed. Please try again."); // generic const after = await app.prisma.formSubmission.count({ where: { formId } }); expect(after).toBe(before); }); it("6th submit within the window → 429 with Retry-After", async () => { const { headers, databaseId, props } = await setupDb([{ name: "T", type: "TEXT" }]); const { id: formId, slug } = await makeForm(headers, databaseId, "Ratelimited", [ { type: "SHORT_TEXT", label: "T", propertyId: props.T }, ]); await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } }); const questions = await app.prisma.formQuestion.findMany({ where: { formId } }); const body = { answers: { [questions[0]!.id]: "x" } }; let last = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body }); for (let i = 0; i < 5; i++) { last = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body }); } // The 6th should be limited. (5 allowed, 6th → 429.) expect(last.status).toBe(429); expect(last.headers.get("Retry-After")).toBeTruthy(); expect(JSON.stringify(await json(last))).toContain("retryAfterSeconds"); }); it("FILE answer from another workspace → 422 FILE_NOT_OWNED; nothing persisted", async () => { const { headers, databaseId, props } = await setupDb([{ name: "T", type: "TEXT" }, { name: "Files", type: "FILES" }]); const { id: formId, slug } = await makeForm(headers, databaseId, "FileOwned", [ { type: "FILE", label: "Attach", propertyId: props.Files, validation: { maxFiles: 1 } }, ]); await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } }); // Answers are keyed by the QUESTION id (the public contract). const attachQ = (await app.prisma.formQuestion.findFirst({ where: { formId } }))!; // A file in ANOTHER workspace (a different user's workspace). const other = await createUser(app.request, { name: "Other" }); const ow = await createWorkspace(app.request, authHeaders(other.accessToken!)); const foreignFile = await app.prisma.file.create({ data: { workspaceId: ow.workspaceId, name: "evil.pdf", key: `ws/${ow.workspaceId}/file_foreign.pdf`, size: 10, mimeType: "application/pdf", status: "READY", uploadedBy: other.userId, metadata: { formId: "not-this-form" } } }); const before = await app.prisma.formSubmission.count({ where: { formId } }); const res = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body: { answers: { [attachQ.id]: [foreignFile.id] } }, }); expect(res.status).toBe(422); expect(JSON.stringify(await json(res))).toContain("FILE_NOT_OWNED"); const after = await app.prisma.formSubmission.count({ where: { formId } }); expect(after).toBe(before); }); });