222 lines
7.3 KiB
TypeScript
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);
|
|
});
|
|
});
|