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>
233 lines
10 KiB
TypeScript
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)
|
|
})
|
|
})
|