RPC transaccional SECURITY DEFINER (rpc_submit_response) como único camino de escritura. Dropea las policies WITH CHECK(true): cierra la falla donde anon podía insertar cualquier cosa. Validación server-side (7 checks) reusando el motor. Persistencia atómica con instance_id por instancia. Verificado contra Postgres real: instancias separadas, atomicidad, RLS. Riesgo residual de bypass por REST documentado (SEC-004). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
151 lines
5.9 KiB
TypeScript
151 lines
5.9 KiB
TypeScript
// Validación de integridad server-side del submit (Etapa 4G).
|
|
// REUSA lib/form-engine.ts (resolveVisibility, resolveScreens, expandInstances) —
|
|
// no reimplementa lógica de flujo. Corre en el servidor (Server Action), nunca
|
|
// en el cliente: "la validación de cliente es UX, NO seguridad" (BRIEF decisión 15).
|
|
//
|
|
// Lo que esta función NO hace: persistir nada. Solo decide si el payload recibido
|
|
// es coherente con el esquema + el propio flujo declarado por el payload, y si es
|
|
// así, devuelve las filas listas para insertar (mapeadas a question_id).
|
|
|
|
import type { AnswerValue, FormAnswers, FormSchema, Question } from './form-engine.types'
|
|
// Extensión .ts explícita (no solo en .mts): a diferencia de form-engine.ts —
|
|
// que solo importa *tipos* de form-engine.types (se borran en runtime, Node
|
|
// nunca necesita resolverlos) — este archivo importa FUNCIONES reales y
|
|
// corre tanto bajo Next.js (Server Action) como bajo Node puro (tests). Node
|
|
// exige extensión explícita para resolver imports de valor; tsconfig.json
|
|
// habilita allowImportingTsExtensions para permitir esto sin romper tsc.
|
|
import { answerKey, expandInstances, isAnswerEmpty, resolveScreens, resolveVisibility } from './form-engine.ts'
|
|
|
|
export interface SubmitAnswerEntry {
|
|
code: string
|
|
/** 'default' para preguntas normales; 'familiar_N' para preguntas de instancia. */
|
|
instanceId: string
|
|
value: AnswerValue
|
|
}
|
|
|
|
export interface ValidatedRow {
|
|
questionId: string
|
|
instanceId: string
|
|
value: AnswerValue
|
|
}
|
|
|
|
export type ValidationResult =
|
|
| { ok: true; rows: ValidatedRow[] }
|
|
| { ok: false; errors: string[] }
|
|
|
|
function validateValueShape(question: Question, value: AnswerValue): string | null {
|
|
switch (question.type) {
|
|
case 'yes_no':
|
|
return value === 'true' || value === 'false' ? null : 'invalid_yes_no_value'
|
|
|
|
case 'single_choice': {
|
|
if (typeof value !== 'string') return 'expected_string_value'
|
|
if (question.options && !question.options.some((o) => o.value === value)) {
|
|
return 'value_not_in_options'
|
|
}
|
|
return null
|
|
}
|
|
|
|
case 'multiple_choice': {
|
|
if (!Array.isArray(value)) return 'expected_array_value'
|
|
if (!value.every((v) => typeof v === 'string')) return 'expected_string_array'
|
|
if (question.max_select !== null && value.length > question.max_select) {
|
|
return 'exceeds_max_select'
|
|
}
|
|
if (question.options) {
|
|
const valid = new Set(question.options.map((o) => o.value))
|
|
if (!value.every((v) => valid.has(v as string))) return 'value_not_in_options'
|
|
}
|
|
return null
|
|
}
|
|
|
|
case 'text_short':
|
|
case 'text_long':
|
|
return typeof value === 'string' ? null : 'expected_string_value'
|
|
|
|
// rating/nps/matrix/ranking/date/slider: no usados por el instrumento v3 hoy.
|
|
// No se valida su forma específica — si en el futuro se usan, agregar el caso.
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Corre las 7 validaciones del punto 4 de ETAPA_4G_PROMPT.md contra el
|
|
* esquema cargado de la DB. Si CUALQUIERA falla, devuelve ok:false con la
|
|
* lista completa de errores encontrados (no solo el primero) — el caller
|
|
* rechaza el submit entero, sin insertar nada.
|
|
*/
|
|
export function validateSubmission(
|
|
schema: FormSchema,
|
|
entries: SubmitAnswerEntry[],
|
|
consentOperativo: boolean
|
|
): ValidationResult {
|
|
const errors: string[] = []
|
|
const byCode = new Map(schema.questions.map((q) => [q.code, q] as const))
|
|
|
|
// Reconstruye el mismo FormAnswers que tuvo el wizard — las funciones del
|
|
// motor lo necesitan para evaluar reglas/instancias/visibilidad.
|
|
const answers: FormAnswers = {}
|
|
for (const e of entries) answers[answerKey(e.code, e.instanceId)] = e.value
|
|
|
|
// 6. Consentimiento operativo requerido presente
|
|
if (!consentOperativo) errors.push('consent_operativo_required')
|
|
|
|
const nonEmpty = entries.filter((e) => !isAnswerEmpty(e.value))
|
|
|
|
// 2. Preguntas legítimas · 3. Valores válidos · 5. Instancias legítimas
|
|
for (const e of nonEmpty) {
|
|
const q = byCode.get(e.code)
|
|
if (!q) { errors.push(`unknown_question:${e.code}`); continue }
|
|
if (q.type === 'section') { errors.push(`section_is_not_answerable:${e.code}`); continue }
|
|
|
|
if (q.instance_of) {
|
|
const generator = byCode.get(q.instance_of)
|
|
const instances = generator ? expandInstances(generator, answers) : []
|
|
if (!instances.some((i) => i.instanceId === e.instanceId)) {
|
|
errors.push(`invalid_instance:${e.code}:${e.instanceId}`)
|
|
continue
|
|
}
|
|
} else if (e.instanceId !== 'default') {
|
|
errors.push(`unexpected_instance_id:${e.code}:${e.instanceId}`)
|
|
continue
|
|
}
|
|
|
|
const shapeError = validateValueShape(q, e.value)
|
|
if (shapeError) errors.push(`${shapeError}:${e.code}`)
|
|
}
|
|
if (errors.length > 0) return { ok: false, errors }
|
|
|
|
// 4. Visibilidad coherente — cada respuesta corresponde a algo que el
|
|
// flujo (con las respuestas recibidas) debía mostrar. Usa resolveVisibility
|
|
// del motor directamente — ninguna lógica de show/hide se repite acá.
|
|
for (const e of nonEmpty) {
|
|
const q = byCode.get(e.code)!
|
|
if (!resolveVisibility(q, answers)) {
|
|
errors.push(`question_should_be_hidden:${e.code}:${e.instanceId}`)
|
|
}
|
|
}
|
|
if (errors.length > 0) return { ok: false, errors }
|
|
|
|
// 7. Requeridas presentes — recorre las pantallas que el motor resuelve
|
|
// como visibles dadas estas respuestas, exige valor donde required=true.
|
|
const screens = resolveScreens(schema, answers)
|
|
for (const s of screens) {
|
|
if (s.kind !== 'question' || !s.question?.required) continue
|
|
const v = answers[answerKey(s.question.code, s.instanceId ?? undefined)]
|
|
if (isAnswerEmpty(v)) {
|
|
errors.push(`required_missing:${s.question.code}:${s.instanceId ?? 'default'}`)
|
|
}
|
|
}
|
|
if (errors.length > 0) return { ok: false, errors }
|
|
|
|
const rows: ValidatedRow[] = nonEmpty.map((e) => ({
|
|
questionId: byCode.get(e.code)!.id,
|
|
instanceId: e.instanceId,
|
|
value: e.value,
|
|
}))
|
|
|
|
return { ok: true, rows }
|
|
}
|