mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
Executable
+125
@@ -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"
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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, ".") } },
|
||||
});
|
||||
|
||||
@@ -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, ".") } },
|
||||
});
|
||||
Reference in New Issue
Block a user