From 5c39317877648b3c160277601642fac8bafaa9e8 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 16:40:09 -0700 Subject: [PATCH] test(forms): extract pure decision logic and add exhaustive coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forms security/correctness logic was buried in DB-coupled server actions and the builder component, so it had no real tests (the suite is pure-logic only; nothing exercises Supabase). Extract the pure parts behaviour-unchanged into lib/forms-logic.ts and test them for real: - csvCell (results-export escaping), parseClientIp (e-sign IP capture filtering), shouldBumpFormVersion (waiver non-blocking re-sign), evaluateRequiredFormStep (onboarding required/auto-satisfy/fail-open), slugify + deriveFieldKeys (builder key derivation + dedupe) - forms.ts / onboarding.ts / form-builder.tsx now consume these; the local copies are removed (no behaviour change — verified by the full suite staying green) - __tests__/forms.test.ts expanded 16 -> 62: every validateAnswers type/branch (type coercion, length caps, required, unknown-key stripping, first-error ordering), parseFormSchema, every new Zod schema incl. formSlug boundaries, and all extracted helpers 333/333 tests; build verified (Compiled successfully, exit 0). No deploy. --- __tests__/forms.test.ts | 508 +++++++++++++++++++++++++----- components/forms/form-builder.tsx | 23 +- lib/actions/forms.ts | 32 +- lib/actions/onboarding.ts | 46 ++- lib/forms-logic.ts | 93 ++++++ 5 files changed, 564 insertions(+), 138 deletions(-) create mode 100644 lib/forms-logic.ts diff --git a/__tests__/forms.test.ts b/__tests__/forms.test.ts index 20d94ed..4b41acf 100644 --- a/__tests__/forms.test.ts +++ b/__tests__/forms.test.ts @@ -3,42 +3,109 @@ import { createFormSchema, updateFormSchema, submitFormSchema, + setFormStatusSchema, + formIdSchema, + linkSubmissionsSchema, + getPublicFormSchema, + formSlug, formSchemaArray, formFieldSchema, + formFieldTypes, onboardingStepTypeSchema, createOnboardingStepSchema, } from '@/lib/validations' import { parseFormSchema, validateAnswers, type FormField } from '@/lib/forms-schema' +import { + csvCell, + parseClientIp, + shouldBumpFormVersion, + evaluateRequiredFormStep, + slugify, + deriveFieldKeys, +} from '@/lib/forms-logic' -describe('form field schema', () => { - it('accepts a valid text field and defaults required to false', () => { - const r = formFieldSchema.safeParse({ key: 'full_name', type: 'short_text', label: 'Name' }) +// ─── formSlug ──────────────────────────────────────────────────────────────── + +describe('formSlug', () => { + it('accepts valid slugs', () => { + for (const s of ['a', 'ab', 'a1', '1a', 'a-b', 'liability-waiver-2026', '0']) { + expect(formSlug.safeParse(s).success, s).toBe(true) + } + }) + it('accepts an 80-char slug and rejects 81', () => { + expect(formSlug.safeParse('a'.repeat(80)).success).toBe(true) + expect(formSlug.safeParse('a'.repeat(81)).success).toBe(false) + }) + it('rejects invalid slugs', () => { + for (const s of ['', 'A', 'aB', 'a_b', '-a', 'a-', ' a', 'a b', 'a.b', 'café']) { + expect(formSlug.safeParse(s).success, s).toBe(false) + } + }) +}) + +// ─── formFieldSchema ───────────────────────────────────────────────────────── + +describe('formFieldSchema', () => { + it('accepts a minimal text field and defaults required to false', () => { + const r = formFieldSchema.safeParse({ key: 'name', type: 'short_text', label: 'Name' }) expect(r.success).toBe(true) if (r.success) expect(r.data.required).toBe(false) }) - - it('rejects a select field with no options', () => { - const r = formFieldSchema.safeParse({ key: 'tier', type: 'select', label: 'Tier' }) - expect(r.success).toBe(false) + it('requires options for select and radio', () => { + expect(formFieldSchema.safeParse({ key: 'a', type: 'select', label: 'A' }).success).toBe(false) + expect(formFieldSchema.safeParse({ key: 'a', type: 'radio', label: 'A' }).success).toBe(false) + expect( + formFieldSchema.safeParse({ key: 'a', type: 'select', label: 'A', options: ['x'] }).success, + ).toBe(true) }) - - it('rejects an invalid field key', () => { - const r = formFieldSchema.safeParse({ key: 'Full Name', type: 'short_text', label: 'X' }) - expect(r.success).toBe(false) + it('enforces the key character set and length', () => { + expect(formFieldSchema.safeParse({ key: 'Bad Key', type: 'short_text', label: 'L' }).success).toBe(false) + expect(formFieldSchema.safeParse({ key: '', type: 'short_text', label: 'L' }).success).toBe(false) + expect(formFieldSchema.safeParse({ key: 'a'.repeat(61), type: 'short_text', label: 'L' }).success).toBe(false) + expect(formFieldSchema.safeParse({ key: 'ok_key1', type: 'short_text', label: 'L' }).success).toBe(true) }) + it('requires a non-empty label within length', () => { + expect(formFieldSchema.safeParse({ key: 'k', type: 'short_text', label: '' }).success).toBe(false) + expect(formFieldSchema.safeParse({ key: 'k', type: 'short_text', label: 'x'.repeat(201) }).success).toBe(false) + }) + it('every declared field type parses', () => { + for (const t of formFieldTypes) { + const base: Record = { key: 'k', type: t, label: 'L' } + if (t === 'select' || t === 'radio') base.options = ['o1'] + expect(formFieldSchema.safeParse(base).success, t).toBe(true) + } + }) +}) - it('rejects duplicate field keys in the array', () => { +// ─── formSchemaArray ───────────────────────────────────────────────────────── + +describe('formSchemaArray', () => { + it('accepts an empty array', () => { + expect(formSchemaArray.safeParse([]).success).toBe(true) + }) + it('rejects duplicate field keys', () => { const r = formSchemaArray.safeParse([ { key: 'a', type: 'short_text', label: 'A' }, - { key: 'a', type: 'short_text', label: 'A again' }, + { key: 'a', type: 'short_text', label: 'A2' }, ]) expect(r.success).toBe(false) }) + it('rejects more than 200 fields', () => { + const mk = (n: number) => ({ key: `k${n}`, type: 'short_text', label: `L${n}` }) + expect(formSchemaArray.safeParse(Array.from({ length: 200 }, (_, i) => mk(i))).success).toBe(true) + expect(formSchemaArray.safeParse(Array.from({ length: 201 }, (_, i) => mk(i))).success).toBe(false) + }) + it('propagates an inner field error', () => { + const r = formSchemaArray.safeParse([{ key: 'a', type: 'select', label: 'A' }]) + expect(r.success).toBe(false) + }) }) +// ─── createFormSchema ──────────────────────────────────────────────────────── + describe('createFormSchema', () => { it('applies defaults', () => { - const r = createFormSchema.safeParse({ slug: 'liability-waiver', title: 'Waiver' }) + const r = createFormSchema.safeParse({ slug: 'waiver', title: 'Waiver' }) expect(r.success).toBe(true) if (r.success) { expect(r.data.kind).toBe('form') @@ -46,97 +113,378 @@ describe('createFormSchema', () => { expect(r.data.schema).toEqual([]) } }) - - it('rejects an uppercase or hyphen-edged slug', () => { - expect(createFormSchema.safeParse({ slug: 'Waiver', title: 'x' }).success).toBe(false) - expect(createFormSchema.safeParse({ slug: '-waiver', title: 'x' }).success).toBe(false) - expect(createFormSchema.safeParse({ slug: 'waiver-', title: 'x' }).success).toBe(false) + it('rejects bad enums', () => { + expect(createFormSchema.safeParse({ slug: 'a', title: 'T', kind: 'survey' }).success).toBe(false) + expect(createFormSchema.safeParse({ slug: 'a', title: 'T', visibility: 'world' }).success).toBe(false) + }) + it('rejects an empty or oversized title', () => { + expect(createFormSchema.safeParse({ slug: 'a', title: '' }).success).toBe(false) + expect(createFormSchema.safeParse({ slug: 'a', title: 'x'.repeat(201) }).success).toBe(false) + }) + it('bounds legal_text to 100000 chars', () => { + expect(createFormSchema.safeParse({ slug: 'a', title: 'T', legal_text: 'x'.repeat(100000) }).success).toBe(true) + expect(createFormSchema.safeParse({ slug: 'a', title: 'T', legal_text: 'x'.repeat(100001) }).success).toBe(false) + }) + it('propagates slug and nested field errors', () => { + expect(createFormSchema.safeParse({ slug: 'BAD', title: 'T' }).success).toBe(false) + expect( + createFormSchema.safeParse({ slug: 'a', title: 'T', schema: [{ key: 'q', type: 'select', label: 'Q' }] }).success, + ).toBe(false) }) }) -describe('submitFormSchema', () => { - it('requires formId or slug', () => { - expect(submitFormSchema.safeParse({ answers: {} }).success).toBe(false) - expect(submitFormSchema.safeParse({ slug: 'a', answers: {} }).success).toBe(true) - }) -}) +// ─── updateFormSchema ──────────────────────────────────────────────────────── describe('updateFormSchema', () => { - it('does not accept a slug change', () => { - const r = updateFormSchema.safeParse({ - formId: '11111111-1111-1111-1111-111111111111', - slug: 'new-slug', - }) - // slug is simply stripped (not in the schema), so parse still succeeds + const ID = '11111111-1111-1111-1111-111111111111' + it('requires a valid formId', () => { + expect(updateFormSchema.safeParse({}).success).toBe(false) + expect(updateFormSchema.safeParse({ formId: 'nope' }).success).toBe(false) + expect(updateFormSchema.safeParse({ formId: ID }).success).toBe(true) + }) + it('strips a slug change (slug is immutable post-create)', () => { + const r = updateFormSchema.safeParse({ formId: ID, slug: 'new-slug' }) expect(r.success).toBe(true) if (r.success) expect('slug' in r.data).toBe(false) }) + it('validates schema when provided', () => { + expect( + updateFormSchema.safeParse({ formId: ID, schema: [{ key: 'a', type: 'radio', label: 'A' }] }).success, + ).toBe(false) + }) }) -const fields: FormField[] = [ - { key: 'full_name', type: 'short_text', label: 'Full name', required: true }, - { key: 'agree', type: 'checkbox', label: 'I agree', required: true }, - { key: 'shirt', type: 'select', label: 'Shirt', required: false, options: ['S', 'M', 'L'] }, - { key: 'contact', type: 'email', label: 'Email', required: false }, -] +// ─── status / id / link / public schemas ───────────────────────────────────── -describe('validateAnswers', () => { - it('errors when a required field is missing', () => { - const r = validateAnswers(fields, { agree: true }) - expect(r.ok).toBe(false) - if (!r.ok) expect(r.error).toMatch(/Full name/) - }) - - it('errors when a required checkbox is not checked', () => { - const r = validateAnswers(fields, { full_name: 'Ada', agree: false }) - expect(r.ok).toBe(false) - }) - - it('rejects a select value outside the options', () => { - const r = validateAnswers(fields, { full_name: 'Ada', agree: true, shirt: 'XL' }) - expect(r.ok).toBe(false) - }) - - it('drops unknown keys and normalizes', () => { - const r = validateAnswers(fields, { - full_name: ' Ada ', - agree: 'on', - contact: 'Ada@Example.COM', - injected: 'evil', - }) - expect(r.ok).toBe(true) - if (r.ok) { - expect(r.value).toEqual({ full_name: 'Ada', agree: true, contact: 'ada@example.com' }) - expect('injected' in r.value).toBe(false) +describe('setFormStatusSchema / formIdSchema', () => { + const ID = '11111111-1111-1111-1111-111111111111' + it('accepts the three statuses, rejects others', () => { + for (const s of ['draft', 'published', 'closed']) { + expect(setFormStatusSchema.safeParse({ formId: ID, status: s }).success, s).toBe(true) } + expect(setFormStatusSchema.safeParse({ formId: ID, status: 'archived' }).success).toBe(false) + expect(setFormStatusSchema.safeParse({ formId: 'x', status: 'draft' }).success).toBe(false) + }) + it('formIdSchema requires a uuid', () => { + expect(formIdSchema.safeParse({ formId: ID }).success).toBe(true) + expect(formIdSchema.safeParse({ formId: 'x' }).success).toBe(false) + expect(formIdSchema.safeParse({}).success).toBe(false) }) }) -describe('onboarding form step (Phase 4)', () => { +describe('linkSubmissionsSchema', () => { + const ID = '11111111-1111-1111-1111-111111111111' + it('requires a uuid memberId and normalizes the email', () => { + expect(linkSubmissionsSchema.safeParse({ memberId: 'x', email: 'a@b.com' }).success).toBe(false) + expect(linkSubmissionsSchema.safeParse({ memberId: ID }).success).toBe(false) + const r = linkSubmissionsSchema.safeParse({ memberId: ID, email: 'Foo@Bar.COM' }) + expect(r.success).toBe(true) + if (r.success) expect(r.data.email).toBe('foo@bar.com') + }) +}) + +describe('getPublicFormSchema', () => { + it('validates the slug', () => { + expect(getPublicFormSchema.safeParse({ slug: 'a-form' }).success).toBe(true) + expect(getPublicFormSchema.safeParse({ slug: 'Bad' }).success).toBe(false) + }) +}) + +// ─── submitFormSchema ──────────────────────────────────────────────────────── + +describe('submitFormSchema', () => { + const ID = '11111111-1111-1111-1111-111111111111' + it('requires formId or slug', () => { + expect(submitFormSchema.safeParse({ answers: {} }).success).toBe(false) + expect(submitFormSchema.safeParse({ slug: 'a', answers: {} }).success).toBe(true) + expect(submitFormSchema.safeParse({ formId: ID }).success).toBe(true) + }) + it('defaults answers to an empty object', () => { + const r = submitFormSchema.safeParse({ slug: 'a' }) + expect(r.success).toBe(true) + if (r.success) expect(r.data.answers).toEqual({}) + }) + it('rejects a bad formId or slug', () => { + expect(submitFormSchema.safeParse({ formId: 'nope' }).success).toBe(false) + expect(submitFormSchema.safeParse({ slug: 'Bad Slug' }).success).toBe(false) + }) + it('email is optional/nullable and normalized; consent is boolean', () => { + expect(submitFormSchema.safeParse({ slug: 'a', email: null }).success).toBe(true) + expect(submitFormSchema.safeParse({ slug: 'a', email: 'not-an-email' }).success).toBe(false) + const r = submitFormSchema.safeParse({ slug: 'a', email: 'Me@Example.COM', consent: true }) + expect(r.success).toBe(true) + if (r.success) expect(r.data.email).toBe('me@example.com') + expect(submitFormSchema.safeParse({ slug: 'a', consent: 'yes' }).success).toBe(false) + }) +}) + +// ─── onboarding form step ──────────────────────────────────────────────────── + +describe('onboarding form step contract', () => { it('onboardingStepTypeSchema accepts "form" and rejects unknown', () => { expect(onboardingStepTypeSchema.safeParse('form').success).toBe(true) expect(onboardingStepTypeSchema.safeParse('content').success).toBe(true) expect(onboardingStepTypeSchema.safeParse('quiz').success).toBe(false) }) - - it('createOnboardingStepSchema accepts a form step with a form_id config', () => { - const r = createOnboardingStepSchema.safeParse({ - step_type: 'form', - title: 'Sign the liability waiver', - config: { form_id: '11111111-1111-1111-1111-111111111111' }, - }) - expect(r.success).toBe(true) + it('createOnboardingStepSchema accepts a form step with config and rejects no title', () => { + expect( + createOnboardingStepSchema.safeParse({ + step_type: 'form', + title: 'Sign the waiver', + config: { form_id: '11111111-1111-1111-1111-111111111111' }, + }).success, + ).toBe(true) + expect(createOnboardingStepSchema.safeParse({ step_type: 'form', title: '' }).success).toBe(false) }) }) +// ─── validateAnswers (server-side input boundary) ──────────────────────────── + +const fields: FormField[] = [ + { key: 'name', type: 'short_text', label: 'Name', required: true }, + { key: 'bio', type: 'long_text', label: 'Bio', required: false }, + { key: 'mail', type: 'email', label: 'Email', required: false }, + { key: 'age', type: 'number', label: 'Age', required: false }, + { key: 'dob', type: 'date', label: 'DOB', required: false }, + { key: 'agree', type: 'checkbox', label: 'Agree', required: true }, + { key: 'news', type: 'checkbox', label: 'News', required: false }, + { key: 'size', type: 'select', label: 'Size', required: false, options: ['S', 'M'] }, + { key: 'pick', type: 'radio', label: 'Pick', required: false, options: ['x', 'y'] }, +] + +describe('validateAnswers', () => { + it('errors on a missing required field, naming it', () => { + const r = validateAnswers(fields, { agree: true }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatch(/Name/) + }) + + it('reports the FIRST failing field by schema order, not a later one', () => { + // name (field 0) has a wrong type and agree (field 5) is unchecked; the + // error must be about Name, proving evaluation stops at the first failure. + const r = validateAnswers(fields, { name: 123, agree: false }) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.error).toMatch(/Name/) + expect(r.error).not.toMatch(/Agree/) + } + }) + + it('requires a checked required checkbox', () => { + const r = validateAnswers(fields, { name: 'A', agree: false }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatch(/Agree/) + }) + + it('accepts truthy checkbox encodings and stores a boolean', () => { + for (const v of [true, 'true', 'on', 1]) { + const r = validateAnswers(fields, { name: 'A', agree: v }) + expect(r.ok, String(v)).toBe(true) + if (r.ok) expect(r.value.agree).toBe(true) + } + }) + + it('stores an unchecked non-required checkbox as false, omits when absent', () => { + const r1 = validateAnswers(fields, { name: 'A', agree: true, news: false }) + expect(r1.ok).toBe(true) + if (r1.ok) expect(r1.value.news).toBe(false) + const r2 = validateAnswers(fields, { name: 'A', agree: true }) + expect(r2.ok).toBe(true) + if (r2.ok) expect('news' in r2.value).toBe(false) + }) + + it('trims text and enforces max lengths', () => { + const ok = validateAnswers(fields, { name: ' Ada ', agree: true }) + expect(ok.ok).toBe(true) + if (ok.ok) expect(ok.value.name).toBe('Ada') + expect(validateAnswers(fields, { name: 'x'.repeat(2001), agree: true }).ok).toBe(false) + expect(validateAnswers(fields, { name: 'A', agree: true, bio: 'x'.repeat(20001) }).ok).toBe(false) + }) + + it('rejects a non-string for a text field', () => { + expect(validateAnswers(fields, { name: 123, agree: true }).ok).toBe(false) + }) + + it('validates and normalizes email', () => { + const ok = validateAnswers(fields, { name: 'A', agree: true, mail: ' USER@Example.COM ' }) + expect(ok.ok).toBe(true) + if (ok.ok) expect(ok.value.mail).toBe('user@example.com') + expect(validateAnswers(fields, { name: 'A', agree: true, mail: 'bogus' }).ok).toBe(false) + }) + + it('coerces numbers and rejects non-numeric', () => { + const ok = validateAnswers(fields, { name: 'A', agree: true, age: '42' }) + expect(ok.ok).toBe(true) + if (ok.ok) expect(ok.value.age).toBe(42) + expect(validateAnswers(fields, { name: 'A', agree: true, age: 'abc' }).ok).toBe(false) + const e = validateAnswers(fields, { name: 'A', agree: true, age: '1e3' }) + if (e.ok) expect(e.value.age).toBe(1000) + }) + + it('validates dates', () => { + expect(validateAnswers(fields, { name: 'A', agree: true, dob: '2026-05-16' }).ok).toBe(true) + expect(validateAnswers(fields, { name: 'A', agree: true, dob: 'not-a-date' }).ok).toBe(false) + expect(validateAnswers(fields, { name: 'A', agree: true, dob: 99 }).ok).toBe(false) + }) + + it('enforces select/radio membership', () => { + expect(validateAnswers(fields, { name: 'A', agree: true, size: 'XL' }).ok).toBe(false) + expect(validateAnswers(fields, { name: 'A', agree: true, pick: 'z' }).ok).toBe(false) + const ok = validateAnswers(fields, { name: 'A', agree: true, size: 'M', pick: 'x' }) + expect(ok.ok).toBe(true) + if (ok.ok) { + expect(ok.value.size).toBe('M') + expect(ok.value.pick).toBe('x') + } + }) + + it('discards unknown keys (no arbitrary jsonb injection)', () => { + const r = validateAnswers(fields, { name: 'A', agree: true, __proto__pollute: 1, evil: 'x' }) + expect(r.ok).toBe(true) + if (r.ok) { + expect('evil' in r.value).toBe(false) + expect(Object.keys(r.value).sort()).toEqual(['agree', 'name']) + } + }) +}) + +// ─── parseFormSchema ───────────────────────────────────────────────────────── + describe('parseFormSchema', () => { - it('returns [] for malformed stored schema', () => { - expect(parseFormSchema('not an array')).toEqual([]) - expect(parseFormSchema([{ key: 'a' }])).toEqual([]) + it('round-trips a valid schema and applies the required default', () => { + const out = parseFormSchema([{ key: 'a', type: 'short_text', label: 'A' }]) + expect(out).toHaveLength(1) + expect(out[0].required).toBe(false) }) + it('returns [] for anything malformed', () => { + expect(parseFormSchema('not an array')).toEqual([]) + expect(parseFormSchema(null)).toEqual([]) + expect(parseFormSchema([{ key: 'a' }])).toEqual([]) + expect(parseFormSchema([{ key: 'a', type: 'select', label: 'A' }])).toEqual([]) + }) + it('strips unknown properties on a field', () => { + const out = parseFormSchema([{ key: 'a', type: 'short_text', label: 'A', danger: 'x' }]) + expect(out).toHaveLength(1) + expect('danger' in out[0]).toBe(false) + }) +}) - it('round-trips a valid schema', () => { - const raw = [{ key: 'a', type: 'short_text', label: 'A' }] - expect(parseFormSchema(raw)).toHaveLength(1) +// ─── csvCell ───────────────────────────────────────────────────────────────── + +describe('csvCell', () => { + it('blanks null/undefined', () => { + expect(csvCell(null)).toBe('') + expect(csvCell(undefined)).toBe('') + }) + it('passes plain strings and numbers through', () => { + expect(csvCell('abc')).toBe('abc') + expect(csvCell(5)).toBe('5') + expect(csvCell(true)).toBe('true') + }) + it('quotes and escapes commas, quotes, and newlines', () => { + expect(csvCell('a,b')).toBe('"a,b"') + expect(csvCell('a"b')).toBe('"a""b"') + expect(csvCell('a\nb')).toBe('"a\nb"') + expect(csvCell('a\r')).toBe('"a\r"') + }) + it('JSON-encodes objects and quotes them when needed', () => { + expect(csvCell({ x: 1 })).toBe('"{""x"":1}"') + expect(csvCell([1, 2])).toBe('"[1,2]"') + }) +}) + +// ─── parseClientIp ─────────────────────────────────────────────────────────── + +describe('parseClientIp', () => { + it('takes the first XFF entry, trimmed', () => { + expect(parseClientIp('1.2.3.4, 5.6.7.8', null)).toBe('1.2.3.4') + expect(parseClientIp(' 9.9.9.9 ', null)).toBe('9.9.9.9') + }) + it('prefers XFF over x-real-ip and falls back when XFF absent', () => { + expect(parseClientIp('1.1.1.1', '2.2.2.2')).toBe('1.1.1.1') + expect(parseClientIp(null, '2.2.2.2')).toBe('2.2.2.2') + }) + it('returns null for nothing or junk', () => { + expect(parseClientIp(null, null)).toBe(null) + expect(parseClientIp('not an ip!', null)).toBe(null) + expect(parseClientIp('x'.repeat(50), null)).toBe(null) + }) + it('accepts an ipv6-shaped token', () => { + expect(parseClientIp('::1', null)).toBe('::1') + }) +}) + +// ─── shouldBumpFormVersion ─────────────────────────────────────────────────── + +describe('shouldBumpFormVersion', () => { + it('bumps only a published waiver whose legal text or schema changed', () => { + expect(shouldBumpFormVersion({ kind: 'waiver', status: 'published', legalChanged: true, schemaChanged: false })).toBe(true) + expect(shouldBumpFormVersion({ kind: 'waiver', status: 'published', legalChanged: false, schemaChanged: true })).toBe(true) + expect(shouldBumpFormVersion({ kind: 'waiver', status: 'published', legalChanged: false, schemaChanged: false })).toBe(false) + expect(shouldBumpFormVersion({ kind: 'form', status: 'published', legalChanged: true, schemaChanged: true })).toBe(false) + expect(shouldBumpFormVersion({ kind: 'waiver', status: 'draft', legalChanged: true, schemaChanged: true })).toBe(false) + expect(shouldBumpFormVersion({ kind: 'waiver', status: 'closed', legalChanged: true, schemaChanged: true })).toBe(false) + }) +}) + +// ─── evaluateRequiredFormStep ──────────────────────────────────────────────── + +describe('evaluateRequiredFormStep', () => { + it('blocks a required non-form step', () => { + expect( + evaluateRequiredFormStep({ stepType: 'content', formId: null, formPublished: false, submissionExists: false }), + ).toBe('block') + }) + it('passes a form step with no configured form (misconfig is non-blocking)', () => { + expect( + evaluateRequiredFormStep({ stepType: 'form', formId: undefined, formPublished: false, submissionExists: false }), + ).toBe('pass') + }) + it('passes a form step whose form is missing/unpublished (fail open)', () => { + expect( + evaluateRequiredFormStep({ stepType: 'form', formId: 'f1', formPublished: false, submissionExists: false }), + ).toBe('pass') + }) + it('blocks a published required form with no submission', () => { + expect( + evaluateRequiredFormStep({ stepType: 'form', formId: 'f1', formPublished: true, submissionExists: false }), + ).toBe('block') + }) + it('passes (auto-satisfy) when a submission exists', () => { + expect( + evaluateRequiredFormStep({ stepType: 'form', formId: 'f1', formPublished: true, submissionExists: true }), + ).toBe('pass') + }) +}) + +// ─── slugify / deriveFieldKeys ─────────────────────────────────────────────── + +describe('slugify / deriveFieldKeys', () => { + it('slugifies with the requested separator and trims runs', () => { + expect(slugify('Hello World', '-')).toBe('hello-world') + expect(slugify(' --Trim__ ', '_')).toBe('trim') + expect(slugify('A!!!B', '_')).toBe('a_b') + expect(slugify('', '_')).toBe('') + expect(slugify(' ', '_')).toBe('') + }) + it('derives valid, unique keys from labels and dedupes', () => { + const out = deriveFieldKeys([ + { label: 'Full Name' }, + { label: 'Full Name' }, + { label: '' }, + { label: ' ' }, + { label: 'Full Name' }, + ]) + expect(out.map(f => f.key)).toEqual([ + 'full_name', + 'full_name_2', + 'field_3', + 'field_4', + 'full_name_3', + ]) + }) + it('preserves the other field properties', () => { + const out = deriveFieldKeys([{ label: 'Q', type: 'short_text', required: true }]) + expect(out[0]).toMatchObject({ label: 'Q', type: 'short_text', required: true, key: 'q' }) }) }) diff --git a/components/forms/form-builder.tsx b/components/forms/form-builder.tsx index 82b2764..5964919 100644 --- a/components/forms/form-builder.tsx +++ b/components/forms/form-builder.tsx @@ -20,6 +20,7 @@ import { } from '@/lib/actions' import { formFieldTypes } from '@/lib/validations' import type { FormField } from '@/lib/forms-schema' +import { slugify, deriveFieldKeys } from '@/lib/forms-logic' import { FormRenderer } from './form-renderer' type Kind = 'form' | 'waiver' @@ -49,27 +50,7 @@ const TYPE_LABELS: Record<(typeof formFieldTypes)[number], string> = { radio: 'Choice', } -function slugify(s: string, sep: '-' | '_') { - const cleaned = s - .toLowerCase() - .replace(/[^a-z0-9]+/g, sep) - .replace(new RegExp(`^\\${sep}+|\\${sep}+$`, 'g'), '') - return cleaned -} - -// Keys are derived from labels so the builder only ever asks for a label. -// Duplicates and empties are made safe here, matching the server schema. -function deriveKeys(fields: FormField[]): FormField[] { - const seen = new Set() - return fields.map((f, i) => { - const base = slugify(f.label || '', '_') || `field_${i + 1}` - let key = base - let n = 2 - while (seen.has(key)) key = `${base}_${n++}` - seen.add(key) - return { ...f, key } - }) -} +const deriveKeys = (fields: FormField[]): FormField[] => deriveFieldKeys(fields) export function FormBuilder({ initial }: { initial: BuilderForm }) { const router = useRouter() diff --git a/lib/actions/forms.ts b/lib/actions/forms.ts index bef1179..2012da2 100644 --- a/lib/actions/forms.ts +++ b/lib/actions/forms.ts @@ -22,6 +22,11 @@ import { getPublicFormSchema, } from '@/lib/validations' import { parseFormSchema, validateAnswers } from '@/lib/forms-schema' +import { + csvCell, + parseClientIp, + shouldBumpFormVersion, +} from '@/lib/forms-logic' const SUBMISSIONS_CAP = 5000 @@ -117,9 +122,12 @@ export async function updateForm(input: unknown) { const legalChanged = u.legal_text !== undefined && u.legal_text !== existing.legal_text const schemaChanged = u.schema !== undefined if ( - existing.kind === 'waiver' && - existing.status === 'published' && - (legalChanged || schemaChanged) + shouldBumpFormVersion({ + kind: existing.kind, + status: existing.status, + legalChanged, + schemaChanged, + }) ) { patch.version = (existing.version as number) + 1 } @@ -237,13 +245,6 @@ export async function getFormResults(input: unknown) { return { data: { form, submissions: submissions ?? [] } } } -function csvCell(value: unknown): string { - if (value === null || value === undefined) return '' - const s = typeof value === 'string' ? value : JSON.stringify(value) - if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"` - return s -} - export async function exportFormResultsCsv(input: unknown) { const gate = await requireFormsManager() if (!gate.ok) return { error: gate.error } @@ -301,15 +302,6 @@ export async function exportFormResultsCsv(input: unknown) { } } -const IP_RE = /^[0-9a-fA-F:.]{3,45}$/ - -function clientIp(h: Headers): string | null { - const fwd = h.get('x-forwarded-for') - const candidate = (fwd ? fwd.split(',')[0] : h.get('x-real-ip'))?.trim() - if (!candidate || !IP_RE.test(candidate)) return null - return candidate -} - /** * Public submission entry point. Callable by anonymous visitors, signed-in * non-members, or members depending on the form's visibility. Reads the form @@ -375,7 +367,7 @@ export async function submitForm(input: unknown) { form_snapshot: form.schema, legal_text_snapshot: form.legal_text ?? null, form_version: form.version, - ip: clientIp(h), + ip: parseClientIp(h.get('x-forwarded-for'), h.get('x-real-ip')), user_agent: h.get('user-agent'), }) if (insErr) return { error: insErr.message } diff --git a/lib/actions/onboarding.ts b/lib/actions/onboarding.ts index 2539492..1a65314 100644 --- a/lib/actions/onboarding.ts +++ b/lib/actions/onboarding.ts @@ -10,6 +10,7 @@ import { uuidSchema, } from '@/lib/validations' import { claimMyAnonymousSubmissions } from './forms' +import { evaluateRequiredFormStep } from '@/lib/forms-logic' const ONBOARDING_ADMIN_ROLES = ['admin', 'board'] as const @@ -184,23 +185,34 @@ export async function finishOnboarding() { return { error: 'Please complete the required steps before finishing.' } } const formId = (s.config as { form_id?: string } | null)?.form_id - if (!formId) continue - - const { data: form } = await supabase - .from('forms') - .select('id, status') - .eq('id', formId) - .eq('space_id', member.space_id) - .maybeSingle() - if (!form || form.status !== 'published') continue - - const { count } = await admin - .from('form_submissions') - .select('id', { count: 'exact', head: true }) - .eq('form_id', formId) - .eq('space_id', member.space_id) - .eq('member_id', member.id) - if ((count ?? 0) === 0) { + let formPublished = false + let submissionExists = false + if (formId) { + const { data: form } = await supabase + .from('forms') + .select('id, status') + .eq('id', formId) + .eq('space_id', member.space_id) + .maybeSingle() + formPublished = !!form && form.status === 'published' + if (formPublished) { + const { count } = await admin + .from('form_submissions') + .select('id', { count: 'exact', head: true }) + .eq('form_id', formId) + .eq('space_id', member.space_id) + .eq('member_id', member.id) + submissionExists = (count ?? 0) > 0 + } + } + if ( + evaluateRequiredFormStep({ + stepType: s.step_type, + formId, + formPublished, + submissionExists, + }) === 'block' + ) { return { error: 'Please complete the required form before finishing.' } } } diff --git a/lib/forms-logic.ts b/lib/forms-logic.ts new file mode 100644 index 0000000..812fa71 --- /dev/null +++ b/lib/forms-logic.ts @@ -0,0 +1,93 @@ +// Pure, dependency-free decision logic for the forms/waivers feature. +// Extracted from the server actions and the builder so the security- and +// correctness-critical branches can be unit-tested directly. No Supabase, +// no React, no Next imports here. + +// ─── CSV escaping (results export) ─────────────────────────────────────────── + +export function csvCell(value: unknown): string { + if (value === null || value === undefined) return '' + const s = typeof value === 'string' ? value : JSON.stringify(value) + if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"` + return s +} + +// ─── Client IP capture (e-sign defensibility) ──────────────────────────────── +// Only a plausibly IP-shaped token is kept; anything else becomes null so it +// can never break the inet column or store junk in a waiver record. + +const IP_RE = /^[0-9a-fA-F:.]{3,45}$/ + +export function parseClientIp( + forwardedFor: string | null, + realIp: string | null, +): string | null { + const candidate = (forwardedFor ? forwardedFor.split(',')[0] : realIp)?.trim() + if (!candidate || !IP_RE.test(candidate)) return null + return candidate +} + +// ─── Waiver version bump on update ─────────────────────────────────────────── +// Bump only a published waiver whose legal text or field schema actually +// changed. Non-blocking re-sign: existing submissions stay valid against the +// snapshot they captured; the bump only nudges future signers. + +export function shouldBumpFormVersion(input: { + kind: string + status: string + legalChanged: boolean + schemaChanged: boolean +}): boolean { + return ( + input.kind === 'waiver' && + input.status === 'published' && + (input.legalChanged || input.schemaChanged) + ) +} + +// ─── Required onboarding step satisfaction ─────────────────────────────────── +// Decision for one required step that is NOT already in completed_step_ids. +// - a non-form required step blocks (must be acknowledged in the flow) +// - a form step with no configured form id is non-blocking (misconfig) +// - a form step whose form is missing/unpublished is non-blocking +// (cannot require a form nobody can fill — fail open, never trap a member) +// - a form step with a published form is satisfied iff a submission by this +// member exists (any version — re-sign is non-blocking / auto-satisfy) + +export function evaluateRequiredFormStep(input: { + stepType: string + formId: string | null | undefined + formPublished: boolean + submissionExists: boolean +}): 'block' | 'pass' { + if (input.stepType !== 'form') return 'block' + if (!input.formId) return 'pass' + if (!input.formPublished) return 'pass' + return input.submissionExists ? 'pass' : 'block' +} + +// ─── Builder field keys ────────────────────────────────────────────────────── +// The builder only ever asks for a label; keys are derived here so they are +// always valid (/^[a-z0-9_]+$/) and unique within a form. Duplicate or empty +// labels are made safe, matching the server-side formFieldSchema. + +export function slugify(s: string, sep: '-' | '_'): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, sep) + .replace(new RegExp(`^\\${sep}+|\\${sep}+$`, 'g'), '') +} + +export function deriveFieldKeys( + fields: T[], +): (T & { key: string })[] { + const seen = new Set() + return fields.map((f, i) => { + const base = slugify(f.label || '', '_') || `field_${i + 1}` + let key = base + let n = 2 + while (seen.has(key)) key = `${base}_${n++}` + seen.add(key) + return { ...f, key } + }) +}