Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 5 additions & 18 deletions apps/web/src/app/api/forms/[slug]/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { prisma, type Prisma } from "@msk-forms/db";
import {
buildAnswerSchema,
FILE_FIELD_TYPES,
formatAnswerValue,
isLayoutField,
type FileAnswer,
type FormField,
type FormSpec,
type SubmissionReviewNotification,
} from "@msk-forms/shared";
Expand All @@ -22,29 +23,15 @@ export const dynamic = "force-dynamic";
const SUBMIT_LIMIT = 8;
const SUBMIT_WINDOW_SECONDS = 60;

const LAYOUT_TYPES = ["section_break", "heading", "paragraph", "image_block", "divider", "spacer"];
const PREVIEW_LABELS = { empty: "—", yes: "Yes", no: "No" } as const;

/** Short "Label: value" lines for the bot's review embed (first few fields). */
function buildPreview(spec: FormSpec, data: Record<string, unknown>): string[] {
const labelFor = (field: FormField, v: string) =>
field.options?.find((o) => o.value === v)?.label ?? v;
const valueOf = (field: FormField, value: unknown): string => {
if (value === undefined || value === null || value === "") return "—";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (Array.isArray(value)) return value.map((v) => labelFor(field, String(v))).join(", ");
// null/undefined already returned above, so a plain object check suffices.
if (typeof value === "object" && "name" in value) {
return String((value as { name: unknown }).name);
}
if (field.options) return labelFor(field, String(value));
return String(value);
};

return spec.pages
.flatMap((p) => p.fields)
.filter((f) => !LAYOUT_TYPES.includes(f.type))
.filter((f) => !isLayoutField(f.type))
.slice(0, 6)
.map((f) => `${f.label ?? f.id}: ${valueOf(f, data[f.id]).slice(0, 100)}`);
.map((f) => `${f.label ?? f.id}: ${formatAnswerValue(f, data[f.id], PREVIEW_LABELS).slice(0, 100)}`);
}

/**
Expand Down
10 changes: 0 additions & 10 deletions apps/web/src/components/form/field-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,6 @@ import { FileField, type FileFieldLabels } from "./file-field";

export type FieldValue = string | number | boolean | string[] | FileAnswer | undefined;

/** Field types that carry no answer value (rendered as layout only). */
export const LAYOUT_TYPES = [
"section_break",
"heading",
"paragraph",
"image_block",
"divider",
"spacer",
] as const;

