feat(G1-02): disposable Postgres harness — baseline install+update + 2-tenant RLS isolation [gov-loop]

pnpm test:db: ephemeral pgvector:pg17, Supabase stubs prelude, baseline in
install (ON_ERROR_STOP=1) and update modes, 9 vitest RLS isolation tests.
Verified-by: gov-verifier PASS 2026-07-16T19:26-03:00
This commit is contained in:
Rafael Melgaço
2026-07-16 19:27:13 -03:00
parent 98eb5e6e40
commit 35899eab65
8 changed files with 396 additions and 3 deletions
+1
View File
@@ -20,6 +20,7 @@
"db:reset": "supabase db reset",
"test:e2e": "playwright test",
"test:unit": "vitest run",
"test:db": "bash scripts/test-db.sh",
"gov:verify": "pnpm typecheck && pnpm lint && pnpm test:unit"
},
"dependencies": {
+7 -2
View File
@@ -51,8 +51,13 @@
"priority": 20,
"lane": "core",
"kind": "build",
"passes": false,
"verification": null
"passes": true,
"verification": {
"verdict": "PASS",
"by": "gov-verifier",
"at": "2026-07-16T19:26:00-0300",
"commit": "self"
}
},
{
"id": "G1-03",
+16
View File
@@ -44,3 +44,19 @@
- Chão de entrada: AGENTS.md/GEMINI.md reapareceram (app externo regenera);
stashados como "orphan 2026-07-16T18:54:42-0300". `.lina/` segue intocado no chão.
- Próxima sessão: G1-02 (Postgres descartável + isolamento 2-tenants) é a elegível.
## 2026-07-16 — sessão 3 do loop (core) — G1-02
- G1-02 (Postgres descartável + isolamento 2-tenants): `pnpm test:db` sobe
pgvector:pg17 efêmero (porta 127.0.0.1:54329, --rm, trap EXIT), aplica prelude
de stubs Supabase (roles, auth.uid() via request.jwt.claims, storage.*) +
baseline install (ON_ERROR_STOP=1) + update, e roda 9 testes RLS via
`docker exec psql` — zero devDependency nova.
- gov-verifier: PASS com probes independentes (UPDATE cross-org → 0 rows;
authenticated sem claims → nada vaza; SIGINT → teardown ok). Hash-check OK.
- Sessão rodada pelo watchdog (Maestro): terminal Arquiteto ficou Idle após
G1-01 e a cooperação A2A do Espaço está pausada — continuidade assumida aqui.
- Nota pra fase futura: create policy do apêndice do baseline (0014/0017) não é
idempotente ("already exists" tolerado no update) — melhoria possível, não bug.
- Próxima sessão: G1-03 (suíte de invariantes dos 7 eixos) ou G1-04 (auditoria
de gap, sem deps) — G1-03 tem priority menor (30 < 40).
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# gov-loop G1-02 — baseline install+update gate + RLS isolation invariants.
#
# Sobe um Postgres efêmero (pgvector/pgvector:pg17), aplica supabase/baseline.sql
# em modo install (ON_ERROR_STOP=1 — qualquer statement falhando derruba o run),
# re-aplica em modo update (sem a flag — idempotência) e roda a suíte vitest de
# invariantes (tests/invariants/**) conectada ao container via `docker exec psql`.
# O container é SEMPRE derrubado no EXIT (sucesso ou falha).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BASELINE="$ROOT/supabase/baseline.sql"
PORT="${TEST_DB_PORT:-54329}"
CONTAINER="deskcomm-test-db-$$"
IMAGE="pgvector/pgvector:pg17"
[ -f "$BASELINE" ] || { echo "FATAL: $BASELINE não encontrado" >&2; exit 1; }
cleanup() {
echo "==> teardown: removendo container $CONTAINER"
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> subindo $IMAGE como $CONTAINER (porta local $PORT)"
docker run -d --rm --name "$CONTAINER" \
-p "127.0.0.1:${PORT}:5432" \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres \
"$IMAGE" >/dev/null
# Espera o servidor DEFINITIVO (o initdb sobe um temporário só em socket;
# testar via TCP 127.0.0.1 evita o falso-ready da fase de init).
ready=0
for _ in $(seq 1 60); do
if docker exec "$CONTAINER" psql -h 127.0.0.1 -U postgres -d postgres -c "select 1" >/dev/null 2>&1; then
ready=1; break
fi
sleep 1
done
[ "$ready" = 1 ] || { echo "FATAL: postgres não ficou pronto em 60s" >&2; exit 1; }
psql_install() {
docker exec -i "$CONTAINER" psql -U postgres -d postgres -v ON_ERROR_STOP=1 -q -f - "$@"
}
echo "==> prelude: stubs mínimos do Supabase (roles, auth.uid(), extensions)"
# Um Postgres cru não tem os roles/schemas do Supabase que o baseline (pg_dump) supõe.
# Criamos os stubs mínimos AQUI — nunca editar o baseline.sql pra isso.
psql_install <<'SQL'
do $$
begin
if not exists (select 1 from pg_roles where rolname = 'anon') then
create role anon nologin;
end if;
if not exists (select 1 from pg_roles where rolname = 'authenticated') then
create role authenticated nologin;
end if;
if not exists (select 1 from pg_roles where rolname = 'service_role') then
create role service_role nologin bypassrls;
end if;
end
$$;
create schema if not exists auth;
create schema if not exists extensions;
-- O baseline referencia extensions.uuid_generate_v4/gen_random_bytes e os tipos
-- public.vector/public.citext + gin_trgm_ops, mas não cria as extensões (pg_dump).
create extension if not exists "uuid-ossp" with schema extensions;
create extension if not exists pgcrypto with schema extensions;
create extension if not exists vector with schema public;
create extension if not exists citext with schema public;
create extension if not exists pg_trgm with schema public;
-- Stubs de storage (o apêndice do baseline cria buckets + policies em storage.objects).
create schema if not exists storage;
create table if not exists storage.buckets (
id text primary key,
name text not null,
public boolean not null default false,
file_size_limit bigint,
allowed_mime_types text[],
created_at timestamptz not null default now()
);
create table if not exists storage.objects (
id uuid primary key default gen_random_uuid(),
bucket_id text references storage.buckets(id),
name text,
owner uuid,
metadata jsonb,
created_at timestamptz not null default now()
);
-- Stub de auth.users (FKs do baseline apontam pra cá).
create table if not exists auth.users (
id uuid primary key default gen_random_uuid(),
email text unique,
created_at timestamptz not null default now()
);
-- Stub de auth.uid() lendo o claim `sub` de request.jwt.claims (mesmo contrato
-- do Supabase; os testes simulam o JWT via set_config).
create or replace function auth.uid() returns uuid
language sql stable
as $fn$
select nullif(current_setting('request.jwt.claims', true)::jsonb ->> 'sub', '')::uuid
$fn$;
grant usage on schema auth, extensions, storage to anon, authenticated, service_role;
grant select on auth.users to anon, authenticated, service_role;
SQL
echo "==> modo INSTALL: aplicando baseline.sql com ON_ERROR_STOP=1"
psql_install < "$BASELINE"
echo " ✓ install ok"
echo "==> modo UPDATE: re-aplicando baseline.sql sem ON_ERROR_STOP (idempotência)"
docker exec -i "$CONTAINER" psql -U postgres -d postgres -q -f - < "$BASELINE" >/dev/null
echo " ✓ update ok (re-apply terminou; erros tolerados por contrato)"
echo "==> invariantes: vitest (tests/invariants)"
TEST_DB_CONTAINER="$CONTAINER" vitest run --config vitest.db.config.ts
echo "==> test:db verde"
+51
View File
@@ -0,0 +1,51 @@
# tests/invariants — harness de invariantes de banco
Automatiza o gate manual da doutrina de migrations (CLAUDE.md §"Migrations & Banco",
item 7): o baseline tem que aplicar limpo num Postgres cru e o isolamento RLS entre
tenants tem que valer de verdade.
## Como rodar
```bash
pnpm test:db
```
Requisitos: Docker rodando. Nada mais — o harness não toca no seu banco nem nos
seus containers existentes.
## O contrato do harness (`scripts/test-db.sh`)
1. Sobe um container **efêmero** `pgvector/pgvector:pg17` (nome único
`deskcomm-test-db-<pid>`, porta local `54329`, override via `TEST_DB_PORT`).
2. Aplica um **prelude** com os stubs mínimos do Supabase que um Postgres cru não
tem: roles `anon`/`authenticated`/`service_role`, schemas `auth`/`extensions`,
`auth.users`, `auth.uid()` (lê o claim `sub` de `request.jwt.claims`),
`storage.buckets`/`storage.objects` e as extensões `uuid-ossp`, `pgcrypto`,
`vector`, `citext`, `pg_trgm`.
3. **Modo install**: aplica `supabase/baseline.sql` com `ON_ERROR_STOP=1` —
qualquer statement falhando derruba o run com exit ≠ 0 (é o que o
`install.sh` do kit self-host faz num banco novo).
4. **Modo update**: re-aplica o baseline **sem** `ON_ERROR_STOP` — prova a
idempotência do apêndice (é o que o `update.sh` faz num banco existente).
5. Roda a suíte vitest desta pasta (`vitest.db.config.ts`) com
`TEST_DB_CONTAINER` apontando pro container; os testes falam com o banco via
`docker exec psql` (sem driver novo no repo).
6. `trap` no `EXIT` remove o container **sempre** — sucesso ou falha.
## O que a suíte prova hoje
- `rls-isolation.test.ts` — cria 2 orgs + 1 usuário em cada e prova que o usuário
da org A lê **0 rows** da org B em `conversations`, `messages`, `contacts` e
`crm_leads`, sob RLS com claims simulados
(`set role authenticated` + `set_config('request.jwt.claims', ...)` — o mesmo
caminho `auth.uid()` → `fn_user_org_ids()` das policies de produção), mais o
controle positivo (a própria org continua legível).
## Regras pra adicionar invariante novo
- Arquivo novo `*.test.ts` nesta pasta; ele entra automaticamente no `test:db`
(config `vitest.db.config.ts`) e fica FORA do `test:unit`.
- Invariantes existentes são **congelados**: adicione, não edite/delete.
- Zero PII em seeds e asserts (LGPD) — dados sintéticos sempre.
- Se o invariante exigir schema novo, isso é migration (tripla completa), não
mudança no harness. O harness só consome `baseline.sql`.
+178
View File
@@ -0,0 +1,178 @@
import { execFileSync } from "node:child_process";
import { beforeAll, describe, expect, it } from "vitest";
/**
* G1-02 — RLS isolation invariant.
*
* Runs against the ephemeral Postgres container started by scripts/test-db.sh
* (baseline.sql already applied). Seeds 2 orgs + 1 user each, then proves that
* a user of org A sees ZERO rows of org B in conversations / messages /
* contacts / crm_leads under RLS, with JWT claims simulated via
* set_config('request.jwt.claims', ...) — the same auth.uid() /
* fn_user_org_ids() path production policies use.
*/
const container = process.env.TEST_DB_CONTAINER;
if (!container) {
throw new Error(
"TEST_DB_CONTAINER not set — run this suite via `pnpm test:db` (scripts/test-db.sh)",
);
}
const containerName: string = container;
/** Runs a SQL script in ONE psql session inside the container; returns stdout (tuples-only). */
function sql(script: string): string {
return execFileSync(
"docker",
[
"exec",
"-i",
containerName,
"psql",
"-U",
"postgres",
"-d",
"postgres",
"-v",
"ON_ERROR_STOP=1",
"-tA",
"-f",
"-",
],
{ input: script, encoding: "utf8" },
).trim();
}
// Fixed UUIDs make the seed idempotent (on conflict do nothing).
const ORG_A = "aaaaaaaa-0000-4000-8000-000000000001";
const ORG_B = "bbbbbbbb-0000-4000-8000-000000000002";
const USER_A = "aaaaaaaa-1111-4000-8000-000000000001";
const USER_B = "bbbbbbbb-1111-4000-8000-000000000002";
const SESS_A = "aaaaaaaa-2222-4000-8000-000000000001";
const SESS_B = "bbbbbbbb-2222-4000-8000-000000000002";
/**
* Runs SELECTs as the `authenticated` role with the given user's JWT claims,
* exactly how PostgREST/Supabase set them: session role + request.jwt.claims.
*/
function countAs(userId: string, countQuery: string): number {
const out = sql(`
set role authenticated;
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
${countQuery}
`);
// Output lines: set_config echo, then the count (last line).
const lines = out.split("\n");
const last = lines[lines.length - 1];
if (last === undefined || !/^\d+$/.test(last)) {
throw new Error(`unexpected psql output: ${out}`);
}
return Number(last);
}
function seedOrg(org: string, user: string, sess: string, tag: string): string {
// No real PII: synthetic emails/names only (LGPD).
return `
insert into auth.users (id, email) values ('${user}', 'rls-${tag}@invariant.test')
on conflict (id) do nothing;
insert into public.organizations (id, slug, legal_name, display_name)
values ('${org}', 'rls-inv-${tag}', 'RLS Invariant ${tag}', 'RLS ${tag}')
on conflict (id) do nothing;
insert into public.user_organizations (user_id, organization_id, role, accepted_at)
values ('${user}', '${org}', 'agent', now())
on conflict do nothing;
insert into public.channel_sessions (id, organization_id, waha_session_name, webhook_secret_encrypted)
values ('${sess}', '${org}', 'rls-inv-${tag}', '\\x00'::bytea)
on conflict (id) do nothing;
`;
}
beforeAll(() => {
sql(seedOrg(ORG_A, USER_A, SESS_A, "a") + seedOrg(ORG_B, USER_B, SESS_B, "b"));
// Contact → conversation → message + pipeline → stage → lead, per org.
sql(`
do $seed$
declare
v_org uuid;
v_sess uuid;
v_contact uuid;
v_conv uuid;
v_pipe uuid;
v_stage uuid;
begin
foreach v_org in array array['${ORG_A}'::uuid, '${ORG_B}'::uuid] loop
select id into v_sess from public.channel_sessions where organization_id = v_org limit 1;
select id into v_contact from public.contacts
where organization_id = v_org and display_name = 'RLS Invariant Contact';
if v_contact is null then
insert into public.contacts (organization_id, display_name)
values (v_org, 'RLS Invariant Contact') returning id into v_contact;
end if;
select id into v_conv from public.conversations
where organization_id = v_org and contact_id = v_contact;
if v_conv is null then
insert into public.conversations (organization_id, contact_id, channel_session_id)
values (v_org, v_contact, v_sess) returning id into v_conv;
end if;
if not exists (select 1 from public.messages where organization_id = v_org) then
insert into public.messages (organization_id, conversation_id, channel_session_id, contact_id, type, direction, body)
values (v_org, v_conv, v_sess, v_contact, 'text', 'inbound', 'rls invariant probe');
end if;
select id into v_pipe from public.crm_pipelines
where organization_id = v_org and slug = 'rls-inv';
if v_pipe is null then
insert into public.crm_pipelines (organization_id, name, slug)
values (v_org, 'RLS Invariant', 'rls-inv') returning id into v_pipe;
end if;
select id into v_stage from public.crm_stages
where organization_id = v_org and pipeline_id = v_pipe and slug = 'novo';
if v_stage is null then
insert into public.crm_stages (organization_id, pipeline_id, name, slug, position)
values (v_org, v_pipe, 'Novo', 'novo', 1000) returning id into v_stage;
end if;
if not exists (select 1 from public.crm_leads where organization_id = v_org) then
insert into public.crm_leads (organization_id, pipeline_id, stage_id, title)
values (v_org, v_pipe, v_stage, 'RLS invariant lead');
end if;
end loop;
end
$seed$;
`);
});
const TABLES = ["conversations", "messages", "contacts", "crm_leads"] as const;
describe("RLS tenant isolation (fn_user_org_ids pattern)", () => {
for (const table of TABLES) {
it(`user of org A reads 0 rows of org B in ${table}`, () => {
const crossTenant = countAs(
USER_A,
`select count(*) from public.${table} where organization_id = '${ORG_B}';`,
);
expect(crossTenant).toBe(0);
});
it(`user of org A still reads their own org rows in ${table} (positive control)`, () => {
const ownRows = countAs(
USER_A,
`select count(*) from public.${table} where organization_id = '${ORG_A}';`,
);
expect(ownRows).toBeGreaterThanOrEqual(1);
});
}
it("superuser sees both orgs (seed sanity: cross-tenant rows really exist)", () => {
const total = Number(
sql(
`select count(distinct organization_id) from public.contacts where organization_id in ('${ORG_A}','${ORG_B}');`,
),
);
expect(total).toBe(2);
});
});
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineConfig({
setupFiles: ["./tests/setup/vitest.setup.ts"],
globals: true,
coverage: { provider: "v8", reporter: ["text", "html"] },
exclude: ["node_modules", ".next", "dist", "tests/e2e/**"],
exclude: ["node_modules", ".next", "dist", "tests/e2e/**", "tests/invariants/**"],
},
resolve: { alias: { "@": path.resolve(__dirname, ".") } },
});
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
// Config dedicada da suíte de invariantes de banco (tests/invariants/**).
// Roda SÓ via `pnpm test:db` (scripts/test-db.sh), que sobe o Postgres efêmero
// e exporta TEST_DB_CONTAINER. Não faz parte do `pnpm test:unit`.
export default defineConfig({
test: {
environment: "node",
include: ["tests/invariants/**/*.test.ts"],
globals: false,
// Seed + queries via docker exec são lentos o suficiente pro default de 5s.
testTimeout: 30_000,
hookTimeout: 60_000,
},
resolve: { alias: { "@": path.resolve(__dirname, ".") } },
});