Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routes/api.embed.forms.$formKey.submit.test.ts
T

222 lines
7.3 KiB
TypeScript

/**
* 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<unknown>) => 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<string, number>();
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<string, unknown>;
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<string, unknown>;
};
};
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);
});
});