310 lines
12 KiB
PL/PgSQL
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;
|