const YES_NO_OPTIONS = [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/form/form-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
"use client";

import type { FormField, FormSpec } from "@msk-forms/shared";
import { isLayoutField, type FormField, type FormSpec } from "@msk-forms/shared";
import { Button, Field } from "@msk-forms/ui";
import type { Route } from "next";
import { useRouter } from "next/navigation";
import { useState } from "react";

import { FieldInput, LAYOUT_TYPES, type FieldValue } from "./field-input";
import { FieldInput, type FieldValue } from "./field-input";
import { LayoutBlock } from "./layout-block";
import { TurnstileWidget } from "./turnstile-widget";

type Answers = Record<string, FieldValue>;

function isLayout(field: FormField): boolean {
return (LAYOUT_TYPES as readonly string[]).includes(field.type);
return isLayoutField(field.type);
}

/** True when a required field has no meaningful answer. */
Expand Down
22 changes: 9 additions & 13 deletions apps/web/src/components/submission/answer-summary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { FILE_FIELD_TYPES, type FormField, type FormSpec } from "@msk-forms/shared";
import {
FILE_FIELD_TYPES,
formatAnswerValue,
isLayoutField,
type FormField,
type FormSpec,
} from "@msk-forms/shared";

/** Labels needed to format answers, shared by the public + reviewer views. */
export interface AnswerLabels {
Expand All @@ -14,18 +20,8 @@ export interface SubmissionFile {
filename: string;
}

/** Layout-only field types carry no answer and are skipped in the summary. */
const LAYOUT = ["section_break", "heading", "paragraph", "image_block", "divider", "spacer"];

function formatAnswer(field: FormField, value: unknown, t: AnswerLabels): string {
if (value === undefined || value === null || value === "") return t.notAnswered;
if (typeof value === "boolean") return value ? t.yes : t.no;

const labelFor = (v: string) => field.options?.find((o) => o.value === v)?.label ?? v;

if (Array.isArray(value)) return value.map((v) => labelFor(String(v))).join(", ");
if (field.options) return labelFor(String(value));
return String(value);
return formatAnswerValue(field, value, { empty: t.notAnswered, yes: t.yes, no: t.no });
}

/** Read-only definition list of a submission's answers, by form field. */
Expand All @@ -40,7 +36,7 @@ export function AnswerSummary({
labels: AnswerLabels;
files?: SubmissionFile[];
}) {
const fields = spec.pages.flatMap((p) => p.fields).filter((f) => !LAYOUT.includes(f.type));
const fields = spec.pages.flatMap((p) => p.fields).filter((f) => !isLayoutField(f.type));
const isFileField = (f: FormField) => (FILE_FIELD_TYPES as readonly string[]).includes(f.type);

return (
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/lib/builder-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ export const BUILDER_FIELDS: { type: FieldType; label: string }[] = [
];

const CHOICE_TYPES: FieldType[] = ["single_choice", "dropdown", "multi_choice"];
const LAYOUT_TYPES: FieldType[] = ["heading", "paragraph", "divider"];
// Distinct from shared LAYOUT_FIELD_TYPES: only the layout types the builder
// currently offers (not every layout type the renderer can display).
const BUILDER_LAYOUT_TYPES: FieldType[] = ["heading", "paragraph", "divider"];

export const fieldTypeLabel = (type: FieldType): string =>
BUILDER_FIELDS.find((f) => f.type === type)?.label ?? type;

export const needsOptions = (type: FieldType): boolean => CHOICE_TYPES.includes(type);
export const isLayoutType = (type: FieldType): boolean => LAYOUT_TYPES.includes(type);
export const isLayoutType = (type: FieldType): boolean => BUILDER_LAYOUT_TYPES.includes(type);
85 changes: 85 additions & 0 deletions apps/web/src/lib/captcha.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { captchaEnabled, captchaSiteKey, verifyCaptcha } from "./captcha";

const ENV = { site: process.env.CAPTCHA_SITE_KEY, secret: process.env.CAPTCHA_SECRET_KEY };

afterEach(() => {
process.env.CAPTCHA_SITE_KEY = ENV.site;
process.env.CAPTCHA_SECRET_KEY = ENV.secret;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

beforeEach(() => {
delete process.env.CAPTCHA_SITE_KEY;
delete process.env.CAPTCHA_SECRET_KEY;
});

describe("captchaEnabled / captchaSiteKey", () => {
it("is off unless BOTH keys are set", () => {
expect(captchaEnabled()).toBe(false);
process.env.CAPTCHA_SITE_KEY = "site";
expect(captchaEnabled()).toBe(false);
expect(captchaSiteKey()).toBe("site");
process.env.CAPTCHA_SECRET_KEY = "secret";
expect(captchaEnabled()).toBe(true);
});
});

describe("verifyCaptcha", () => {
it("passes through when captcha is not configured (nothing to enforce)", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
expect(await verifyCaptcha(undefined)).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
});

it("fails closed on a missing token when enabled", async () => {
process.env.CAPTCHA_SECRET_KEY = "secret";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
expect(await verifyCaptcha(undefined)).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});

it("returns true only when Cloudflare reports success", async () => {
process.env.CAPTCHA_SECRET_KEY = "secret";
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ success: true }) })),
);
expect(await verifyCaptcha("token")).toBe(true);
});

it("fails closed when Cloudflare reports failure", async () => {
process.env.CAPTCHA_SECRET_KEY = "secret";
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ success: false }) })),
);
expect(await verifyCaptcha("token")).toBe(false);
});

it("fails closed on a non-OK HTTP response", async () => {
process.env.CAPTCHA_SECRET_KEY = "secret";
vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })),
);
expect(await verifyCaptcha("token")).toBe(false);
});

it("fails closed when fetch throws", async () => {
process.env.CAPTCHA_SECRET_KEY = "secret";
vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("network down");
}),
);
expect(await verifyCaptcha("token")).toBe(false);
});
});
63 changes: 63 additions & 0 deletions apps/web/src/lib/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { getRedis } from "./redis";
import { rateLimit } from "./rate-limit";

