/** * Submission API smoke-tests. Covers the three documented anti-spam layers * (origin allowlist, honeypot silent-drop, per-IP rate-limit) plus the * happy-path persistence call. Heavy collaborators (Prisma + SharedStore + * feature-flags + Turnstile fetch) are mocked at the module boundary so the * tests stay hermetic + fast. */ import { describe, expect, it, vi, beforeEach } from "vitest"; import { HONEYPOT_FIELD_KEY } from "@toolctrl/form-widget"; const formRow = { id: "form_1", tenantId: "t_1", isEnabled: true, definition: { title: "Contact", submitLabel: "Send", successMessage: "Thanks", fields: [ { key: "email", type: "email", label: "Email", required: true }, { key: HONEYPOT_FIELD_KEY, type: "honeypot", label: "Leave empty" }, ], }, allowedOrigins: ["https://allowed.example.com"], targetType: "generic", targetEntityId: null, fieldMapping: {}, config: {}, webhookUrl: null, notifyEmails: ["ops@example.com"], }; const createSubmission = vi.fn(async () => ({ id: "sub_1" })); const publishOutboxEventMock = vi.fn(async () => ({ id: "ob_1" })); vi.mock("@toolctrl/db/outbox", () => ({ publishOutboxEvent: (...args: unknown[]) => publishOutboxEventMock(...args), })); vi.mock("~/utils/db.server", () => { const tx = { formSubmission: { create: createSubmission }, }; return { db: { form: { findUnique: vi.fn(async ({ where }: { where: { key: string } }) => where.key === "key_missing" ? null : { ...formRow }, ), }, formSubmission: { create: createSubmission }, $transaction: vi.fn(async (fn: (tx: typeof tx) => Promise) => fn(tx)), }, }; }); vi.mock("~/utils/logger.server", () => ({ createScopedLogger: () => ({ info: () => undefined, warn: () => undefined, error: () => undefined, }), })); vi.mock("~/utils/services/.server/featureFlagEvaluator.server", () => ({ evaluateFlagForContext: vi.fn(async () => ({ enabled: false, reason: "disabled" })), })); vi.mock("~/shared-store.server", () => { const memory = new Map(); return { getSharedStore: () => ({ // The route only calls helpers via createSlidingWindowRateLimiter; we // hand the limiter a real MemoryAdapter-backed store from shared-store. // For tests we stub eval to no-op via override below. }), __memory: memory, }; }); vi.mock("@toolctrl/shared-store", async (orig) => { const real = (await orig()) as Record; return { ...real, createSlidingWindowRateLimiter: () => ({ consume: async () => ({ allowed: true, remaining: 9, oldestAt: null }), reset: async () => undefined, }), }; }); async function loadAction() { const mod = await import("./api.embed.forms.$formKey.submit"); return mod.action; } function makeRequest(body: unknown, origin: string | null): Request { const params = new URLSearchParams(); params.set("__payload", typeof body === "string" ? body : JSON.stringify(body)); return new Request("https://forms.example.com/api/embed/forms/key_1/submit", { method: "POST", body: params, headers: { "content-type": "application/x-www-form-urlencoded", ...(origin ? { origin } : {}), "x-forwarded-for": "1.2.3.4", "user-agent": "vitest", }, }); } describe("api.embed.forms.$formKey.submit action", () => { beforeEach(() => { createSubmission.mockClear(); publishOutboxEventMock.mockClear(); }); it("returns 404 for an unknown form key", async () => { const action = await loadAction(); const res = await action({ params: { formKey: "key_missing" }, request: makeRequest({ values: { email: "a@b.co" } }, "https://allowed.example.com"), }); expect(res.status).toBe(404); }); it("returns 403 when origin is not allowlisted", async () => { const action = await loadAction(); const res = await action({ params: { formKey: "key_1" }, request: makeRequest({ values: { email: "a@b.co" } }, "https://evil.example.com"), }); expect(res.status).toBe(403); expect(createSubmission).not.toHaveBeenCalled(); }); it("silently drops (204) when honeypot is filled", async () => { const action = await loadAction(); const res = await action({ params: { formKey: "key_1" }, request: makeRequest( { values: { email: "a@b.co", [HONEYPOT_FIELD_KEY]: "bot" } }, "https://allowed.example.com", ), }); expect(res.status).toBe(204); expect(createSubmission).not.toHaveBeenCalled(); }); it("rejects missing required fields with 400", async () => { const action = await loadAction(); const res = await action({ params: { formKey: "key_1" }, request: makeRequest({ values: { email: "" } }, "https://allowed.example.com"), }); expect(res.status).toBe(400); expect(createSubmission).not.toHaveBeenCalled(); }); it("rejects malformed email with 400", async () => { const action = await loadAction(); const res = await action({ params: { formKey: "key_1" }, request: makeRequest({ values: { email: "not-an-email" } }, "https://allowed.example.com"), }); expect(res.status).toBe(400); }); it("persists submission on the happy path", async () => { const action = await loadAction(); const res = await action({ params: { formKey: "key_1" }, request: makeRequest({ values: { email: "alice@example.com" } }, "https://allowed.example.com"), }); expect(res.status).toBe(200); const json = (await res.json()) as { ok: boolean }; expect(json.ok).toBe(true); expect(createSubmission).toHaveBeenCalledTimes(1); const args = createSubmission.mock.calls[0]![0] as { data: { tenantId: string; status: string } }; expect(args.data.tenantId).toBe("t_1"); expect(args.data.status).toBe("received"); }); it("publishes a form.submission.process outbox event with the submission payload", async () => { const action = await loadAction(); const res = await action({ params: { formKey: "key_1" }, request: makeRequest({ values: { email: "alice@example.com" } }, "https://allowed.example.com"), }); expect(res.status).toBe(200); expect(publishOutboxEventMock).toHaveBeenCalledTimes(1); const call = publishOutboxEventMock.mock.calls[0]!; const args = call[1] as { tenantId: string; type: string; dispatchKey: string; payload: { submissionId: string; formId: string; targetType: string; notifyEmails: string[]; values: Record; }; }; expect(args.type).toBe("form.submission.process"); expect(args.tenantId).toBe("t_1"); expect(args.dispatchKey).toBe("form-submission:sub_1"); expect(args.payload.submissionId).toBe("sub_1"); expect(args.payload.formId).toBe("form_1"); expect(args.payload.targetType).toBe("generic"); expect(args.payload.notifyEmails).toEqual(["ops@example.com"]); expect(args.payload.values).toEqual({ email: "alice@example.com" }); }); it("returns 405 on GET", async () => { const action = await loadAction(); const req = new Request("https://forms.example.com/api/embed/forms/key_1/submit", { method: "GET" }); const res = await action({ params: { formKey: "key_1" }, request: req }); expect(res.status).toBe(405); }); });