mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Com o prelude do test:db reproduzindo o privilégio padrão de tabelas do Supabase, três comentários passaram a afirmar um estado que não vale mais: que o prelude cobre só funções e que por isso o caso de api_audit_log em retencao-poda-e-expurgo fica verde com ou sem o revoke da migration 0258. O texto novo diz a que ponto isso valia e o que foi medido: tirando `update, delete` do bloco da 0258, o caso reprova com o prelude novo e passa com o antigo. Só linhas de comentário mudam. O comentário no baseline.sql fica dentro do bloco da 0258 e não altera o SQL aplicado. DESKCOMM_GOV_INVARIANTS_EDIT: os dois arquivos de tests/invariants mudam só em comentário, que a issue #887 torna falso. Nenhuma asserção muda. Refs #887 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
279 lines
14 KiB
TypeScript
279 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { lastLine, sql } from "./gov-helpers";
|
|
|
|
/**
|
|
* A PODA DA FILA E O EXPURGO DA AUDITORIA CONTRA UM POSTGRES DE VERDADE — issue #261.
|
|
*
|
|
* ─── Por que invariante e não unidade ───────────────────────────────────────
|
|
*
|
|
* O que pode quebrar aqui é SQL, e as regras que importam só existem no banco:
|
|
* quais status são terminais, o anti-join contra o aviso ainda aberto, o piso
|
|
* dentro do corpo da função, a cascata das FKs e — a mais importante — o
|
|
* privilégio de quem pode chamar. Nenhuma dessas propriedades é observável de
|
|
* um mock. Roda contra o Postgres efêmero do `scripts/test-db.sh`, com o MESMO
|
|
* `baseline.sql` que o kit self-host aplica.
|
|
*
|
|
* ─── O defeito medido ───────────────────────────────────────────────────────
|
|
*
|
|
* $ grep -rn "from job_queue" lib workers app supabase scripts | grep -i delete
|
|
* (zero linhas)
|
|
*
|
|
* `job_queue` crescia desde a instalação; `api_audit_log` prometia retenção de 5
|
|
* anos no COMMENT e não tinha expurgo nenhum. Numa instalação parada elas são as
|
|
* candidatas naturais a estourar os 500 MB do plano free do Supabase.
|
|
*
|
|
* ─── O risco do conserto, que é o que este arquivo vigia ────────────────────
|
|
*
|
|
* Uma poda mal cortada PERDE TRABALHO. `pending` é trabalho que ainda vai sair;
|
|
* `running` está com um worker agora. E `dead` com aviso ABERTO na Central tem
|
|
* dono também: um humano que ainda não olhou. Um DELETE por idade que não
|
|
* distinga essas três coisas é pior que a tabela crescendo.
|
|
*/
|
|
|
|
const ORG = "26100000-0000-4000-8000-000000000001";
|
|
const CONTATO = "26100000-0000-4000-8000-000000000002";
|
|
|
|
/** Um id determinístico por caso — sem colisão entre os testes deste arquivo. */
|
|
function id(n: number): string {
|
|
return `26100000-1111-4000-8000-${String(n).padStart(12, "0")}`;
|
|
}
|
|
|
|
function conta(query: string): number {
|
|
return Number(lastLine(sql(query)));
|
|
}
|
|
|
|
/**
|
|
* `idade` em dias ANTES de agora. `status` decide o `kind` porque o CHECK
|
|
* `job_queue_turn_needs_contact` amarra kind ⇔ contato — a poda não olha kind
|
|
* nenhum, então usar `inbound_turn` em todos os casos é indiferente para a regra.
|
|
*/
|
|
function enfileirar(opts: { id: string; status: string; idadeDias: number }): void {
|
|
sql(`
|
|
insert into job_queue (id, organization_id, contact_id, kind, payload, status, created_at)
|
|
values ('${opts.id}', '${ORG}', '${CONTATO}', 'inbound_turn', '{}'::jsonb,
|
|
'${opts.status}', now() - interval '${opts.idadeDias} days');
|
|
`);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
sql(`
|
|
insert into organizations (id, slug, legal_name, display_name)
|
|
values ('${ORG}', 'org-retencao-261', 'Org Retencao LTDA', 'Org Retencao')
|
|
on conflict (id) do nothing;
|
|
insert into contacts (id, organization_id, name, phone_number)
|
|
values ('${CONTATO}', '${ORG}', 'Lead da Poda', '+5511900000261')
|
|
on conflict (id) do nothing;
|
|
delete from agent_inbox_items where organization_id = '${ORG}';
|
|
delete from job_queue where organization_id = '${ORG}';
|
|
delete from api_audit_log where organization_id = '${ORG}';
|
|
`);
|
|
});
|
|
|
|
describe("fn_podar_fila_de_jobs — apaga o terminal velho, preserva o que tem dono", () => {
|
|
it("controle positivo: a função existe e devolve inteiro", () => {
|
|
// Sem isto, uma função ausente faria os casos de "não apagou" passarem por
|
|
// vacuidade — zero apagados é verdade quando nada roda.
|
|
expect(conta(`select public.fn_podar_fila_de_jobs(90, 100)`)).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it("apaga done/failed/dead velhos e NÃO toca em pending/running", () => {
|
|
enfileirar({ id: id(1), status: "done", idadeDias: 200 });
|
|
enfileirar({ id: id(2), status: "failed", idadeDias: 200 });
|
|
enfileirar({ id: id(3), status: "dead", idadeDias: 200 });
|
|
// `pending` e `running` com a MESMA idade: se a regra fosse só idade, os
|
|
// cinco sairiam juntos. É o corte por status que este caso mede.
|
|
enfileirar({ id: id(4), status: "pending", idadeDias: 200 });
|
|
enfileirar({ id: id(5), status: "running", idadeDias: 200 });
|
|
|
|
const apagados = conta(`select public.fn_podar_fila_de_jobs(90, 1000)`);
|
|
expect(apagados).toBe(3);
|
|
|
|
const sobraram = sql(
|
|
`select string_agg(status, ',' order by status) from job_queue where organization_id = '${ORG}'`,
|
|
);
|
|
expect(lastLine(sobraram)).toBe("pending,running");
|
|
});
|
|
|
|
it("NÃO apaga terminal recente (o corte é por idade, e é por created_at)", () => {
|
|
enfileirar({ id: id(6), status: "done", idadeDias: 10 });
|
|
expect(conta(`select public.fn_podar_fila_de_jobs(90, 1000)`)).toBe(0);
|
|
expect(conta(`select count(*) from job_queue where id = '${id(6)}'`)).toBe(1);
|
|
});
|
|
|
|
it("NÃO apaga `dead` cujo aviso na Central ainda está ABERTO", () => {
|
|
// O `failJob`/`reapExpiredJobs` abre `agent_inbox_items` com
|
|
// ref_kind='job_queue' e ref_id = job.id. Apagar o job deixaria o aviso
|
|
// apontando para o vazio — e quem ia investigar perde o objeto.
|
|
enfileirar({ id: id(7), status: "dead", idadeDias: 400 });
|
|
enfileirar({ id: id(8), status: "dead", idadeDias: 400 });
|
|
sql(`
|
|
insert into agent_inbox_items (organization_id, kind, severity, title, ref_kind, ref_id, status)
|
|
values ('${ORG}', 'job_dead', 'critical', 'Job descartado', 'job_queue', '${id(7)}', 'open'),
|
|
('${ORG}', 'job_dead', 'critical', 'Job descartado', 'job_queue', '${id(8)}', 'resolved');
|
|
`);
|
|
|
|
expect(conta(`select public.fn_podar_fila_de_jobs(90, 1000)`)).toBe(1);
|
|
// O protegido é o do aviso ABERTO; o do aviso já resolvido sai.
|
|
expect(conta(`select count(*) from job_queue where id = '${id(7)}'`)).toBe(1);
|
|
expect(conta(`select count(*) from job_queue where id = '${id(8)}'`)).toBe(0);
|
|
});
|
|
|
|
it("o job protegido não trava a fila: o lote pula por cima dele", () => {
|
|
// A armadilha que este caso prende: filtrar os protegidos DEPOIS do `limit`
|
|
// faria um lote inteiro de protegidos devolver 0, o laço do cron pararia
|
|
// achando que acabou, e a poda morreria de fome com backlog atrás.
|
|
enfileirar({ id: id(9), status: "dead", idadeDias: 500 }); // o MAIS VELHO, protegido
|
|
sql(`
|
|
insert into agent_inbox_items (organization_id, kind, severity, title, ref_kind, ref_id, status)
|
|
values ('${ORG}', 'job_dead', 'critical', 'Job descartado', 'job_queue', '${id(9)}', 'open');
|
|
`);
|
|
enfileirar({ id: id(10), status: "done", idadeDias: 400 });
|
|
|
|
// Lote de UM: se o filtro viesse depois do limit, o lote seria só o
|
|
// protegido e a função devolveria 0 para sempre.
|
|
expect(conta(`select public.fn_podar_fila_de_jobs(90, 1)`)).toBe(1);
|
|
expect(conta(`select count(*) from job_queue where id = '${id(10)}'`)).toBe(0);
|
|
});
|
|
|
|
it("respeita o `p_limite` (é isso que torna o DELETE lote, e não travamento)", () => {
|
|
for (let i = 0; i < 5; i += 1) {
|
|
enfileirar({ id: id(20 + i), status: "done", idadeDias: 300 });
|
|
}
|
|
expect(conta(`select public.fn_podar_fila_de_jobs(90, 2)`)).toBe(2);
|
|
expect(conta(`select count(*) from job_queue where organization_id = '${ORG}'`)).toBe(3);
|
|
});
|
|
|
|
it("o PISO de 7 dias mora na função: p_retencao_dias = 0 não apaga o de ontem", () => {
|
|
enfileirar({ id: id(30), status: "done", idadeDias: 1 });
|
|
enfileirar({ id: id(31), status: "done", idadeDias: 30 });
|
|
// Quem chama pede zero; a função eleva ao piso e o de ontem sobrevive.
|
|
expect(conta(`select public.fn_podar_fila_de_jobs(0, 1000)`)).toBe(1);
|
|
expect(conta(`select count(*) from job_queue where id = '${id(30)}'`)).toBe(1);
|
|
});
|
|
|
|
it("a cascata leva o send_ledger do run (efeito DECLARADO, não surpresa)", () => {
|
|
enfileirar({ id: id(40), status: "done", idadeDias: 300 });
|
|
sql(`
|
|
insert into send_ledger (organization_id, contact_id, job_id, seq, body_hash, status)
|
|
values ('${ORG}', '${CONTATO}', '${id(40)}', 1, 'hash-261', 'accepted');
|
|
`);
|
|
expect(conta(`select count(*) from send_ledger where job_id = '${id(40)}'`)).toBe(1);
|
|
|
|
conta(`select public.fn_podar_fila_de_jobs(90, 1000)`);
|
|
|
|
// `on delete cascade` — as duas tabelas do run também crescem sem poda, então
|
|
// isso é parte do conserto. Os dois consumidores de send_ledger sem janela
|
|
// (disclosure de IA e gate LGPD de 1º toque) falham FECHADO quando a linha
|
|
// some: disclosure a mais e veto a mais, nunca a menos.
|
|
expect(conta(`select count(*) from send_ledger where job_id = '${id(40)}'`)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("fn_expurgar_auditoria_vencida — a retenção que a doutrina prometia", () => {
|
|
function auditar(opts: { id: string; idadeDias: number }): void {
|
|
sql(`
|
|
insert into api_audit_log (id, organization_id, action, created_at)
|
|
values ('${opts.id}', '${ORG}', 'retention.sweep_run',
|
|
now() - interval '${opts.idadeDias} days');
|
|
`);
|
|
}
|
|
|
|
it("apaga o que passou da retenção e preserva o que não passou", () => {
|
|
auditar({ id: id(50), idadeDias: 2000 });
|
|
auditar({ id: id(51), idadeDias: 1000 });
|
|
expect(conta(`select public.fn_expurgar_auditoria_vencida(1825, 1000)`)).toBe(1);
|
|
expect(conta(`select count(*) from api_audit_log where id = '${id(51)}'`)).toBe(1);
|
|
});
|
|
|
|
it("o PISO de 90 dias mora na função: nem com p_retencao_dias = 0 se apaga rastro recente", () => {
|
|
// É esta linha que separa "expurgo de retenção" de "porta de adulteração de
|
|
// auditoria". A função NÃO TEM seletor de linha — nem org, nem ator, nem
|
|
// ação, nem id — e o único predicado é a idade, com piso no corpo. Não
|
|
// existe argumento que a faça apagar a linha de ontem que incomoda.
|
|
auditar({ id: id(52), idadeDias: 10 });
|
|
auditar({ id: id(53), idadeDias: 400 });
|
|
expect(conta(`select public.fn_expurgar_auditoria_vencida(0, 1000)`)).toBe(1);
|
|
expect(conta(`select count(*) from api_audit_log where id = '${id(52)}'`)).toBe(1);
|
|
});
|
|
|
|
it("respeita o `p_limite`", () => {
|
|
for (let i = 0; i < 4; i += 1) auditar({ id: id(60 + i), idadeDias: 2000 });
|
|
expect(conta(`select public.fn_expurgar_auditoria_vencida(1825, 2)`)).toBe(2);
|
|
expect(conta(`select count(*) from api_audit_log where organization_id = '${ORG}'`)).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("append-only: por onde o expurgo pode passar, e por onde não pode", () => {
|
|
it("NINGUÉM tem GRANT de DELETE/UPDATE/TRUNCATE em api_audit_log — nem service_role", () => {
|
|
// É por isso que o expurgo precisa de uma `security definer`: o admin client
|
|
// do produto não consegue apagar esta tabela, e é bom que não consiga.
|
|
//
|
|
// ⚠️ `TRUNCATE` ENTRA NA SONDA, e por muito tempo não entrava. Esta versão
|
|
// do caso perguntava só por DELETE/UPDATE, devolvia vazio, e deixava quem
|
|
// leu concluindo que a tabela não podia ser esvaziada — enquanto o
|
|
// privilégio que a esvazia INTEIRA estava concedido a anon, authenticated e
|
|
// service_role pelo `GRANT` enumerado do `pg_dump` (o dump também enumera
|
|
// outras tabelas: `grep -nE '^GRANT [A-Z,]+ ON TABLE' supabase/baseline.sql
|
|
// | grep -v 'GRANT ALL'`).
|
|
//
|
|
// ⚠️ ESTE CASO SÓ MEDE O SUPABASE REAL DESDE A ISSUE #887. Até ela, o
|
|
// prelude do `test-db.sh` reproduzia o default ACL do Supabase só para
|
|
// funções: `api_audit_log` nascia só com o que o dump concede, e o caso
|
|
// ficava verde com ou sem o revoke de UPDATE/DELETE da migration 0258.
|
|
// Agora a tabela nasce com o que o Supabase dá, e o caso reprova sem esse
|
|
// revoke. Medido tirando `update, delete` do bloco da 0258: vermelho com o
|
|
// prelude novo, verde com o antigo.
|
|
const linhas = sql(`
|
|
select coalesce(string_agg(grantee || ':' || privilege_type, ',' order by grantee), '')
|
|
from information_schema.role_table_grants
|
|
where table_schema = 'public' and table_name = 'api_audit_log'
|
|
and privilege_type in ('DELETE', 'UPDATE', 'TRUNCATE')
|
|
and grantee in ('anon', 'authenticated', 'service_role');
|
|
`);
|
|
expect(lastLine(linhas)).toBe("");
|
|
});
|
|
|
|
it("INSERT e SELECT continuam de pé (controle positivo do revoke)", () => {
|
|
// Sem este controle, um `revoke` largo demais — `revoke all` no lugar do
|
|
// `revoke truncate` — deixaria o caso acima verde e a auditoria MORTA:
|
|
// ninguém mais gravaria linha, e a tabela ficaria append-only no sentido
|
|
// mais literal possível, o de nunca receber nada.
|
|
const privilegios = lastLine(
|
|
sql(`
|
|
select coalesce(string_agg(distinct privilege_type, ',' order by privilege_type), '')
|
|
from information_schema.role_table_grants
|
|
where table_schema = 'public' and table_name = 'api_audit_log'
|
|
and grantee = 'service_role';
|
|
`),
|
|
);
|
|
expect(privilegios).toContain("INSERT");
|
|
expect(privilegios).toContain("SELECT");
|
|
});
|
|
|
|
it("as duas funções não são executáveis por anon nem por authenticated", () => {
|
|
// As DUAS origens de EXECUTE: o grant direto do `ALTER DEFAULT PRIVILEGES
|
|
// ... TO anon` do baseline, e o grant a PUBLIC que o Postgres dá na criação.
|
|
// Tratar só uma deixa a função alcançável pela anon key, que vai ao browser.
|
|
for (const fn of ["fn_podar_fila_de_jobs", "fn_expurgar_auditoria_vencida"]) {
|
|
for (const papel of ["anon", "authenticated"]) {
|
|
const pode = lastLine(
|
|
sql(`select has_function_privilege('${papel}', 'public.${fn}(int,int)', 'EXECUTE')`),
|
|
);
|
|
expect(pode, `${papel} pode executar ${fn}`).toBe("f");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("service_role PODE executar as duas (controle positivo do revoke)", () => {
|
|
// Sem este caso, um `revoke` largo demais deixaria a suíte verde e a poda
|
|
// morta: ninguém apagaria nada e o teste de exposição continuaria passando.
|
|
for (const fn of ["fn_podar_fila_de_jobs", "fn_expurgar_auditoria_vencida"]) {
|
|
const pode = lastLine(
|
|
sql(`select has_function_privilege('service_role', 'public.${fn}(int,int)', 'EXECUTE')`),
|
|
);
|
|
expect(pode, `service_role NÃO pode executar ${fn}`).toBe("t");
|
|
}
|
|
});
|
|
});
|