diff --git a/BACKLOG.md b/BACKLOG.md index cc3492b..8305e0b 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -7,6 +7,28 @@ ## Seguridad +### [SEC-004] RPC de submit es bypasseable vía REST directo (coherencia de flujo) +- **Estado:** Riesgo residual documentado — no resuelto en Etapa 4G +- **Contexto:** `rpc_submit_response` (migración `20260624000001_submit_rpc.sql`) es el único + camino de escritura para `consent_records`/`responses`/`answers` (las policies `WITH CHECK(true)` + que permitían INSERT directo a `anon` se eliminaron). La RPC valida lo ESTRUCTURAL en SQL + (el `question_id` pertenece a la encuesta, la encuesta está viva, el consentimiento llegó). + La validación de COHERENCIA DE FLUJO (visibilidad según `conditional_rules`, instancias + legítimas de `4.1`) vive en TypeScript (`lib/submit-validation.ts`, reusa `lib/form-engine.ts`) + porque reimplementarla en SQL violaría la regla de "no duplicar la lógica de flujo" (BRIEF 4G). +- **Riesgo:** quien llame a `rpc_submit_response` directo por la REST API de Supabase (sin pasar + por el Server Action de Next.js) puede mandar un payload con instancias/visibilidad incoherentes + que igual pase las validaciones estructurales de la RPC. +- **Por qué no es un incidente de privacidad:** el dato resultante sigue siendo anónimo + (`respondent_id=NULL`), `is_sensitive` se sigue respetando, y queda sujeto al umbral k-anonimato + en `agg_segment`/RPCs de dashboard igual que cualquier otra respuesta. Es un problema de calidad + de datos de investigación (una respuesta lógicamente imposible podría colarse), no de exposición + de datos. +- **Cierre posible (no implementado):** firmar el payload en el Server Action (HMAC con un secreto + server-only) y verificar la firma dentro de la RPC antes de insertar — así un caller directo sin + el secreto no puede producir un payload válido. Evaluar costo/beneficio antes de priorizar. +- **Dependencia:** no bloquea Sprint 4; evaluar junto con SEC-002 (sprint de compliance). + ### [SEC-002] Sprint de compliance & seguridad - **Estado:** Diferido — pendiente de priorizar tras Sprint 4 - **Contexto:** COMPLIANCE.md documenta el estado actual de seguridad (v2.1.0) y el roadmap diff --git a/COMPLIANCE.md b/COMPLIANCE.md index 7d9ba98..5926e57 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -2,7 +2,7 @@ > Documento vivo. Área: Compliance & Seguridad del producto "Datos País sobre el Cuidado". > Audiencia: dirección técnica, auditores externos, equipo legal, clientes (resumen). -> Última actualización: 2026-06-20 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo) +> Última actualización: 2026-06-24 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo) --- @@ -78,6 +78,12 @@ señal de inmadurez en seguridad. - **TECH-DEBT-008**: `/admin/responses` sin autenticación — mitigado por URL no publicada, pero requiere gate de auth antes de exponer. - **TECH-DEBT-009**: rotación de secrets Supabase post-CVE — pendiente. +- **SEC-004** (ver `BACKLOG.md`): el submit v3 (`rpc_submit_response`, Etapa 4G) cerró el INSERT + directo de `anon` sobre `consent_records`/`responses`/`answers` (antes `WITH CHECK(true)`). + La RPC valida estructura en SQL; la coherencia de flujo (visibilidad, instancias legítimas) se + valida en TypeScript antes de llamarla — quien la invoque directo por REST puede evadir esa + segunda capa. No es un riesgo de privacidad (el dato sigue anónimo y sujeto a k-anonimato), + es de calidad de datos de investigación. --- @@ -138,4 +144,5 @@ por inadecuación técnica y normativa, no por desconocimiento. ## 8. Historial de revisiones de este documento | Fecha | Versión producto | Cambio | |---|---|---| -| 2026-06-20 | 2.1.0 / 3.0.0-dev | Creación inicial. Estado de seguridad v2.1.0, roadmap de auditabilidad, decisión sobre blockchain. | \ No newline at end of file +| 2026-06-20 | 2.1.0 / 3.0.0-dev | Creación inicial. Estado de seguridad v2.1.0, roadmap de auditabilidad, decisión sobre blockchain. | +| 2026-06-24 | 2.1.0 / 3.0.0-dev | Etapa 4G: submit v3 vía RPC SECURITY DEFINER, cierra INSERT directo de anon. Registra SEC-004 (riesgo residual de bypass vía REST, ver BACKLOG.md). | \ No newline at end of file diff --git a/DATA_MODEL.md b/DATA_MODEL.md index 1b7d7c0..7d31612 100644 --- a/DATA_MODEL.md +++ b/DATA_MODEL.md @@ -97,6 +97,11 @@ qi_zone text ``` > Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar > (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`. + +> **Mapeo v3 (Etapa 4G, confirmado):** `qi_zone` ← `2.1` (departamento), `qi_age_bucket` ← `2.4` +> (rango de edad). `qi_sector` ← `2.6` (nivel organizacional) — v3 no tiene una pregunta +> equivalente a "área/gerencia" como v2 (`A4`); `2.6` es el cuasi-identificador organizacional +> más cercano disponible en el instrumento actual, no un mapeo 1:1 con el campo de v2. > Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para > minimizar re-identificación. diff --git a/app/actions/submit-survey.ts b/app/actions/submit-survey.ts index c1f2476..0189ebd 100644 --- a/app/actions/submit-survey.ts +++ b/app/actions/submit-survey.ts @@ -1,7 +1,10 @@ 'use server' import { headers } from 'next/headers' -import { createHash, randomUUID } from 'crypto' +import { createHash } from 'crypto' import { createClient as createSupabaseClient } from '@supabase/supabase-js' +import { buildFormSchema } from '@/lib/form-engine' +import type { Question as EngineQuestion } from '@/lib/form-engine.types' +import { validateSubmission, type SubmitAnswerEntry } from '@/lib/submit-validation' export type SubmitResult = | { success: true } @@ -9,103 +12,83 @@ export type SubmitResult = export async function submitSurvey(payload: { surveyId: string - answers: Record - questionMap: Record // código → question_id UUID + answers: SubmitAnswerEntry[] consentOperativo: boolean consentMacroN1: boolean }): Promise { - // 1. ip_hash: server-only, nunca en texto plano + // ip_hash: server-only, nunca en texto plano (dedup sin identidad) const headersList = await headers() - const rawIp = - headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown' + const rawIp = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown' const salt = process.env.IP_HASH_SALT ?? '' const ipHash = createHash('sha256').update(rawIp + salt).digest('hex') - // 2. Cliente Supabase con anon key (RLS policies permiten INSERT a anon) - // NUNCA usar service_role — el anon key respeta RLS + // Cliente con anon key — respeta RLS. NUNCA service_role (regla 9 CLAUDE.md). const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? '' const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '' const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey) - // 3. Leer survey para obtener org_id y consent_version_id + // 1. Validación #1: survey existe y está activa const { data: survey, error: sErr } = await supabase .from('surveys') - .select('org_id, consent_version_id') + .select('id, org_id, status') .eq('id', payload.surveyId) - .single() + .eq('status', 'live') + .maybeSingle() - if (sErr || !survey) return { success: false, error: 'survey_not_found' } + if (sErr) return { success: false, error: 'survey_lookup_failed' } + if (!survey) return { success: false, error: 'survey_not_found_or_not_live' } - // 4. Pre-generar UUIDs server-side para evitar .select().single() post-INSERT. - // - // DIAGNÓSTICO: el Supabase JS client usa RETURNING * en .select().single(), lo que - // requiere SELECT policy para anon en consent_records/responses. Esa policy no existe - // (correcto — anon no debe leer consent_records de otros respondentes). - // Solución: pre-generar UUIDs con randomUUID() y NO hacer SELECT after INSERT. - const consentId = randomUUID() - const responseId = randomUUID() - - // 5. INSERT consent_record (inmutable, ADR-003) - // Sin .select() para no activar RETURNING y evitar el error RLS 42501. - const { error: cErr } = await supabase - .from('consent_records') - .insert({ - id: consentId, - consent_version_id: survey.consent_version_id, - granted_scopes: { - operativo: payload.consentOperativo, - macro_n1: payload.consentMacroN1, - }, - granted_at: new Date().toISOString(), - }) - - if (cErr) return { success: false, error: `consent_insert_failed: ${cErr.message}` } - - // 6. Extraer qi_* de las respuestas - const qi_age_bucket = - typeof payload.answers['A5'] === 'string' ? payload.answers['A5'] : null - const qi_sector = - typeof payload.answers['A4'] === 'string' ? payload.answers['A4'] : null - const qi_zone = - typeof payload.answers['A1'] === 'string' ? payload.answers['A1'] : null - - // 7. INSERT response (respondent_id=null — encuesta anónima, regla 2 CLAUDE.md) - const { error: rErr } = await supabase - .from('responses') - .insert({ - id: responseId, - survey_id: payload.surveyId, - company_id: survey.org_id, - respondent_id: null, - ip_hash: ipHash, - consent_record_id: consentId, - qi_age_bucket: qi_age_bucket || null, - qi_sector: qi_sector || null, - qi_zone: qi_zone || null, - }) - - if (rErr) return { success: false, error: `response_insert_failed: ${rErr.message}` } - - // 8. INSERT answers — una fila por pregunta respondida - const answerRows = Object.entries(payload.answers) - .map(([code, value]) => ({ - response_id: responseId, - question_id: payload.questionMap[code], - value, - })) - .filter( - (row) => - row.question_id && - row.value !== null && - row.value !== undefined && - row.value !== '' && - !(Array.isArray(row.value) && row.value.length === 0) + // 2. Carga el esquema completo de la encuesta — es contra ESTO que se valida, + // nunca contra lo que el cliente afirma que vio. + const { data: questionRows, error: qErr } = await supabase + .from('questions') + .select( + 'id, code, type, prompt, position, is_sensitive, required, hint, ' + + 'max_select, options, conditional_rules, instance_generator, instance_of' ) + .eq('survey_id', payload.surveyId) - if (answerRows.length > 0) { - const { error: aErr } = await supabase.from('answers').insert(answerRows) - if (aErr) return { success: false, error: `answers_insert_failed: ${aErr.message}` } + if (qErr || !questionRows) return { success: false, error: 'schema_load_failed' } + + const schema = buildFormSchema(questionRows as unknown as EngineQuestion[]) + + // 3. Validaciones #2-#7 — reusa lib/form-engine.ts, no reimplementa flujo. + const result = validateSubmission(schema, payload.answers, payload.consentOperativo) + if (!result.ok) { + return { success: false, error: `validation_failed: ${result.errors.join(', ')}` } } + // 4. Cuasi-identificadores — códigos v3 (2.1 zona, 2.4 edad). + // qi_sector: v3 no tiene una pregunta equivalente a "área/gerencia" de v2. + // Se usa 2.6 (nivel organizacional) como proxy más cercano disponible — + // decisión a confirmar con el director, no hay un mapeo exacto. + const findDefault = (code: string): string | null => { + const entry = payload.answers.find((e) => e.code === code && e.instanceId === 'default') + return typeof entry?.value === 'string' ? entry.value : null + } + const qiZone = findDefault('2.1') + const qiAgeBucket = findDefault('2.4') + const qiSector = findDefault('2.6') + + // 5. Persistencia atómica vía RPC SECURITY DEFINER — ver migración + // 20260624000001_submit_rpc.sql para el detalle de por qué RPC y no + // INSERTs sueltos desde acá. + const { error: rpcErr } = await supabase.rpc('rpc_submit_response', { + p_survey_id: payload.surveyId, + p_ip_hash: ipHash, + p_qi_age_bucket: qiAgeBucket, + p_qi_sector: qiSector, + p_qi_zone: qiZone, + p_consent_operativo: payload.consentOperativo, + p_consent_macro_n1: payload.consentMacroN1, + p_answers: result.rows.map((r) => ({ + question_id: r.questionId, + instance_id: r.instanceId, + value: r.value, + })), + }) + + if (rpcErr) return { success: false, error: 'submit_failed' } + return { success: true } } diff --git a/components/wizard-form.tsx b/components/wizard-form.tsx index a7b98f8..e11b6e8 100644 --- a/components/wizard-form.tsx +++ b/components/wizard-form.tsx @@ -1,5 +1,5 @@ 'use client' -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react' import type { BrandConfig, Survey } from '@/lib/types' import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types' import { @@ -12,12 +12,13 @@ import { back, resolveScreens, } from '@/lib/form-engine' -import { loadDraft, saveDraft } from '@/lib/storage' +import { loadDraft, saveDraft, clearDraft } from '@/lib/storage' +import { submitSurvey } from '@/app/actions/submit-survey' import { ConsentScreen } from './consent-screen' import { ProgressBar } from './progress-bar' import { QuestionRenderer } from './question-renderer' -type Stage = 'consent' | 'form' +type Stage = 'consent' | 'form' | 'success' | 'error' interface WizardFormProps { survey: Survey @@ -55,6 +56,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) { const [a11yAnnounce, setA11yAnnounce] = useState('') const answerAreaRef = useRef(null) const [hasOverflow, setHasOverflow] = useState(false) + const [submitError, setSubmitError] = useState(null) + const [isPending, startTransition] = useTransition() useEffect(() => { const draft = loadDraft(survey.id) @@ -112,6 +115,43 @@ export function WizardForm({ survey, schema }: WizardFormProps) { ) } + if (stage === 'success') { + return ( +
+
+

