test(forms): extract pure decision logic and add exhaustive coverage

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.
This commit is contained in:
Moheeb Zara
2026-05-16 16:40:09 -07:00
parent 40d4cdad98
commit 5c39317877
5 changed files with 564 additions and 138 deletions
+425 -77
View File
@@ -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<string, unknown> = { 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',
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)
})
// slug is simply stripped (not in the schema), so parse still succeeds
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: '[email protected]',
injected: 'evil',
})
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.value).toEqual({ full_name: 'Ada', agree: true, contact: '[email protected]' })
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: '[email protected]' }).success).toBe(false)
expect(linkSubmissionsSchema.safeParse({ memberId: ID }).success).toBe(false)
const r = linkSubmissionsSchema.safeParse({ memberId: ID, email: '[email protected]' })
expect(r.success).toBe(true)
if (r.success) expect(r.data.email).toBe('[email protected]')
})
})
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: '[email protected]', consent: true })
expect(r.success).toBe(true)
if (r.success) expect(r.data.email).toBe('[email protected]')
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({
it('createOnboardingStepSchema accepts a form step with config and rejects no title', () => {
expect(
createOnboardingStepSchema.safeParse({
step_type: 'form',
title: 'Sign the liability waiver',
title: 'Sign the waiver',
config: { form_id: '11111111-1111-1111-1111-111111111111' },
})
expect(r.success).toBe(true)
}).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: ' [email protected] ' })
expect(ok.ok).toBe(true)
if (ok.ok) expect(ok.value.mail).toBe('[email protected]')
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' })
})
})
+2 -21
View File
@@ -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<string>()
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()
+12 -20
View File
@@ -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 }
+17 -5
View File
@@ -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
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()
if (!form || form.status !== 'published') continue
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)
if ((count ?? 0) === 0) {
submissionExists = (count ?? 0) > 0
}
}
if (
evaluateRequiredFormStep({
stepType: s.step_type,
formId,
formPublished,
submissionExists,
}) === 'block'
) {
return { error: 'Please complete the required form before finishing.' }
}
}
+93
View File
@@ -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<T extends { label: string }>(
fields: T[],
): (T & { key: string })[] {
const seen = new Set<string>()
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 }
})
}