Primer commit - Sprint 1
This commit is contained in:
309
supabase/tests/01_schema_and_logic.test.sql
Normal file
309
supabase/tests/01_schema_and_logic.test.sql
Normal file
@@ -0,0 +1,309 @@
|
||||
-- ============================================================
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user