-- ============================================================ -- 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;