// @vitest-environment node /** * TASK-0106 — unit: the shared forms contracts. * * Covers: the compatibility matrix (every cell), the per-type validation * schemas (accept valid / reject each documented invalid shape), the ReDoS * probe (worker-thread, hard-kill budget), the config schema, and the * public-safe subset builder. */ import { describe, expect, it } from "vitest"; import { FORM_ACCENTS, FORM_CONFIG_SCHEMA, FORM_QUESTION_TYPES, QUESTION_PROPERTY_COMPATIBILITY, DB_PROPERTY_TYPES, compatiblePropertyTypes, isCompatibleMapping, toPublicSafeValidation, validateQuestionValidation } from "./forms.js"; // --------------------------------------------------------------------------- // Compatibility matrix // --------------------------------------------------------------------------- describe("QUESTION_PROPERTY_COMPATIBILITY", () => { it("spec matrix — every cell (TASK-0106 §1)", () => { expect(QUESTION_PROPERTY_COMPATIBILITY.SHORT_TEXT).toEqual( expect.arrayContaining(["TITLE", "TEXT", "RICH_TEXT", "EMAIL", "PHONE", "URL"]) ); expect(QUESTION_PROPERTY_COMPATIBILITY.LONG_TEXT).toEqual(expect.arrayContaining(["RICH_TEXT", "TEXT"])); expect(QUESTION_PROPERTY_COMPATIBILITY.NUMBER).toEqual(["NUMBER"]); expect(QUESTION_PROPERTY_COMPATIBILITY.EMAIL).toEqual(["EMAIL"]); expect(QUESTION_PROPERTY_COMPATIBILITY.DATE).toEqual(["DATE"]); expect(QUESTION_PROPERTY_COMPATIBILITY.CHECKBOX).toEqual(["CHECKBOX"]); expect(QUESTION_PROPERTY_COMPATIBILITY.SINGLE_SELECT).toEqual(expect.arrayContaining(["SELECT", "STATUS"])); expect(QUESTION_PROPERTY_COMPATIBILITY.MULTI_SELECT).toEqual(["MULTI_SELECT"]); expect(QUESTION_PROPERTY_COMPATIBILITY.RATING).toEqual(["NUMBER"]); expect(QUESTION_PROPERTY_COMPATIBILITY.FILE).toEqual(["FILES"]); }); it("every entry is a known PropertyType", () => { for (const t of FORM_QUESTION_TYPES) { for (const p of compatiblePropertyTypes(t)) { expect(DB_PROPERTY_TYPES).toContain(p); } } }); it("isCompatibleMapping — positive + negative cells", () => { expect(isCompatibleMapping("SHORT_TEXT", "TEXT")).toBe(true); expect(isCompatibleMapping("SHORT_TEXT", "NUMBER")).toBe(false); expect(isCompatibleMapping("SINGLE_SELECT", "STATUS")).toBe(true); expect(isCompatibleMapping("SINGLE_SELECT", "MULTI_SELECT")).toBe(false); expect(isCompatibleMapping("FILE", "FILES")).toBe(true); expect(isCompatibleMapping("FILE", "TEXT")).toBe(false); expect(isCompatibleMapping("RATING", "NUMBER")).toBe(true); expect(isCompatibleMapping("RATING", "SELECT")).toBe(false); }); }); // --------------------------------------------------------------------------- // Validation config schemas // --------------------------------------------------------------------------- describe("validateQuestionValidation", () => { it("null/undefined is always valid (no config)", async () => { for (const t of FORM_QUESTION_TYPES) { await expect(validateQuestionValidation(t, null)).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation(t, undefined)).resolves.toEqual({ valid: true }); } }); it("SHORT_TEXT/LONG_TEXT — valid config accepted", async () => { await expect( validateQuestionValidation("SHORT_TEXT", { minLength: 1, maxLength: 200, pattern: "^[a-z]+$" }) ).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation("LONG_TEXT", {})).resolves.toEqual({ valid: true }); }); it("SHORT_TEXT — rejects min > max", async () => { await expect(validateQuestionValidation("SHORT_TEXT", { minLength: 10, maxLength: 5 })).resolves.toMatchObject({ valid: false, issue: "min_gt_max" }); }); it("SHORT_TEXT — rejects pattern > 200 chars", async () => { await expect(validateQuestionValidation("SHORT_TEXT", { pattern: "a".repeat(201) })).resolves.toMatchObject({ valid: false, issue: "too_big" }); }); it("SHORT_TEXT — rejects unknown keys (strict)", async () => { await expect(validateQuestionValidation("SHORT_TEXT", { minLength: 1, bogus: true })).resolves.toMatchObject({ valid: false, issue: "unrecognized_keys" }); }); it("NUMBER — valid + min > max rejected + integer flag", async () => { await expect(validateQuestionValidation("NUMBER", { min: 0, max: 100, integer: true })).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation("NUMBER", { min: 50, max: 10 })).resolves.toMatchObject({ valid: false, issue: "min_gt_max" }); }); it("EMAIL — only the empty config is valid", async () => { await expect(validateQuestionValidation("EMAIL", {})).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation("EMAIL", { minLength: 1 })).resolves.toMatchObject({ valid: false, issue: "unrecognized_keys" }); }); it("DATE — valid + bad ISO + minDate > maxDate rejected", async () => { await expect( validateQuestionValidation("DATE", { minDate: "2026-01-01", maxDate: "2026-12-31" }) ).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation("DATE", { minDate: "2026/01/01" })).resolves.toMatchObject({ valid: false, issue: "invalid_string" }); await expect(validateQuestionValidation("DATE", { minDate: "2026-05-01", maxDate: "2026-01-01" })).resolves.toMatchObject({ valid: false, issue: "min_gt_max" }); }); it("CHECKBOX — only the empty config is valid", async () => { await expect(validateQuestionValidation("CHECKBOX", {})).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation("CHECKBOX", { min: 1 })).resolves.toMatchObject({ valid: false, issue: "unrecognized_keys" }); }); it("SINGLE_SELECT/MULTI_SELECT — options rules", async () => { await expect(validateQuestionValidation("SINGLE_SELECT", { options: ["a", "b"] })).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation("SINGLE_SELECT", { options: [] })).resolves.toMatchObject({ valid: false }); await expect(validateQuestionValidation("SINGLE_SELECT", { options: [""] })).resolves.toMatchObject({ valid: false, issue: "too_small" }); await expect(validateQuestionValidation("SINGLE_SELECT", { options: ["a", "a"] })).resolves.toMatchObject({ valid: false, issue: "duplicate_option" }); await expect( validateQuestionValidation("MULTI_SELECT", { options: Array.from({ length: 51 }, (_, i) => `o${i}`) }) ).resolves.toMatchObject({ valid: false, issue: "too_big" }); await expect( validateQuestionValidation("MULTI_SELECT", { options: Array.from({ length: 50 }, (_, i) => `o${i}`) }) ).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation("SINGLE_SELECT", { options: ["x".repeat(51)] })).resolves.toMatchObject({ valid: false, issue: "too_big" }); }); it("RATING — max bounds 5..10, default ≤ max", async () => { await expect(validateQuestionValidation("RATING", { max: 5 })).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation("RATING", { max: 10, default: 10 })).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation("RATING", { max: 4 })).resolves.toMatchObject({ valid: false, issue: "too_small" }); await expect(validateQuestionValidation("RATING", { max: 11 })).resolves.toMatchObject({ valid: false, issue: "too_big" }); await expect(validateQuestionValidation("RATING", { max: 5, default: 7 })).resolves.toMatchObject({ valid: false, issue: "min_gt_max" }); }); it("FILE — maxFiles 1..5 + maxBytes", async () => { await expect(validateQuestionValidation("FILE", { maxFiles: 5, maxBytes: 1048576 })).resolves.toEqual({ valid: true }); await expect(validateQuestionValidation("FILE", { maxFiles: 0 })).resolves.toMatchObject({ valid: false, issue: "too_small" }); await expect(validateQuestionValidation("FILE", { maxFiles: 6 })).resolves.toMatchObject({ valid: false, issue: "too_big" }); await expect(validateQuestionValidation("FILE", { maxBytes: 0 })).resolves.toMatchObject({ valid: false, issue: "too_small" }); }); it("ReDoS probe — catastrophic backtracking rejected (worker hard-kill), linear passes", async () => { // (a+)+$ is catastrophic on the non-matching tail of the probe — without // the worker budget it would hang the process forever. await expect(validateQuestionValidation("SHORT_TEXT", { pattern: "^(a+)+$" })).resolves.toMatchObject({ valid: false, issue: "PATTERN_TOO_COMPLEX" }); await expect(validateQuestionValidation("SHORT_TEXT", { pattern: "^[a-z]+$" })).resolves.toEqual({ valid: true }); }, 15_000); it("invalid regex source rejected", async () => { await expect(validateQuestionValidation("SHORT_TEXT", { pattern: "(" })).resolves.toMatchObject({ valid: false, issue: "invalid_pattern" }); }); }); // --------------------------------------------------------------------------- // Config schema // --------------------------------------------------------------------------- describe("FORM_CONFIG_SCHEMA", () => { it("accepts the documented shape", () => { const r = FORM_CONFIG_SCHEMA.safeParse({ icon: "📝", intro: "Tell us about **yourself**", accent: FORM_ACCENTS[1], successMessage: "Thanks!", closedMessage: "This form is closed.", poweredBy: false, recaptcha: { enabled: false, siteKey: "abc" } }); expect(r.success).toBe(true); }); it("rejects a non-preset accent", () => { expect(FORM_CONFIG_SCHEMA.safeParse({ accent: "#123456" }).success).toBe(false); }); it("enforces length caps (intro 2000, messages 500)", () => { expect(FORM_CONFIG_SCHEMA.safeParse({ intro: "x".repeat(2001) }).success).toBe(false); expect(FORM_CONFIG_SCHEMA.safeParse({ successMessage: "x".repeat(501) }).success).toBe(false); expect(FORM_CONFIG_SCHEMA.safeParse({ intro: "x".repeat(2000) }).success).toBe(true); }); it("rejects unknown keys (strict)", () => { expect(FORM_CONFIG_SCHEMA.safeParse({ icon: "x", hacker: true }).success).toBe(false); }); }); // --------------------------------------------------------------------------- // Public-safe subset (TASK-0107 §1 — pattern NEVER leaks) // --------------------------------------------------------------------------- describe("toPublicSafeValidation", () => { it("strips pattern + non-numeric noise, keeps the numeric/options subset", () => { const out = toPublicSafeValidation({ minLength: 2, maxLength: 50, pattern: "TOP-SECRET", min: 0, max: 100, options: ["a", "b", 7, "c"], maxFiles: 3, minDate: "2026-01-01" }); expect(out).toEqual({ minLength: 2, maxLength: 50, min: 0, max: 100, options: ["a", "b", "c"], maxFiles: 3 }); expect(JSON.stringify(out)).not.toContain("TOP-SECRET"); expect("minDate" in out).toBe(false); }); it("tolerates corrupt/null configs", () => { expect(toPublicSafeValidation(null)).toEqual({}); expect(toPublicSafeValidation("nope")).toEqual({}); expect(toPublicSafeValidation({ options: "nope" })).toEqual({}); }); });