Files
motor-de-encuestas/lib/submit-validation.test.mts
markosbenitez 2e6fbd7801 feat(submit): submit v3 con validación de integridad server-side [4G]
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>
2026-06-24 21:27:49 -03:00

233 lines
10 KiB
TypeScript

// Tests Node puros (sin navegador, sin DB) del validador server-side de submit.
// Ejecutar: node --test lib/submit-validation.test.mts
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'
import { buildFormSchema } from './form-engine.ts'
import { validateSubmission } from './submit-validation.ts'
import type { ConditionalRule, Question } from './form-engine.types.ts'
import type { SubmitAnswerEntry } from './submit-validation.ts'
function q(partial: Partial<Question> & Pick<Question, 'code' | 'type' | 'prompt' | 'position'>): Question {
return {
id: partial.code,
is_sensitive: false,
required: false,
hint: null,
options: null,
max_select: null,
conditional_rules: null,
instance_generator: false,
instance_of: null,
...partial,
}
}
const r_show_si_3_1: ConditionalRule[] = [
{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' },
]
const VINCULOS = ['Hijo menor con discapacidad', 'Familiar mayor con discapacidad', 'Otro / Especificar']
function buildFixtureSchema() {
const rows: Question[] = [
q({ code: 'S2', type: 'section', prompt: 'Datos generales', position: 20 }),
q({
code: '2.5', type: 'single_choice', prompt: 'Género', position: 25, required: true,
options: [{ value: 'Femenino', label: 'Femenino' }, { value: 'Masculino', label: 'Masculino' }],
}),
q({ code: 'S3', type: 'section', prompt: 'Prevalencia', position: 30 }),
q({
code: '3.1', type: 'single_choice', prompt: '¿Tenés discapacidad?', position: 31,
options: [{ value: 'Sí', label: 'Sí' }, { value: 'No', label: 'No' }],
}),
q({
code: '3.2', type: 'multiple_choice', prompt: 'Tipo', position: 32, conditional_rules: r_show_si_3_1,
options: [{ value: 'Visual', label: 'Visual' }, { value: 'Auditiva', label: 'Auditiva' }],
}),
q({ code: 'S4', type: 'section', prompt: 'Vinculación familiar', position: 40 }),
q({
code: '4.1', type: 'multiple_choice', prompt: 'Vínculos', position: 41, instance_generator: true,
options: VINCULOS.map((v) => ({ value: v, label: v })),
}),
q({
code: '4.2', type: 'multiple_choice', prompt: 'Tipo familiar', position: 42, instance_of: '4.1',
max_select: 2,
options: [{ value: 'Visual', label: 'Visual' }, { value: 'Auditiva', label: 'Auditiva' }],
}),
]
return buildFormSchema(rows)
}
// ── Happy path: 2 familiares ──────────────────────────────────────────────
test('happy path: submit válido con 2 familiares — payload completo con instance_id', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '3.1', instanceId: 'default', value: 'No' }, // oculta 3.2 — no se manda
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0], VINCULOS[1]] },
{ code: '4.2', instanceId: 'familiar_1', value: ['Visual'] },
{ code: '4.2', instanceId: 'familiar_2', value: ['Auditiva'] },
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, true)
if (!result.ok) return
// ── Dato concreto pedido en el reporte: filas reales con instance_id ──
console.log('\nPayload validado → filas listas para la RPC (questionId/instanceId/value):')
console.log(JSON.stringify(result.rows, null, 2))
assert.equal(result.rows.length, 5)
const f1 = result.rows.find((r) => r.questionId === '4.2' && r.instanceId === 'familiar_1')
const f2 = result.rows.find((r) => r.questionId === '4.2' && r.instanceId === 'familiar_2')
assert.deepEqual(f1?.value, ['Visual'])
assert.deepEqual(f2?.value, ['Auditiva'])
})
describe('rechazos — las 7 validaciones', () => {
test('rechaza respuesta a una pregunta que el flujo nunca debió mostrar (3.2 con 3.1=No)', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '3.1', instanceId: 'default', value: 'No' },
{ code: '3.2', instanceId: 'default', value: ['Visual'] }, // 3.1='No' la oculta
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('question_should_be_hidden:3.2')))
})
test('rechaza valor fuera de las opciones válidas', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '3.1', instanceId: 'default', value: 'Tal vez' }, // no es 'Sí' ni 'No'
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.includes('value_not_in_options') && e.includes('3.1')))
})
test('rechaza max_select excedido (4.2 tiene max_select=2)', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0]] },
{ code: '4.2', instanceId: 'familiar_1', value: ['Visual', 'Auditiva', 'Visual'] }, // 3 > max_select(2)
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.includes('exceeds_max_select')))
})
test('rechaza instancia inventada (familiar que 4.1 no generó)', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0]] }, // genera solo familiar_1
{ code: '4.2', instanceId: 'familiar_2', value: ['Visual'] }, // familiar_2 NO existe
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('invalid_instance:4.2:familiar_2')))
})
test('rechaza instance_id en una pregunta que no es de instancia', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'familiar_1', value: 'Femenino' }, // 2.5 no es instance_of nada
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('unexpected_instance_id:2.5')))
})
test('rechaza sin consentimiento operativo', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [{ code: '2.5', instanceId: 'default', value: 'Femenino' }]
const result = validateSubmission(schema, entries, false)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.includes('consent_operativo_required'))
})
test('rechaza pregunta requerida visible sin responder', () => {
const schema = buildFixtureSchema()
// 2.5 es required=true y no se manda
const entries: SubmitAnswerEntry[] = [{ code: '3.1', instanceId: 'default', value: 'No' }]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('required_missing:2.5')))
})
test('rechaza pregunta que no existe en el esquema (code inventado)', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '99.9', instanceId: 'default', value: 'inyectado' },
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('unknown_question:99.9')))
})
test('rechaza respuesta a una fila type=section', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: 'S4', instanceId: 'default', value: 'algo' },
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('section_is_not_answerable:S4')))
})
test('rechaza valor de tipo incorrecto (string donde se espera array)', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '4.1', instanceId: 'default', value: 'Hijo menor con discapacidad' as unknown as string[] },
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.includes('expected_array_value')))
})
test('reporta TODOS los errores encontrados, no solo el primero', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '99.9', instanceId: 'default', value: 'inyectado' },
{ code: 'S4', instanceId: 'default', value: 'algo' },
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.length >= 2, `esperaba >=2 errores, hubo ${result.errors.length}`)
})
})
describe('valores vacíos', () => {
test('una entrada con valor vacío no se valida ni se persiste, pero no bloquea el submit', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '3.1', instanceId: 'default', value: '' }, // vacío — se ignora, no se evalúa su visibilidad
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, true)
if (!result.ok) return
assert.equal(result.rows.some((r) => r.questionId === '3.1'), false)
})
})