mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
feat(G1-03): governance invariants suite — 7 eixos, 22 green + 7 GAP ratchets [gov-loop]
tests/invariants/gov-1..7: per-eixo invariants against disposable Postgres; it.fails ratchets flag known gaps (G2-G6). Spec 13 Apêndice A filled. Verified-by: gov-verifier PASS 2026-07-16T19:48-03:00
This commit is contained in:
@@ -117,7 +117,36 @@ Aprovação do checkpoint G6 é o gatilho da fase FG do Vendaval.
|
||||
|
||||
## Apêndice A — Invariantes de governança (G1-03)
|
||||
|
||||
_Preenchido por G1-03: tabela eixo → invariante → status (passa | GAP Gx)._
|
||||
Suíte executável em `tests/invariants/gov-*.test.ts` (1 arquivo por eixo),
|
||||
rodada por `pnpm test:invariants` (mesmo harness Postgres descartável do
|
||||
`pnpm test:db` — `scripts/test-db.sh`). Gap conhecido = `it.fails` com
|
||||
comentário `GAP(Gx)`: passa enquanto o gap existe; quando a fase corrigir, o
|
||||
`it.fails` quebra e obriga o flip para teste normal (catraca). Isolamento RLS
|
||||
entre orgs (pré-requisito de tudo) já é coberto por
|
||||
`tests/invariants/rls-isolation.test.ts` (G1-02).
|
||||
|
||||
| Eixo | Invariante (arquivo → teste) | Status |
|
||||
|---|---|---|
|
||||
| 1. RBAC | `gov-1-rbac.test.ts` → "fn_role_at_least ordena viewer < agent < manager < admin" | passa |
|
||||
| 1. RBAC | `gov-1-rbac.test.ts` → "fn_user_role_in mapeia viewer→1, agent→2, manager→3, admin→4" | passa |
|
||||
| 1. RBAC | `gov-1-rbac.test.ts` → "RLS impede agent de se auto-promover (user_orgs_update é admin-only)" | passa |
|
||||
| 1. RBAC | `gov-1-rbac.test.ts` → "role de membro é editável via API — PATCH /api/v1/team/[user_id]/role existe" (gap do plano JÁ fechado pelo EPIC-09) | passa |
|
||||
| 1. RBAC | `gov-1-rbac.test.ts` → "agent NÃO escreve config de pipeline (spec 13 §4: manager+)" | GAP G2 |
|
||||
| 1. RBAC | `gov-1-rbac.test.ts` → "viewer NÃO escreve em conversations (spec 13 §4: viewer é read-only)" | GAP G2 |
|
||||
| 2. Atribuição | `gov-2-assignment.test.ts` → "conversations tem assigned_to_user_id + assigned_at, com FK para auth.users" | passa |
|
||||
| 2. Atribuição | `gov-2-assignment.test.ts` → "crm_leads tem owner_user_id" | passa |
|
||||
| 2. Atribuição | `gov-2-assignment.test.ts` → "mudança de owner em crm_leads emite lead.assigned no event_log" | passa |
|
||||
| 3. Transferência | `gov-3-transfer.test.ts` → "claim atômico: UPDATE condicional atribui 1x; segundo claim concorrente perde" | passa |
|
||||
| 3. Transferência | `gov-3-transfer.test.ts` → "transferência é auditada: tabela conversation_assignment_events existe" | GAP G3 |
|
||||
| 4. Roteamento/fila | `gov-4-routing.test.ts` → "índice parcial idx_conversations_open_unassigned existe (base da fila)" | passa |
|
||||
| 4. Roteamento/fila | `gov-4-routing.test.ts` → "disponibilidade por atendente: tabela attendant_availability existe" | GAP G5 |
|
||||
| 5. Escopo | `gov-5-visibility-scope.test.ts` → "agent vê conversa atribuída a si mesmo (controle positivo)" | passa |
|
||||
| 5. Escopo | `gov-5-visibility-scope.test.ts` → "agent NÃO vê conversa atribuída a outro agent (spec 13 §4: own*)" | GAP G4 |
|
||||
| 6. Handoff IA | `gov-6-ai-handoff.test.ts` → "colunas de handoff do estado atual existem (bot_silenced_until, last_handoff_*, force_human)" | passa |
|
||||
| 6. Handoff IA | `gov-6-ai-handoff.test.ts` → "status 'ai_handling' é aceito pelo check de conversations.status" | passa |
|
||||
| 6. Handoff IA | `gov-6-ai-handoff.test.ts` → "conversations.assignee_kind ('user'\|'ai') existe" | GAP G6 |
|
||||
| 7. Tags | `gov-7-tags.test.ts` → "contacts.tags e crm_leads.tags existem com índice GIN" | passa |
|
||||
| 7. Tags | `gov-7-tags.test.ts` → "conversations.tags text[] existe" | GAP G3 |
|
||||
|
||||
## Apêndice B — Auditoria spec 04/05 vs código (G1-04)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"test:e2e": "playwright test",
|
||||
"test:unit": "vitest run",
|
||||
"test:db": "bash scripts/test-db.sh",
|
||||
"test:invariants": "bash scripts/test-db.sh",
|
||||
"gov:verify": "pnpm typecheck && pnpm lint && pnpm test:unit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+7
-2
@@ -77,8 +77,13 @@
|
||||
"priority": 30,
|
||||
"lane": "core",
|
||||
"kind": "build",
|
||||
"passes": false,
|
||||
"verification": null
|
||||
"passes": true,
|
||||
"verification": {
|
||||
"verdict": "PASS",
|
||||
"by": "gov-verifier",
|
||||
"at": "2026-07-16T19:48:00-0300",
|
||||
"commit": "self"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "G1-04",
|
||||
|
||||
@@ -60,3 +60,15 @@
|
||||
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).
|
||||
|
||||
## 2026-07-16 — sessão 4 do loop (core) — G1-03
|
||||
|
||||
- G1-03 (suíte de invariantes dos 7 eixos): 8 arquivos em tests/invariants/
|
||||
(gov-helpers + gov-1..7), 29 testes no total — 22 verdes + 7 catracas it.fails
|
||||
com GAP(Gx). `pnpm test:invariants` = alias do harness test-db.
|
||||
- Desvio aceito pelo verifier: gap-exemplo "role não editável via API" JÁ estava
|
||||
fechado (rota do EPIC-09 em app/api/v1/team/[user_id]/role) → virou invariante
|
||||
verde; gaps RBAC reais de G2: pipeline write por agent, conversations write
|
||||
por viewer. Catraca provada em probe (it.fails de assert válido → suíte RED).
|
||||
- gov-verifier: PASS, hash-check OK. Apêndice A da spec 13 preenchido (20 linhas).
|
||||
- Próxima sessão: G1-04 (auditoria de gap specs 04/05 vs código — sem deps).
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
GOV_AGENT_A,
|
||||
GOV_ADMIN,
|
||||
GOV_CONTACT_PROBE,
|
||||
GOV_MANAGER,
|
||||
GOV_ORG,
|
||||
GOV_PIPELINE,
|
||||
GOV_SESSION,
|
||||
GOV_VIEWER,
|
||||
lastLine,
|
||||
seedGov,
|
||||
sql,
|
||||
writeCountAs,
|
||||
} from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* Eixo 1 — RBAC (spec 13 §1; fase que fecha: G2).
|
||||
* docs/specs/13-spec-governanca-atendimento.md — dor: "atendente com
|
||||
* privilégios de owner; nível de acesso não-editável pós-atribuição;
|
||||
* enforcement só no frontend". Matriz alvo em spec 13 §4.
|
||||
*/
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
});
|
||||
|
||||
/** fn_role_at_least(GOV_ORG, threshold) for the user, as '1'/'0' per threshold. */
|
||||
function roleVector(userId: string): string {
|
||||
const thresholds = ["viewer", "agent", "manager", "admin"];
|
||||
const expr = thresholds
|
||||
.map((t) => `public.fn_role_at_least('${GOV_ORG}', '${t}')::int::text`)
|
||||
.join(" || ',' || ");
|
||||
return lastLine(
|
||||
sql(`
|
||||
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
|
||||
select ${expr};
|
||||
`),
|
||||
);
|
||||
}
|
||||
|
||||
describe("eixo 1 — RBAC", () => {
|
||||
it("fn_role_at_least ordena viewer < agent < manager < admin", () => {
|
||||
expect(roleVector(GOV_VIEWER)).toBe("1,0,0,0");
|
||||
expect(roleVector(GOV_AGENT_A)).toBe("1,1,0,0");
|
||||
expect(roleVector(GOV_MANAGER)).toBe("1,1,1,0");
|
||||
expect(roleVector(GOV_ADMIN)).toBe("1,1,1,1");
|
||||
});
|
||||
|
||||
it("fn_user_role_in mapeia viewer→1, agent→2, manager→3, admin→4", () => {
|
||||
const rank = (userId: string): string =>
|
||||
lastLine(
|
||||
sql(`
|
||||
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
|
||||
select public.fn_user_role_in('${GOV_ORG}')::text;
|
||||
`),
|
||||
);
|
||||
expect(rank(GOV_VIEWER)).toBe("1");
|
||||
expect(rank(GOV_AGENT_A)).toBe("2");
|
||||
expect(rank(GOV_MANAGER)).toBe("3");
|
||||
expect(rank(GOV_ADMIN)).toBe("4");
|
||||
});
|
||||
|
||||
it("RLS impede agent de se auto-promover (user_orgs_update é admin-only)", () => {
|
||||
const updated = writeCountAs(
|
||||
GOV_AGENT_A,
|
||||
`update public.user_organizations set role = 'admin'
|
||||
where user_id = '${GOV_AGENT_A}' and organization_id = '${GOV_ORG}'`,
|
||||
);
|
||||
expect(updated).toBe(0);
|
||||
const role = sql(
|
||||
`select role from public.user_organizations where user_id = '${GOV_AGENT_A}' and organization_id = '${GOV_ORG}';`,
|
||||
);
|
||||
expect(role).toBe("agent");
|
||||
});
|
||||
|
||||
// Listado no plano como gap conhecido ("role de membro não é editável via
|
||||
// API" → GAP G2), mas a rota JÁ existe (EPIC-09: PATCH
|
||||
// app/api/v1/team/[user_id]/role, admin-only + proteção de último admin).
|
||||
// Gap já fechado ⇒ registrado como invariante VERDE — um it.fails aqui
|
||||
// seria desonesto e quebraria a suíte.
|
||||
it("role de membro é editável via API — PATCH /api/v1/team/[user_id]/role existe com export PATCH", () => {
|
||||
const route = path.resolve(process.cwd(), "app/api/v1/team/[user_id]/role/route.ts");
|
||||
expect(existsSync(route)).toBe(true);
|
||||
expect(readFileSync(route, "utf8")).toContain("export async function PATCH");
|
||||
});
|
||||
|
||||
// GAP(G2): spec 13 §4 — pipelines (config) é manager+:write, agent=none.
|
||||
// Hoje a policy tenant_isolation_crm_pipelines_all é org-flat: qualquer
|
||||
// membro (incl. agent) escreve config de pipeline.
|
||||
it.fails("agent NÃO escreve config de pipeline (spec 13 §4: manager+)", () => {
|
||||
const updated = writeCountAs(
|
||||
GOV_AGENT_A,
|
||||
`update public.crm_pipelines set name = name where id = '${GOV_PIPELINE}'`,
|
||||
);
|
||||
expect(updated).toBe(0);
|
||||
});
|
||||
|
||||
// GAP(G2): spec 13 §4 — viewer é read-only em conversations. Hoje a policy
|
||||
// org-flat (WITH CHECK por org) deixa o viewer inserir/escrever.
|
||||
it.fails("viewer NÃO escreve em conversations (spec 13 §4: viewer é read-only)", () => {
|
||||
const inserted = writeCountAs(
|
||||
GOV_VIEWER,
|
||||
`insert into public.conversations (id, organization_id, contact_id, channel_session_id, status)
|
||||
values ('cccccccc-4444-4000-8000-000000000099', '${GOV_ORG}', '${GOV_CONTACT_PROBE}', '${GOV_SESSION}', 'open')
|
||||
on conflict do nothing`,
|
||||
);
|
||||
expect(inserted).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
GOV_AGENT_A,
|
||||
GOV_LEAD,
|
||||
GOV_ORG,
|
||||
columnExists,
|
||||
seedGov,
|
||||
sql,
|
||||
} from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* Eixo 2 — Atribuição (spec 13 §1; fase que fecha: G3).
|
||||
* docs/specs/13-spec-governanca-atendimento.md — dor: "lead/conversa sem
|
||||
* registro de quem atende; card sem responsável". Estado atual inventariado
|
||||
* na spec 13 §2 (assigned_to_user_id / owner_user_id).
|
||||
*/
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
});
|
||||
|
||||
describe("eixo 2 — atribuição", () => {
|
||||
it("conversations tem assigned_to_user_id + assigned_at, com FK para auth.users", () => {
|
||||
expect(columnExists("conversations", "assigned_to_user_id")).toBe(true);
|
||||
expect(columnExists("conversations", "assigned_at")).toBe(true);
|
||||
const fk = sql(
|
||||
`select exists(select 1 from pg_constraint where conname = 'conversations_assigned_to_user_id_fkey');`,
|
||||
);
|
||||
expect(fk).toBe("t");
|
||||
});
|
||||
|
||||
it("crm_leads tem owner_user_id (responsável de 1ª classe no card)", () => {
|
||||
expect(columnExists("crm_leads", "owner_user_id")).toBe(true);
|
||||
});
|
||||
|
||||
it("mudança de owner em crm_leads emite lead.assigned no event_log (trigger, nunca HTTP)", () => {
|
||||
sql(
|
||||
`update public.crm_leads set owner_user_id = '${GOV_AGENT_A}' where id = '${GOV_LEAD}';`,
|
||||
);
|
||||
const events = Number(
|
||||
sql(
|
||||
`select count(*) from public.event_log
|
||||
where organization_id = '${GOV_ORG}'
|
||||
and event_type = 'lead.assigned'
|
||||
and payload ->> 'lead_id' = '${GOV_LEAD}';`,
|
||||
),
|
||||
);
|
||||
expect(events).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
GOV_AGENT_A,
|
||||
GOV_AGENT_B,
|
||||
GOV_CONV_CLAIM,
|
||||
seedGov,
|
||||
tableExists,
|
||||
writeCountAs,
|
||||
} from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* Eixo 3 — Transferência (spec 13 §1; fase que fecha: G3).
|
||||
* docs/specs/13-spec-governanca-atendimento.md — dor: "assumir/transferir com
|
||||
* erro, sem auditoria". Claim atômico já especificado na spec 04 §9; auditoria
|
||||
* de mudança de dono é a tabela conversation_assignment_events (spec 13 §3).
|
||||
*/
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
});
|
||||
|
||||
describe("eixo 3 — transferência", () => {
|
||||
it("claim atômico: UPDATE condicional atribui 1x; segundo claim concorrente perde (0 rows)", () => {
|
||||
// Spec 04 §9: claim = UPDATE ... where assigned_to_user_id is null (o
|
||||
// perdedor recebe 0 rows e a rota devolve 409).
|
||||
const first = writeCountAs(
|
||||
GOV_AGENT_A,
|
||||
`update public.conversations
|
||||
set assigned_to_user_id = '${GOV_AGENT_A}', assigned_at = now(), status = 'claimed'
|
||||
where id = '${GOV_CONV_CLAIM}' and assigned_to_user_id is null`,
|
||||
);
|
||||
expect(first).toBe(1);
|
||||
|
||||
const second = writeCountAs(
|
||||
GOV_AGENT_B,
|
||||
`update public.conversations
|
||||
set assigned_to_user_id = '${GOV_AGENT_B}', assigned_at = now(), status = 'claimed'
|
||||
where id = '${GOV_CONV_CLAIM}' and assigned_to_user_id is null`,
|
||||
);
|
||||
expect(second).toBe(0);
|
||||
});
|
||||
|
||||
// GAP(G3): transferência não gera evento de auditoria — a tabela
|
||||
// conversation_assignment_events (spec 13 §3: org_id, conversation_id,
|
||||
// from/to, changed_by, reason claim|transfer|release|routing|handoff)
|
||||
// ainda não existe.
|
||||
it.fails("transferência é auditada: tabela conversation_assignment_events existe (spec 13 §3)", () => {
|
||||
expect(tableExists("conversation_assignment_events")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { indexExists, seedGov, tableExists } from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* Eixo 4 — Roteamento/fila (spec 13 §1; fase que fecha: G5).
|
||||
* docs/specs/13-spec-governanca-atendimento.md — dor: "sem fila, sem horário
|
||||
* por atendente, sem modo configurável, sem painel". Modelo alvo na spec 13
|
||||
* §3/§5 (attendant_availability, organizations.settings.routing, worker via
|
||||
* event_log).
|
||||
*/
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
});
|
||||
|
||||
describe("eixo 4 — roteamento/fila", () => {
|
||||
it("base da fila de não-atribuídas existe: índice parcial idx_conversations_open_unassigned (spec 04 §8.3)", () => {
|
||||
expect(indexExists("idx_conversations_open_unassigned")).toBe(true);
|
||||
});
|
||||
|
||||
// GAP(G5): sem disponibilidade/horário/capacidade por atendente — a tabela
|
||||
// attendant_availability (spec 13 §3: is_available, capacity, schedule
|
||||
// jsonb tz-aware) ainda não existe.
|
||||
it.fails("disponibilidade por atendente: tabela attendant_availability existe (spec 13 §3)", () => {
|
||||
expect(tableExists("attendant_availability")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
GOV_AGENT_A,
|
||||
GOV_AGENT_B,
|
||||
GOV_CONV_AGENT_B,
|
||||
countAs,
|
||||
seedGov,
|
||||
} from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* Eixo 5 — Escopo de visualização (spec 13 §1; fase que fecha: G4).
|
||||
* docs/specs/13-spec-governanca-atendimento.md — dor: '"select sem where":
|
||||
* atendente vê tudo; métricas sem filtro por responsável'. Matriz alvo na
|
||||
* spec 13 §4 (agent = own*; visibility_mode em §3).
|
||||
*/
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
});
|
||||
|
||||
describe("eixo 5 — escopo de visualização", () => {
|
||||
it("agent vê conversa atribuída a si mesmo (controle positivo — vale hoje e pós-G4)", () => {
|
||||
const own = countAs(
|
||||
GOV_AGENT_B,
|
||||
`select count(*) from public.conversations where id = '${GOV_CONV_AGENT_B}';`,
|
||||
);
|
||||
expect(own).toBe(1);
|
||||
});
|
||||
|
||||
// GAP(G4): a RLS de conversations é org-flat — agent A enxerga a conversa
|
||||
// atribuída ao agent B da mesma org. Spec 13 §4: agent = own*:read+write.
|
||||
it.fails("agent NÃO vê conversa atribuída a outro agent (spec 13 §4: agent = own*)", () => {
|
||||
const crossAgent = countAs(
|
||||
GOV_AGENT_A,
|
||||
`select count(*) from public.conversations where id = '${GOV_CONV_AGENT_B}';`,
|
||||
);
|
||||
expect(crossAgent).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { columnExists, seedGov, sql } from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* Eixo 6 — Handoff IA→humano (spec 13 §1; fase que fecha: G6, + fase FG do
|
||||
* Vendaval). docs/specs/13-spec-governanca-atendimento.md — dor: "IA não sabe
|
||||
* direcionar para humano disponível/fila". Semântica de handoff na spec 05;
|
||||
* alvo assignee_kind ('user'|'ai') na spec 13 §3.
|
||||
*/
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
});
|
||||
|
||||
describe("eixo 6 — handoff IA→humano", () => {
|
||||
it("colunas de handoff do estado atual existem (spec 13 §2)", () => {
|
||||
expect(columnExists("conversations", "bot_silenced_until")).toBe(true);
|
||||
expect(columnExists("conversations", "last_handoff_at")).toBe(true);
|
||||
expect(columnExists("conversations", "last_handoff_reason")).toBe(true);
|
||||
expect(columnExists("contacts", "force_human")).toBe(true);
|
||||
});
|
||||
|
||||
it("status 'ai_handling' é aceito pelo check de conversations.status", () => {
|
||||
const def = sql(
|
||||
`select pg_get_constraintdef(oid) from pg_constraint where conname = 'conversations_status_check';`,
|
||||
);
|
||||
expect(def).toContain("ai_handling");
|
||||
});
|
||||
|
||||
// GAP(G6): handoff ainda não é reassignment auditado de 1ª classe — a
|
||||
// coluna conversations.assignee_kind ('user'|'ai', spec 13 §3) não existe;
|
||||
// quem atende (humano vs IA) segue ambíguo entre status e ai_handling.
|
||||
it.fails("conversations.assignee_kind ('user'|'ai') existe (spec 13 §3)", () => {
|
||||
expect(columnExists("conversations", "assignee_kind")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { columnExists, indexExists, seedGov } from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* Eixo 7 — Tags (spec 13 §1; fase que fecha: G3).
|
||||
* docs/specs/13-spec-governanca-atendimento.md — dor: "origem/categoria/
|
||||
* etiquetas ausentes ou não-filtráveis". Padrão do repo: tags text[] + GIN
|
||||
* (CLAUDE.md §Modelagem); alvo conversations.tags na spec 13 §3.
|
||||
*/
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
});
|
||||
|
||||
describe("eixo 7 — tags", () => {
|
||||
it("contacts.tags e crm_leads.tags existem com índice GIN (padrão filtrável)", () => {
|
||||
expect(columnExists("contacts", "tags")).toBe(true);
|
||||
expect(columnExists("crm_leads", "tags")).toBe(true);
|
||||
expect(indexExists("idx_contacts_tags_gin")).toBe(true);
|
||||
expect(indexExists("idx_crm_leads_tags_gin")).toBe(true);
|
||||
});
|
||||
|
||||
// GAP(G3): conversas não são etiquetáveis — conversations.tags text[]
|
||||
// (spec 13 §3, mesmo padrão de contacts/leads) não existe.
|
||||
it.fails("conversations.tags text[] existe (spec 13 §3)", () => {
|
||||
expect(columnExists("conversations", "tags")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* G1-03 — shared harness for the governance invariants (gov-*.test.ts).
|
||||
*
|
||||
* Same docker-exec-psql pattern as rls-isolation.test.ts (G1-02): the suite
|
||||
* runs against the ephemeral Postgres started by scripts/test-db.sh
|
||||
* (baseline.sql applied), with JWT claims simulated via
|
||||
* set_config('request.jwt.claims', ...) — the exact auth.uid() path the
|
||||
* production RLS policies use. No real PII anywhere (LGPD): synthetic
|
||||
* @invariant.test emails only.
|
||||
*/
|
||||
|
||||
const container = process.env.TEST_DB_CONTAINER;
|
||||
if (!container) {
|
||||
throw new Error(
|
||||
"TEST_DB_CONTAINER not set — run this suite via `pnpm test:invariants` (scripts/test-db.sh)",
|
||||
);
|
||||
}
|
||||
const containerName: string = container;
|
||||
|
||||
/** Runs a SQL script in ONE psql session inside the container; returns stdout (tuples-only). */
|
||||
export 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();
|
||||
}
|
||||
|
||||
/** Last stdout line of a script (psql -tA prints one line per SELECT). */
|
||||
export function lastLine(out: string): string {
|
||||
const lines = out.split("\n");
|
||||
const last = lines[lines.length - 1];
|
||||
if (last === undefined) throw new Error(`empty psql output`);
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a SELECT count as the `authenticated` role with the given user's JWT
|
||||
* claims — same shape PostgREST/Supabase uses (session role + request.jwt.claims).
|
||||
*/
|
||||
export function countAs(userId: string, countQuery: string): number {
|
||||
const out = sql(`
|
||||
set role authenticated;
|
||||
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
|
||||
${countQuery}
|
||||
`);
|
||||
const last = lastLine(out);
|
||||
if (!/^\d+$/.test(last)) throw new Error(`unexpected psql output: ${out}`);
|
||||
return Number(last);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a DML (no trailing `;`, no RETURNING — the helper appends `returning 1`)
|
||||
* as the `authenticated` role with the user's claims; returns affected rows.
|
||||
* An RLS denial (42501 / with-check violation) counts as 0 rows — the write
|
||||
* was blocked, which is exactly what the invariant measures. Any other error
|
||||
* rethrows.
|
||||
*/
|
||||
export function writeCountAs(userId: string, dml: string): number {
|
||||
try {
|
||||
const out = sql(`
|
||||
set role authenticated;
|
||||
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
|
||||
with w as (${dml} returning 1) select count(*) from w;
|
||||
`);
|
||||
const last = lastLine(out);
|
||||
if (!/^\d+$/.test(last)) throw new Error(`unexpected psql output: ${out}`);
|
||||
return Number(last);
|
||||
} catch (err) {
|
||||
const stderr = (err as { stderr?: string }).stderr ?? "";
|
||||
if (stderr.includes("row-level security")) return 0;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function tableExists(table: string): boolean {
|
||||
return (
|
||||
sql(
|
||||
`select exists(select 1 from information_schema.tables where table_schema = 'public' and table_name = '${table}');`,
|
||||
) === "t"
|
||||
);
|
||||
}
|
||||
|
||||
export function columnExists(table: string, column: string): boolean {
|
||||
return (
|
||||
sql(
|
||||
`select exists(select 1 from information_schema.columns where table_schema = 'public' and table_name = '${table}' and column_name = '${column}');`,
|
||||
) === "t"
|
||||
);
|
||||
}
|
||||
|
||||
export function indexExists(index: string): boolean {
|
||||
return (
|
||||
sql(
|
||||
`select exists(select 1 from pg_indexes where schemaname = 'public' and indexname = '${index}');`,
|
||||
) === "t"
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed UUIDs (cccccccc- namespace; rls-isolation uses aaaa/bbbb) make the
|
||||
// seed idempotent AND race-safe across parallel test files (on conflict do nothing).
|
||||
export const GOV_ORG = "cccccccc-0000-4000-8000-000000000001";
|
||||
export const GOV_VIEWER = "cccccccc-1111-4000-8000-000000000001";
|
||||
export const GOV_AGENT_A = "cccccccc-1111-4000-8000-000000000002";
|
||||
export const GOV_AGENT_B = "cccccccc-1111-4000-8000-000000000003";
|
||||
export const GOV_MANAGER = "cccccccc-1111-4000-8000-000000000004";
|
||||
export const GOV_ADMIN = "cccccccc-1111-4000-8000-000000000005";
|
||||
export const GOV_SESSION = "cccccccc-2222-4000-8000-000000000001";
|
||||
// One contact per conversation: uniq_conversations_1to1_per_contact_session
|
||||
// (migration 0027) allows only ONE 1:1 conversation per (org, contact, session).
|
||||
export const GOV_CONTACT_1 = "cccccccc-3333-4000-8000-000000000001";
|
||||
export const GOV_CONTACT_2 = "cccccccc-3333-4000-8000-000000000002";
|
||||
export const GOV_CONTACT_3 = "cccccccc-3333-4000-8000-000000000003";
|
||||
/** Contact WITHOUT any conversation — reserved for write-probe inserts. */
|
||||
export const GOV_CONTACT_PROBE = "cccccccc-3333-4000-8000-000000000004";
|
||||
/** Unassigned open conversation (read-scope probes). */
|
||||
export const GOV_CONV_UNASSIGNED = "cccccccc-4444-4000-8000-000000000001";
|
||||
/** Conversation assigned to GOV_AGENT_B (visibility-scope probes). */
|
||||
export const GOV_CONV_AGENT_B = "cccccccc-4444-4000-8000-000000000002";
|
||||
/** Unassigned conversation reserved for the atomic-claim invariant. */
|
||||
export const GOV_CONV_CLAIM = "cccccccc-4444-4000-8000-000000000003";
|
||||
export const GOV_PIPELINE = "cccccccc-5555-4000-8000-000000000001";
|
||||
export const GOV_STAGE = "cccccccc-5555-4000-8000-000000000002";
|
||||
export const GOV_LEAD = "cccccccc-6666-4000-8000-000000000001";
|
||||
|
||||
const ROLE_USERS: ReadonlyArray<readonly [string, string, string]> = [
|
||||
[GOV_VIEWER, "viewer", "gov-viewer"],
|
||||
[GOV_AGENT_A, "agent", "gov-agent-a"],
|
||||
[GOV_AGENT_B, "agent", "gov-agent-b"],
|
||||
[GOV_MANAGER, "manager", "gov-manager"],
|
||||
[GOV_ADMIN, "admin", "gov-admin"],
|
||||
];
|
||||
|
||||
/** Idempotent seed: 1 org, 5 members (1 per role + 2nd agent), 3 conversations, 1 lead. */
|
||||
export function seedGov(): void {
|
||||
const users = ROLE_USERS.map(
|
||||
([id, , tag]) =>
|
||||
`insert into auth.users (id, email) values ('${id}', '${tag}@invariant.test') on conflict do nothing;`,
|
||||
).join("\n");
|
||||
const memberships = ROLE_USERS.map(
|
||||
([id, role]) =>
|
||||
`insert into public.user_organizations (user_id, organization_id, role, accepted_at)
|
||||
values ('${id}', '${GOV_ORG}', '${role}', now()) on conflict do nothing;`,
|
||||
).join("\n");
|
||||
|
||||
sql(`
|
||||
${users}
|
||||
insert into public.organizations (id, slug, legal_name, display_name)
|
||||
values ('${GOV_ORG}', 'gov-inv', 'Gov Invariant Org', 'Gov Inv')
|
||||
on conflict do nothing;
|
||||
${memberships}
|
||||
-- DO + exception (não ON CONFLICT): channel_sessions tem unique DEFERRABLE
|
||||
-- (phone_per_org), que ON CONFLICT sem arbiter rejeita, e o arbiter (id)
|
||||
-- não cobre a corrida no unique de waha_session_name entre arquivos paralelos.
|
||||
do $gov$ begin
|
||||
insert into public.channel_sessions (id, organization_id, waha_session_name, webhook_secret_encrypted)
|
||||
values ('${GOV_SESSION}', '${GOV_ORG}', 'gov-inv', '\\x00'::bytea);
|
||||
exception when unique_violation then null; end $gov$;
|
||||
insert into public.contacts (id, organization_id, display_name)
|
||||
values
|
||||
('${GOV_CONTACT_1}', '${GOV_ORG}', 'Gov Invariant Contact 1'),
|
||||
('${GOV_CONTACT_2}', '${GOV_ORG}', 'Gov Invariant Contact 2'),
|
||||
('${GOV_CONTACT_3}', '${GOV_ORG}', 'Gov Invariant Contact 3'),
|
||||
('${GOV_CONTACT_PROBE}', '${GOV_ORG}', 'Gov Invariant Contact Probe')
|
||||
on conflict do nothing;
|
||||
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status)
|
||||
values ('${GOV_CONV_UNASSIGNED}', '${GOV_ORG}', '${GOV_CONTACT_1}', '${GOV_SESSION}', 'open')
|
||||
on conflict do nothing;
|
||||
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status, assigned_to_user_id, assigned_at)
|
||||
values ('${GOV_CONV_AGENT_B}', '${GOV_ORG}', '${GOV_CONTACT_2}', '${GOV_SESSION}', 'claimed', '${GOV_AGENT_B}', now())
|
||||
on conflict do nothing;
|
||||
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status)
|
||||
values ('${GOV_CONV_CLAIM}', '${GOV_ORG}', '${GOV_CONTACT_3}', '${GOV_SESSION}', 'open')
|
||||
on conflict do nothing;
|
||||
insert into public.crm_pipelines (id, organization_id, name, slug)
|
||||
values ('${GOV_PIPELINE}', '${GOV_ORG}', 'Gov Invariant', 'gov-inv')
|
||||
on conflict do nothing;
|
||||
insert into public.crm_stages (id, organization_id, pipeline_id, name, slug, position)
|
||||
values ('${GOV_STAGE}', '${GOV_ORG}', '${GOV_PIPELINE}', 'Novo', 'novo', 1000)
|
||||
on conflict do nothing;
|
||||
insert into public.crm_leads (id, organization_id, pipeline_id, stage_id, title)
|
||||
values ('${GOV_LEAD}', '${GOV_ORG}', '${GOV_PIPELINE}', '${GOV_STAGE}', 'Gov invariant lead')
|
||||
on conflict do nothing;
|
||||
`);
|
||||
}
|
||||
Reference in New Issue
Block a user