¡Gracias por participar!

+

+ Tu respuesta fue registrada correctamente. Recordá que la encuesta es anónima: no hay + forma de vincular tus respuestas con tu identidad. +

+

+ Los resultados agregados ayudarán a {survey.brand_config?.org_name ?? 'tu empresa'} a + diseñar mejores apoyos para las personas cuidadoras. +

+
+
+ ) + } + + if (stage === 'error') { + return ( +
+
+

No se pudo enviar la encuesta

+

Hubo un error al guardar tus respuestas. Podés intentarlo de nuevo.

+ {submitError &&
{submitError}
} + +
+
+ ) + } + if (!screen) return null const currentValue = valueFor(screen, state.answers) @@ -159,6 +199,33 @@ export function WizardForm({ survey, schema }: WizardFormProps) { setState((s) => back(s, schema)) } + function handleSubmit() { + if (isPending) return + // state.answers usa claves "code:instanceId" — se descompone una sola vez + // acá, al borde cliente/servidor; el validador (lib/submit-validation.ts) + // recibe la forma explícita {code, instanceId, value}[]. + const answers = Object.entries(state.answers).map(([key, value]) => { + const sep = key.indexOf(':') + return { code: key.slice(0, sep), instanceId: key.slice(sep + 1), value } + }) + + startTransition(async () => { + const result = await submitSurvey({ + surveyId: survey.id, + answers, + consentOperativo, + consentMacroN1, + }) + if (result.success) { + clearDraft(survey.id) + setStage('success') + } else { + setSubmitError(result.error) + setStage('error') + } + }) + } + const bc: BrandConfig | null = survey.brand_config return ( @@ -219,8 +286,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {

Llegaste al final

- El envío de la encuesta se habilita en una próxima etapa. Por ahora podés revisar tus - respuestas con "Anterior". + Revisá tus respuestas con "Anterior" si querés corregir algo, o enviá la + encuesta cuando estés listo/a.

)} @@ -231,8 +298,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) { type="button" className="btn btn-secondary" onClick={handleBack} - disabled={idx === 0} - aria-disabled={idx === 0} + disabled={idx === 0 || isPending} + aria-disabled={idx === 0 || isPending} > Anterior @@ -244,8 +311,14 @@ export function WizardForm({ survey, schema }: WizardFormProps) { Siguiente ) : ( - )} diff --git a/lib/form-engine.ts b/lib/form-engine.ts index 53d6e9b..1ca5e90 100644 --- a/lib/form-engine.ts +++ b/lib/form-engine.ts @@ -176,7 +176,8 @@ export function setAnswer( } } -function isAnswerEmpty(v: AnswerValue | undefined): boolean { +/** Exportada porque también la usa el validador de submit server-side (4G). */ +export function isAnswerEmpty(v: AnswerValue | undefined): boolean { return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0) } diff --git a/lib/submit-validation.test.mts b/lib/submit-validation.test.mts new file mode 100644 index 0000000..89bf67e --- /dev/null +++ b/lib/submit-validation.test.mts @@ -0,0 +1,232 @@ +// 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 & Pick): 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) + }) +}) diff --git a/lib/submit-validation.ts b/lib/submit-validation.ts new file mode 100644 index 0000000..0e8d2ae --- /dev/null +++ b/lib/submit-validation.ts @@ -0,0 +1,150 @@ +// 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 } +} diff --git a/supabase/migrations/20260624000001_submit_rpc.sql b/supabase/migrations/20260624000001_submit_rpc.sql new file mode 100644 index 0000000..1cb80fa --- /dev/null +++ b/supabase/migrations/20260624000001_submit_rpc.sql @@ -0,0 +1,136 @@ +-- ============================================================ +-- Sprint 4 (v3.0.0) — Etapa 4G: submit transaccional + cierre de WITH CHECK(true) +-- +-- PROBLEMA QUE CIERRA: answers_insert/responses_insert/consent_records_insert +-- permitían INSERT directo a anon con casi ninguna restricción real +-- (answers_insert: WITH CHECK(true) literal) — el cliente era la única +-- fuente de verdad sobre qué se guardaba. Closes BRIEF decisión 15. +-- +-- DISEÑO — dos capas, cada una validando lo que le corresponde: +-- 1) lib/form-engine.ts + lib/submit-validation.ts, en el Server Action +-- (Next.js, TypeScript): valida COHERENCIA DE FLUJO — visibilidad, +-- instancias legítimas, opciones válidas, requeridas. Esto NO puede +-- vivir en SQL sin reimplementar el motor (prohibido explícitamente). +-- 2) Esta RPC (Postgres, SECURITY DEFINER): valida ESTRUCTURA — el +-- question_id pertenece a esta encuesta, la encuesta está viva, el +-- consentimiento llegó — y persiste todo de forma ATÓMICA (una +-- llamada a función = una transacción; cualquier RAISE EXCEPTION +-- revierte TODO, sin huérfanos). +-- +-- POR QUÉ SECURITY DEFINER: se revoca el INSERT directo de anon sobre las +-- 3 tablas (paso 1 abajo). Sin SECURITY DEFINER, esta función correría +-- con los privilegios de quien la llama (anon) y esas mismas policies +-- recién revocadas la bloquearían a ELLA también. SECURITY DEFINER la +-- hace correr con los privilegios de quien la creó (el owner de la +-- migración, no anon) — por eso puede insertar después de que anon ya +-- no puede hacerlo directo. Mismo patrón que las RPCs de dashboard +-- existentes (rpc_dashboard_kpis, etc.), aplicado por primera vez a +-- escritura en vez de lectura. +-- +-- LOS GRANTS DE TABLA (migration 006: GRANT INSERT ON answers TO anon...) +-- NO se tocan acá — siguen el "diseño de capas" que esa migración ya +-- documenta: Capa 1 (GRANT) da la capacidad de intentar el INSERT, +-- Capa 2 (RLS, esta migración) lo deniega por default al no quedar +-- ninguna policy permisiva. anon puede seguir "intentando" un INSERT +-- directo — RLS lo rechaza igual, con RLS habilitado (migration 002) y +-- cero policies de INSERT restantes en estas 3 tablas. +-- +-- RIESGO RESIDUAL DOCUMENTADO (no resuelto en esta etapa, ver reporte): +-- cualquiera que llame a esta RPC directamente vía la REST API de +-- Supabase (no a través de nuestro Server Action) puede mandar un +-- payload que pase las validaciones ESTRUCTURALES de acá pero sea +-- incoherente de flujo — porque el motor de flujo es TypeScript y no +-- corre dentro de Postgres. Es un problema de calidad de datos de +-- investigación, no de privacidad/k-anonimato (el dato sigue siendo +-- anónimo, is_sensitive, sujeto al umbral k en agg_segment). Cerrarlo +-- del todo excede el scope de 4G — candidato a BACKLOG.md si se decide +-- perseguirlo (ej. firma del payload por el Server Action). +-- ============================================================ + +-- ── 1. Revoca el INSERT directo — el único camino pasa a ser la RPC ─────── +DROP POLICY IF EXISTS "consent_records_insert" ON public.consent_records; +DROP POLICY IF EXISTS "responses_insert" ON public.responses; +DROP POLICY IF EXISTS "answers_insert" ON public.answers; + +-- ── 2. RPC de submit ──────────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION public.rpc_submit_response( + p_survey_id uuid, + p_ip_hash text, + p_qi_age_bucket text, + p_qi_sector text, + p_qi_zone text, + p_consent_operativo boolean, + p_consent_macro_n1 boolean, + p_answers jsonb -- [{"question_id":"...","instance_id":"familiar_1","value":...}, ...] +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_org_id uuid; + v_cv_id uuid; + v_consent_id uuid := gen_random_uuid(); + v_response_id uuid := gen_random_uuid(); + v_bad_refs int; +BEGIN + -- Defensa en profundidad: el Server Action ya confirmó esto antes de + -- llamar, pero esta función no debe confiar en el caller. + SELECT org_id, consent_version_id INTO v_org_id, v_cv_id + FROM public.surveys + WHERE id = p_survey_id AND status = 'live'; + + IF v_org_id IS NULL THEN + RAISE EXCEPTION 'survey_not_found_or_not_live' USING ERRCODE = 'check_violation'; + END IF; + + IF NOT p_consent_operativo THEN + RAISE EXCEPTION 'consent_operativo_required' USING ERRCODE = 'check_violation'; + END IF; + + -- Chequeo ESTRUCTURAL (no de flujo): cada question_id recibido existe + -- y pertenece a ESTA encuesta; cada instance_id viene no-vacío. + -- Visibilidad/instancia-legítima/opciones-válidas ya se validó en TS. + SELECT count(*) INTO v_bad_refs + FROM jsonb_to_recordset(p_answers) AS r(question_id uuid, instance_id text, value jsonb) + LEFT JOIN public.questions q ON q.id = r.question_id AND q.survey_id = p_survey_id + WHERE q.id IS NULL OR r.instance_id IS NULL OR r.instance_id = ''; + + IF v_bad_refs > 0 THEN + RAISE EXCEPTION 'invalid_answer_reference' USING ERRCODE = 'check_violation'; + END IF; + + -- consent_record (inmutable, ADR-003) + INSERT INTO public.consent_records (id, consent_version_id, granted_scopes, granted_at) + VALUES ( + v_consent_id, v_cv_id, + jsonb_build_object('operativo', p_consent_operativo, 'macro_n1', p_consent_macro_n1), + now() + ); + + -- response (respondent_id SIEMPRE NULL — encuesta anónima, regla #2 CLAUDE.md) + INSERT INTO public.responses ( + id, survey_id, company_id, respondent_id, ip_hash, + consent_record_id, qi_age_bucket, qi_sector, qi_zone + ) + VALUES ( + v_response_id, p_survey_id, v_org_id, NULL, p_ip_hash, + v_consent_id, p_qi_age_bucket, p_qi_sector, p_qi_zone + ); + + -- answers — un row por entrada validada, con su instance_id real + INSERT INTO public.answers (response_id, question_id, instance_id, value) + SELECT v_response_id, r.question_id, r.instance_id, r.value + FROM jsonb_to_recordset(p_answers) AS r(question_id uuid, instance_id text, value jsonb); + + RETURN jsonb_build_object('response_id', v_response_id); +END; +$$; + +REVOKE ALL ON FUNCTION public.rpc_submit_response( + uuid, text, text, text, text, boolean, boolean, jsonb +) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_submit_response( + uuid, text, text, text, text, boolean, boolean, jsonb +) TO anon, authenticated; diff --git a/tsconfig.json b/tsconfig.json index 6420eed..6a5aef7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, + "allowImportingTsExtensions": true, "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }],