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:
+12
-20
@@ -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 }
|
||||
|
||||
+29
-17
@@ -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.' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user