// Control the Redis singleton without a real connection.
vi.mock("./redis", () => ({ getRedis: vi.fn() }));

const mockedGetRedis = vi.mocked(getRedis);

afterEach(() => {
vi.restoreAllMocks();
mockedGetRedis.mockReset();
});

describe("rateLimit", () => {
it("fails open (allows) when Redis is not configured", async () => {
mockedGetRedis.mockReturnValue(null);
const r = await rateLimit("k", 5, 60);
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(5);
});

it("fails open when the Redis command throws", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
mockedGetRedis.mockReturnValue({
eval: vi.fn(async () => {
throw new Error("redis down");
}),
} as never);
const r = await rateLimit("k", 5, 60);
expect(r.allowed).toBe(true);
});

it("allows while under the limit and reports remaining", async () => {
mockedGetRedis.mockReturnValue({ eval: vi.fn(async () => [3, 42]) } as never);
const r = await rateLimit("k", 5, 60);
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(2);
expect(r.retryAfter).toBe(42);
});

it("blocks once the count exceeds the limit", async () => {
mockedGetRedis.mockReturnValue({ eval: vi.fn(async () => [6, 30]) } as never);
const r = await rateLimit("k", 5, 60);
expect(r.allowed).toBe(false);
expect(r.remaining).toBe(0);
expect(r.retryAfter).toBe(30);
});

it("allows exactly at the limit boundary", async () => {
mockedGetRedis.mockReturnValue({ eval: vi.fn(async () => [5, 10]) } as never);
const r = await rateLimit("k", 5, 60);
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(0);
});

it("falls back to the window when TTL is unavailable", async () => {
mockedGetRedis.mockReturnValue({ eval: vi.fn(async () => [6, -1]) } as never);
const r = await rateLimit("k", 5, 90);
expect(r.retryAfter).toBe(90);
});
});
4 changes: 4 additions & 0 deletions apps/web/src/test/server-only.stub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Vitest stub for Next.js's `server-only` guard, which only resolves inside the
// Next build. Aliased in vitest.config.ts so server-side libs (redis, captcha,
// rate-limit) can be unit-tested.
export {};
12 changes: 12 additions & 0 deletions apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { fileURLToPath } from "node:url";

import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
alias: {
// `server-only` only resolves inside the Next build; stub it for unit tests.
"server-only": fileURLToPath(new URL("./src/test/server-only.stub.ts", import.meta.url)),
},
},
});
33 changes: 33 additions & 0 deletions packages/shared/src/form-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,39 @@ export function isLayoutField(type: FieldType): boolean {
return (LAYOUT_FIELD_TYPES as readonly string[]).includes(type);
}

/** Caller-supplied labels for {@link formatAnswerValue} (lets each surface i18n). */
export interface AnswerValueLabels {
/** Shown for an empty/missing answer. */
empty: string;
yes: string;
no: string;
}

/**
* Framework-agnostic, human-readable rendering of a single answer value: maps
* option values to their labels, joins arrays, formats booleans, and shows a
* file-descriptor answer by its filename. Shared by the reviewer/status answer
* summary and the bot's review-embed preview so they never drift.
*/
export function formatAnswerValue(
field: FormField,
value: unknown,
labels: AnswerValueLabels,
): string {
if (value === undefined || value === null || value === "") return labels.empty;
if (typeof value === "boolean") return value ? labels.yes : labels.no;

const labelFor = (v: string) => field.options?.find((o) => o.value === v)?.label ?? v;

if (Array.isArray(value)) return value.map((v) => labelFor(String(v))).join(", ");
// File-descriptor answer ({ key, name, size, mime }) — show the filename.
if (typeof value === "object" && "name" in value) {
return String((value as { name: unknown }).name);
}
if (field.options) return labelFor(String(value));
return String(value);
}

const NUMBER_TYPES = ["number", "slider", "nps", "rating_stars"];
const MULTI_TYPES = ["multi_choice", "multi_select", "ranking"];
const BOOLEAN_TYPES = ["yes_no", "consent", "age_check"];
Expand Down