Files
motor-de-encuestas/supabase/tests/01_schema_and_logic.test.sql
2026-05-31 04:33:07 -03:00

310 lines
12 KiB
PL/PgSQL

-- ============================================================
-- Tests pgTAP: Etapa 1 — Esquema, RLS, Constraints, k-anonimato
--
-- Ejecutar con: supabase test db
-- Requiere: Docker + supabase start + extensión pgtap
--
-- Cubre las 6 validaciones mandatorias del ETAPA_1_PROMPT.md:
-- 1. Migración desde cero sin errores (verificado al correr supabase db reset)
-- 2. Esquema coincide con DATA_MODEL.md
-- 3. RLS: org_admin no ve filas crudas de responses ni answers
-- 4. Constraint: respondent_id en encuesta anónima → error
-- 5. k-anonimato: agg_segment n=3 → suppressed; n=5 → valor real
-- 6. Inmutabilidad: UPDATE/DELETE en consent_records → error
-- ============================================================
BEGIN;
SELECT plan(25);
-- ============================================================
-- SECCIÓN 1: Estructura del esquema (7 tablas + columnas clave)
-- ============================================================
SELECT has_table('public', 'organizations', '1.1 tabla organizations existe');
SELECT has_table('public', 'consent_versions', '1.2 tabla consent_versions existe');
SELECT has_table('public', 'surveys', '1.3 tabla surveys existe');
SELECT has_table('public', 'questions', '1.4 tabla questions existe');
SELECT has_table('public', 'consent_records', '1.5 tabla consent_records existe');
SELECT has_table('public', 'responses', '1.6 tabla responses existe');
SELECT has_table('public', 'answers', '1.7 tabla answers existe');
-- respondent_id debe existir y ser nullable (reservado pero NULL en v1)
SELECT has_column('public', 'responses', 'respondent_id',
'1.8 responses.respondent_id existe (reservado)');
SELECT col_is_null('public', 'responses', 'respondent_id',
'1.9 responses.respondent_id es nullable');
-- función agg_segment debe existir
SELECT has_function('public', 'agg_segment', ARRAY['uuid', 'text[]'],
'1.10 función agg_segment(uuid, text[]) existe');
-- ============================================================
-- SECCIÓN 2: Constraint — respondent_id en encuesta anónima
-- (Regla no negociable #2 de CLAUDE.md)
-- ============================================================
DO $$
DECLARE
v_org_id uuid := '10000000-ffff-0000-0000-000000000001';
v_cv_id uuid := '20000000-ffff-0000-0000-000000000001';
v_survey_id uuid := '30000000-ffff-0000-0000-000000000001';
v_cr_id uuid := '50000000-ffff-0000-0000-000000000001';
BEGIN
INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_constraint_org');
INSERT INTO public.consent_versions (id, version, body, scopes)
VALUES (v_cv_id, '_test-cv-constraint', 'body', '["operativo"]'::jsonb);
INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class)
VALUES (v_survey_id, v_org_id, '_test_anon_survey', true, 'sensible', 'confidencial');
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
END;
$$;
SELECT throws_ok(
format(
'INSERT INTO public.responses (survey_id, company_id, respondent_id, consent_record_id)
VALUES (%L, %L, gen_random_uuid(), %L)',
'30000000-ffff-0000-0000-000000000001',
'10000000-ffff-0000-0000-000000000001',
'50000000-ffff-0000-0000-000000000001'
),
'23514', -- check_violation SQLSTATE
NULL,
'2.1 respondent_id en encuesta anónima → check_violation'
);
-- Una response SIN respondent_id en encuesta anónima debe poder insertarse
SELECT lives_ok(
format(
'INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket)
VALUES (%L, %L, %L, %L)',
'30000000-ffff-0000-0000-000000000001',
'10000000-ffff-0000-0000-000000000001',
'50000000-ffff-0000-0000-000000000001',
'25-34'
),
'2.2 response sin respondent_id en encuesta anónima → permitido'
);
-- ============================================================
-- SECCIÓN 3: Inmutabilidad de consent_records
-- (ADR-003: evidencia permanente del consentimiento)
-- ============================================================
DO $$
DECLARE
v_cv_id uuid := '20000000-eeee-0000-0000-000000000001';
v_cr_id uuid := '50000000-eeee-0000-0000-000000000001';
BEGIN
INSERT INTO public.consent_versions (id, version, body, scopes)
VALUES (v_cv_id, '_test-cv-immutable', 'body', '["operativo"]'::jsonb);
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
END;
$$;
SELECT throws_ok(
format(
'UPDATE public.consent_records SET granted_at = now() WHERE id = %L',
'50000000-eeee-0000-0000-000000000001'
),
'42501', -- insufficient_privilege SQLSTATE
NULL,
'3.1 UPDATE en consent_records → error (inmutabilidad)'
);
SELECT throws_ok(
format(
'DELETE FROM public.consent_records WHERE id = %L',
'50000000-eeee-0000-0000-000000000001'
),
'42501',
NULL,
'3.2 DELETE en consent_records → error (inmutabilidad)'
);
-- ============================================================
-- SECCIÓN 4: k-anonimato — agg_segment
-- k_threshold=5: n=3 debe estar suprimido; n=5 debe mostrar valor
-- ============================================================
DO $$
DECLARE
v_org_id uuid := '10000000-dddd-0000-0000-000000000001';
v_cv_id uuid := '20000000-dddd-0000-0000-000000000001';
v_survey_id uuid := '30000000-dddd-0000-0000-000000000001';
v_cr_id uuid;
i int;
BEGIN
INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_kanonimato_org');
INSERT INTO public.consent_versions (id, version, body, scopes)
VALUES (v_cv_id, '_test-cv-kanon', 'body', '["operativo"]'::jsonb);
INSERT INTO public.surveys (
id, org_id, title, is_anonymous, data_sensitivity, security_class, k_threshold
)
VALUES (v_survey_id, v_org_id, '_test_kanonimato_survey', true, 'comun', 'confidencial', 5);
-- Insertar 3 responses en el mismo segmento (25-34 / Salud)
FOR i IN 1..3 LOOP
v_cr_id := gen_random_uuid();
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket, qi_sector)
VALUES (v_survey_id, v_org_id, v_cr_id, '25-34', 'Salud');
END LOOP;
END;
$$;
-- n=3 → suppressed debe ser TRUE, n debe ser NULL
SELECT ok(
(SELECT suppressed
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
WHERE (segment->>'qi_age_bucket') = '25-34'),
'4.1 agg_segment n=3 → suppressed=true'
);
SELECT is(
(SELECT n
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
WHERE (segment->>'qi_age_bucket') = '25-34'),
NULL::bigint,
'4.2 agg_segment n=3 → n=NULL (celda suprimida)'
);
-- Agregar 2 responses más → total 5
DO $$
DECLARE
v_cv_id uuid := '20000000-dddd-0000-0000-000000000001';
v_survey_id uuid := '30000000-dddd-0000-0000-000000000001';
v_org_id uuid := '10000000-dddd-0000-0000-000000000001';
v_cr_id uuid;
i int;
BEGIN
FOR i IN 1..2 LOOP
v_cr_id := gen_random_uuid();
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket, qi_sector)
VALUES (v_survey_id, v_org_id, v_cr_id, '25-34', 'Salud');
END LOOP;
END;
$$;
-- n=5 → suppressed debe ser FALSE, n debe ser 5
SELECT ok(
NOT (SELECT suppressed
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
WHERE (segment->>'qi_age_bucket') = '25-34'),
'4.3 agg_segment n=5 → suppressed=false'
);
SELECT is(
(SELECT n
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
WHERE (segment->>'qi_age_bucket') = '25-34'),
5::bigint,
'4.4 agg_segment n=5 → n=5 (valor real visible)'
);
-- ============================================================
-- SECCIÓN 5: RLS — org_admin no puede ver filas crudas
-- Se simula el rol 'authenticated' con JWT claim app_role='org_admin'
-- ============================================================
DO $$
DECLARE
v_org_a uuid := '10000000-aaaa-0000-0000-000000000001';
v_cv_id uuid := '20000000-aaaa-0000-0000-000000000001';
v_srv_id uuid := '30000000-aaaa-0000-0000-000000000001';
v_cr_id uuid := gen_random_uuid();
v_resp_count bigint;
v_ans_count bigint;
BEGIN
-- Setup: crear org, encuesta y una response
INSERT INTO public.organizations (id, name) VALUES (v_org_a, '_test_rls_org_a');
INSERT INTO public.consent_versions (id, version, body, scopes)
VALUES (v_cv_id, '_test-cv-rls', 'body', '["operativo"]'::jsonb);
INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class, status)
VALUES (v_srv_id, v_org_a, '_test_rls_survey', true, 'comun', 'confidencial', 'live');
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket)
VALUES (v_srv_id, v_org_a, v_cr_id, '35-44');
-- Simular JWT de org_admin de v_org_a
PERFORM set_config('request.jwt.claims',
json_build_object('app_role', 'org_admin', 'org_id', v_org_a::text)::text,
true
);
-- Cambiar al rol authenticated (con RLS activo)
SET LOCAL ROLE authenticated;
SELECT count(*) INTO v_resp_count FROM public.responses;
SELECT count(*) INTO v_ans_count FROM public.answers;
RESET ROLE;
-- Limpiar config
PERFORM set_config('request.jwt.claims', '', true);
IF v_resp_count != 0 THEN
RAISE EXCEPTION
'RLS FAIL: org_admin ve % filas crudas en responses (esperado: 0)',
v_resp_count;
END IF;
IF v_ans_count != 0 THEN
RAISE EXCEPTION
'RLS FAIL: org_admin ve % filas crudas en answers (esperado: 0)',
v_ans_count;
END IF;
END;
$$;
SELECT pass('5.1 RLS: org_admin ve 0 filas crudas en responses');
SELECT pass('5.2 RLS: org_admin ve 0 filas crudas en answers');
-- ============================================================
-- SECCIÓN 6: Verificación del seed
-- ============================================================
SELECT is(
(SELECT count(*)::int FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001'),
9,
'6.1 Seed: encuesta tiene 9 preguntas'
);
SELECT is(
(SELECT count(*)::int FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001'
AND jsonb_array_length(COALESCE(conditional_rules, '[]'::jsonb)) > 0),
3,
'6.2 Seed: 3 preguntas tienen conditional_rules (B1, C1, D1 declaradas)'
);
-- B1 tiene exactamente 3 reglas condicionales (→ B2, B4, B5)
SELECT is(
(SELECT jsonb_array_length(conditional_rules)
FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND code = 'B1'),
3,
'6.3 Seed: B1 tiene 3 reglas condicionales (despliega B2, B4, B5)'
);
-- H1 tiene max_select=3
SELECT is(
(SELECT max_select FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND code = 'H1'),
3,
'6.4 Seed: H1 max_select=3'
);
-- B5 es la única pregunta marcada is_sensitive=true
SELECT is(
(SELECT count(*)::int FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND is_sensitive = true),
1,
'6.5 Seed: exactamente 1 pregunta is_sensitive=true (B5)'
);
SELECT * FROM finish();
ROLLBACK;