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