fix(ci): desbloquear verify e invariants do PR #346

ruleName faltava nos fixtures do ActionCtx; push_subscriptions entrava
com policy ALL sem role_at_least; o extrator de vocabulario lia o type
alias sem literais; o FakeQuery do webhook nao tinha .in(); e o kick
local quebrava o 200 da captacao depois do lead gravado.
This commit is contained in:
Ian Couto
2026-08-26 14:40:39 -03:00
parent 0fda528f1d
commit b91afa2028
9 changed files with 93 additions and 7 deletions
@@ -16,6 +16,7 @@ function baseCtx(context: Record<string, unknown>): ActionCtx {
admin: {} as ActionCtx["admin"],
organizationId: "org-1",
ruleId: "rule-1",
ruleName: "Automação de teste",
requestId: "evt-1",
event: {
id: "evt-1",
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from "vitest";
import type { SupabaseClient } from "@supabase/supabase-js";
vi.mock("@/lib/event-log/drain", () => ({
drainEventLog: vi.fn(async () => ({ drained: 0 })),
}));
vi.mock("@/lib/event-log/register-handlers", () => ({
ensureHandlersRegistered: vi.fn(),
}));
import { kickLocalPipeline } from "@/lib/dev/kick-local-pipeline";
describe("kickLocalPipeline", () => {
it("não propaga erro do tick do contato (contrato: nunca 5xx no webhook)", async () => {
const admin = {
from: () => ({
select: () => ({
eq: () => ({
in: () => {
throw new Error("boom do mock");
},
}),
}),
}),
} as unknown as SupabaseClient;
await expect(
kickLocalPipeline(admin, {
organizationId: "org",
contactId: "contact",
}),
).resolves.toBeUndefined();
});
});
+12 -2
View File
@@ -175,6 +175,16 @@ export async function kickLocalPipeline(
admin: SupabaseClient,
contato?: ContatoDoPipeline,
): Promise<void> {
await acelerarPipelineDeEventos(admin);
if (contato) await acelerarDesteContato(admin, contato);
// acelerarPipelineDeEventos já é fail-soft; o tick do contato NÃO era —
// uma query incompleta (mock de teste ou PostgREST momentâneo) derrubava o
// 200 da captação depois do lead já gravado. O contrato do cabeçalho vale
// para o POST inteiro.
try {
await acelerarPipelineDeEventos(admin);
if (contato) await acelerarDesteContato(admin, contato);
} catch (err) {
logger.warn("[dev.pipeline] kick falhou (lead/mensagem já gravados)", {
error: err instanceof Error ? err.message : String(err),
});
}
}
+2
View File
@@ -14195,10 +14195,12 @@ create policy push_subscriptions_own on public.push_subscriptions
using (
organization_id in (select public.fn_user_org_ids())
and user_id = auth.uid()
and public.fn_role_at_least(organization_id, 'viewer')
)
with check (
organization_id in (select public.fn_user_org_ids())
and user_id = auth.uid()
and public.fn_role_at_least(organization_id, 'viewer')
);
revoke all on public.push_subscriptions from anon, public;
@@ -0,0 +1,23 @@
-- 0176 — push_subscriptions: policy ALL sem fn_role_at_least.
--
-- A 0168 criou a tabela com RLS da própria linha (user_id = auth.uid()), mas a
-- policy era `FOR ALL` só com tenancy + dono — o gate
-- `tests/invariants/rbac-config-ia-canais.test.ts` ("nenhuma tabela NOVA entra
-- com policy ALL só-tenancy") reprova exatamente isso. A rota HTTP já exige
-- `viewer` (`requireRole("viewer")`); a policy passa a espelhar o mesmo piso
-- com `fn_role_at_least(..., 'viewer')`, sem alargar quem escreve (continua
-- só a própria linha). Forward-fix da 0168; não edita a migration aplicada.
drop policy if exists push_subscriptions_own on public.push_subscriptions;
create policy push_subscriptions_own on public.push_subscriptions
for all
using (
organization_id in (select public.fn_user_org_ids())
and user_id = auth.uid()
and public.fn_role_at_least(organization_id, 'viewer')
)
with check (
organization_id in (select public.fn_user_org_ids())
and user_id = auth.uid()
and public.fn_role_at_least(organization_id, 'viewer')
);
+1
View File
@@ -210,6 +210,7 @@ aplica.
| `20260824120000` | `0173_quem_manda_na_conversa` | **"Assumir" não calava o automático — dois atores atendiam o mesmo cliente.** Medido no HEAD 927dfa51: `grep -rn "assignee_kind\|assigned_to_user_id" lib/agent-engine/` devolve **rc=1** (zero acertos) e `fn_conversation_assign` nunca tocou `bot_silenced_until`. O motor moderno é dono da resposta sempre que a org tem agente publicado, e ele nunca soube que alguém assumiu: o automático só calava por **5 minutos deslizantes** quando o atendente ENVIAVA (`extendBotSilence`, `app/api/v1/messages/_handler.ts`). O guard existe desde a decisão G3-02 e só o worker LEGADO o implementa (`skip("assigned_to_human")`) — o motor regrediu sem ninguém notar, e é isso que faz qualquer selo de "você está no comando" ser mentira. **O conserto entra na função de atribuição, não no motor**, porque `bot_silenced_until` é o gate que o motor JÁ lê (`isLeadInHandoff`): nenhuma linha do motor muda. **A alternativa foi medida e REPROVADA:** ensinar o motor a ler `assignee_kind` parece a correção óbvia e produz mudez permanente — `Fechar` não solta o dono, de propósito ("quem atendeu é histórico"), então o fim NORMAL de um atendimento (Assumir→Fechar) deixa `assignee_kind='user'` pendurado; e como aquele gate é por CONTATO, ele calaria também conversa NOVA de OUTRO número do mesmo cliente (reproduzido em `pgvector/pgvector:pg17` com este baseline: gate atual `f`, gate com `assignee_kind` `t`, sobre uma conversa `closed`). **Três braços, e o do rodízio é o que impede a regressão silenciosa:** `p_reason='routing'` **não mexe** no silêncio — distribuir não é assumir, e `trg_conversation_routing_requested` dispara em TODA conversa nova com o worker rodando 1×/min, então sem a ressalva uma org em `round_robin` ficaria com o automático calado na PRIMEIRA mensagem da vida de cada cliente (medido), numa tela de configuração que não menciona IA; destino humano (`claim`/`transfer`) → `'infinity'`; destino nulo (`release`) → `null`. **Assinatura IDÊNTICA de 6 args de propósito:** parâmetro novo criaria OVERLOAD (o `create or replace` não substitui assinatura diferente) e as cinco chamadas por nome passariam a falhar com `is not unique` — medido. A limpeza do silêncio ao FECHAR mora na rota (`close/route.ts`), porque fechar não passa por esta função e sem ela o silêncio vazaria para o próximo episódio: a ingestão reusa a MESMA linha de conversa (`on conflict do update`). **De brinde, um furo de RLS que a mesma medição achou:** `cae_select` era membership de org PURA enquanto `conversations_select` passa por `fn_can_view_conversation` — org em `visibility_mode='own'`, agent que não é dono lia **0** linhas em `conversations` e **1** em `conversation_assignment_events` da mesma conversa, alcançável pelo PostgREST com a anon key + o JWT do usuário, sem depender de rota nossa. A policy passa a HERDAR o escopo no molde do `messages_select` (`exists` sobre `conversations`, que já aplica a RLS dela) em vez de reescrever a regra — duas cópias divergem na primeira mudança de uma delas. Idempotente (`create or replace` + `drop policy if exists`), sem constraint nova, sem dado a corrigir. |
| `20260825120000` | `0174_historico_de_leads_captados` | **Quem publica uma landing page não tinha como responder "chegou alguém, com que dados, de onde?".** A única coisa que existia era o ARQUIVO FORENSE (`webhook_events_log`), e ele é DESCARTÁVEL por desenho: o cron `webhook-log-retention` (a cada 5 min, migration 0163) **zera** `raw_body`/`payload_parsed`/`headers` em D+7 e **apaga a linha** em D+90. Foi a decisão certa — numa instalação real ele era 468 MB de um banco de 545 MB (86%), contra 3,2 MB de `messages` — mas transforma qualquer histórico construído sobre ele numa tela que MENTE a partir do sétimo dia: os campos viram `null` e nada na UI distingue "o formulário veio vazio" de "o corpo foi descartado ontem". Um arquivo de depuração e um histórico de negócio têm ciclos de vida OPOSTOS. Traz `webhook_lead_captures` com o que o arquivo não guardava: **o IP em coluna tipada** (`inet` — no arquivo ele só existia solto dentro do jsonb `headers`, que é uma das três colunas podadas em D+7); **o DESFECHO** (`criado`/`duplicado`/`recusado` + `reject_reason`) — o arquivo registra "chegou um POST" e não sabe dizer se virou lead, se caiu na deduplicação por `external_id`, ou se foi recusado por não ter campo mapeável, que é justamente o caso em que a pessoa hoje NÃO VÊ NADA (400 para o site dela, zero rastro na tela); e o **nome da fonte NO MOMENTO** da captação (cópia deliberada — a FK é `on delete set null` e o histórico responde de onde o contato VEIO, não de onde viria hoje; é o caso em que duplicar é a resposta certa da DIRC, porque o valor é um fato datado). **RLS exige `manager`, e não é cerimônia:** `webhook_lead_captures_manager_read` usa `fn_role_at_least`, enquanto a policy de `webhook_events_log` é org-flat sem gate de papel — hoje qualquer `viewer` lê a PII do formulário direto pelo PostgREST com a anon key, mesmo com a rota HTTP exigindo `manager`. Sem policy de INSERT/UPDATE/DELETE: só o service role escreve. **LGPD por TRIGGER, não por 9º passo:** `fn_lgpd_cascade_redact_contact` tem 180 linhas e acrescentar um passo exigiria reescrevê-la inteira no apêndice, criando duas cópias que divergem no primeiro conserto; o gancho é a transição `is_anonymized false → true` em `contacts`, que roda na MESMA transação do cascade e alcança QUALQUER caminho que anonimize um contato. **Sobre gravar IP:** `x-forwarded-for` é forjável e esta coluna NÃO é material de segurança — nada no produto decide com base nela; ela existe para o dono reconhecer padrão. No stack padrão do kit o header chega (o `app` não publica porta; quem publica é o Caddy, que faz `reverse_proxy app:3000`). Aditiva, idempotente, sem constraint sobre dado existente. |
| `20260825130000` | `0175_a_automacao_diz_a_verdade` | **A automação não tinha como dizer "ainda não".** `automation_rule_runs.status` aceitava `success`/`partial`/`failed`, e faltava o quarto estado que o motor JÁ produz: quando uma ação de envio pede adiamento (`postponeUntil` — fora da janela do número, cap diário atingido), `runAutomationForEvent` devolve `{status:'retry'}` e sai **sem gravar linha nenhuma**. O evento volta depois e, nesse intervalo, a aba Atividade não mostra absolutamente nada — para quem montou a regra, "não apareceu nada na Atividade" e "a automação não rodou" são a MESMA tela, e foi esse o relato que originou a mudança. É o invariante 4 do Sistema Vivo (nenhuma demanda sem próximo passo) aplicado a uma espera: a espera É um estado, e um estado que ninguém vê é indistinguível de morte. **Valor novo em vez de reusar `partial`:** `partial` significa "algumas ações funcionaram e outras falharam" e a tela pinta de amarelo com o texto "Parcial"; um adiamento não é falha nenhuma — nada foi tentado ainda, e vai ser. Empilhar os dois faria a tela mentir na direção oposta, assustando sobre uma mensagem que só espera o horário. CHECK reconstruído em **UM bloco só** (lição do #159 registrada no baseline para `agent_inbox_items_kind_check`: N blocos quebram o `update.sh` de clone com vocabulário posterior). Aditiva — só ALARGA o conjunto aceito, então nenhuma linha atual passa a violar e não há o que deduplicar antes. |
| `20260826180000` | `0176_push_subscriptions_rbac` | **Forward-fix da 0168: `push_subscriptions` entrava com policy ALL só-tenancy+dono.** O gate `rbac-config-ia-canais` ("nenhuma tabela NOVA entra com policy ALL só-tenancy") reprovou no PR #346. A rota já exige `viewer`; a policy passa a espelhar com `fn_role_at_least(..., 'viewer')`, sem alargar quem escreve (continua só a própria linha). Idempotente (`drop policy if exists` + recreate). |
## Reproducibility
@@ -191,6 +191,7 @@ function baseCtx(): ActionCtx {
admin,
organizationId: GOV_ORG,
ruleId: RULE_ID,
ruleName: "start_message_flow test",
event: { id: lastLine(sql(`select gen_random_uuid();`)) } as unknown as EventRow,
context: { contact: { id: CONTACT_ID } },
requestId: "test-start-message-flow",
@@ -210,8 +210,12 @@ const PARES: Array<{
{
tabela: "followup_flow_pointers",
coluna: "surface",
// lib/followup/api-schemas.ts → FOLLOWUP_FLOW_SURFACES (tupla `as const`).
// O type alias `FollowupFlowSurface = (typeof …)[number]` não carrega
// literais no fonte — o extrator lê a const, que é a fonte em runtime
// (`z.enum` / UI) e a que o CHECK do banco precisa espelhar.
arquivo: "lib/followup/api-schemas.ts",
simbolo: "FollowupFlowSurface",
simbolo: "FOLLOWUP_FLOW_SURFACES",
},
{
tabela: "webhook_lead_captures",
+14 -4
View File
@@ -44,7 +44,7 @@ function sqlLiteral(v: unknown): string {
type QResult = { data: unknown; error: { message: string; code?: string } | null };
type RowResult = { data: Record<string, unknown> | null; error: { message: string; code?: string } | null };
type FilterOp = "eq" | "is";
type FilterOp = "eq" | "is" | "in";
interface Filter {
op: FilterOp;
col: string;
@@ -97,6 +97,11 @@ class FakeQuery implements PromiseLike<QResult> {
return this;
}
in(col: string, val: unknown[]): this {
this.filters.push({ op: "in", col, val });
return this;
}
is(col: string, val: unknown): this {
this.filters.push({ op: "is", col, val });
return this;
@@ -115,9 +120,14 @@ class FakeQuery implements PromiseLike<QResult> {
private buildWhere(): string {
if (!this.filters.length) return "";
const clauses = this.filters.map((f) =>
f.op === "is" ? `${f.col} is ${f.val === null ? "null" : sqlLiteral(f.val)}` : `${f.col} = ${sqlLiteral(f.val)}`,
);
const clauses = this.filters.map((f) => {
if (f.op === "is") return `${f.col} is ${f.val === null ? "null" : sqlLiteral(f.val)}`;
if (f.op === "in") {
const list = Array.isArray(f.val) ? f.val : [];
return `${f.col} in (${list.map((v) => sqlLiteral(v)).join(", ")})`;
}
return `${f.col} = ${sqlLiteral(f.val)}`;
});
return ` where ${clauses.join(" and ")}`;
}