import { describe, expect, it } from "vitest";

import { sql } from "./gov-helpers";

/**
 * VARREDURA de `security definer` no schema public (issue #128).
 *
 * ## O defeito que fez este arquivo existir
 *
 * `baseline.sql` tem, por paridade com o Supabase:
 *
 *     ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public
 *       GRANT ALL ON FUNCTIONS TO anon;      (e a irmã, TO authenticated)
 *
 * Ele vale para toda função criada DEPOIS dele — ou seja, para **todo apêndice
 * novo**, que por construção nasce no fim do arquivo — e concede um grant
 * DIRETO, que `revoke all ... from public` não remove. Copiar as duas linhas
 * padrão de uma função antiga, que é o que qualquer pessoa faz, produz função
 * exposta. O produto é self-host e o kit aplica SÓ o baseline: uma migration
 * com os revokes certos não protege ninguém se o apêndice não os repetir.
 *
 * ## Por que uma varredura, e não mais uma lista
 *
 * O gate anterior (`gov-hardening-anon-definer.test.ts`) prova o revoke numa
 * lista FIXA de 6 funções. Ele continua valendo — prova o `permission denied`
 * sob role anon real, que é evidência de comportamento, mais forte do que ler
 * catálogo. Mas lista fixa não cobre função nova: no dia em que este arquivo
 * foi escrito, **8 das 25** definer de public tinham EXECUTE para anon e os
 * seis gates obrigatórios estavam verdes. Entre elas
 * `fn_publish_ai_agent_version`, que ESCREVE e recebe o org por argumento sem
 * checar membership.
 *
 * ## As duas regras, e por que são duas
 *
 * `anon` e `authenticated` correm riscos diferentes, então uma regra só não
 * serve — e fundi-las esconderia qual das duas está de fato vigiada:
 *
 *   - **anon**: nenhuma definer de public executável. Sem exceção. É a anon key
 *     que vai para o browser; qualquer definer aqui é RPC pública do PostgREST.
 *   - **authenticated**: definer **volátil** (a que pode escrever) só continua
 *     executável se houver call site com a sessão do usuário. Sem isso, o grant
 *     é superfície morta que permite escrita cross-tenant — usuário logado no
 *     tenant A chamando a função com o org do tenant B.
 *
 * Definer **estável** (só lê) não entra na segunda regra de propósito: os
 * helpers de RLS (`fn_user_org_ids`, `fn_can_view_lead`, …) são avaliados
 * dentro das policies com o papel de quem consulta, então `authenticated`
 * PRECISA de EXECUTE neles. Proibi-los quebraria toda leitura logada.
 */

/** Uma exceção só existe com razão escrita — entrada sem razão é dívida muda. */
interface Excecao {
  readonly fn: string;
  readonly razao: string;
}

/**
 * Vazia, e é para continuar assim. Se você precisa expor uma definer à anon,
 * o caminho é uma função `security invoker` com policy — não uma exceção aqui.
 */
const ANON_PERMITIDO: readonly Excecao[] = [];

/**
 * Definer volátil que `authenticated` PRECISA chamar, com o call site nomeado.
 * Cada uma foi verificada em `app/`, `lib/` e `workers/`: só entra quem é
 * chamada com o client de sessão do usuário, nunca com o de service role.
 */
const AUTHENTICATED_PERMITIDO: readonly Excecao[] = [
  {
    fn: "encrypt_cpf(text)",
    razao:
      "POST/PATCH app/api/v1/contacts/route.ts e app/api/v1/contacts/[id]/route.ts, " +
      "e POST app/api/v1/contacts/import/route.ts — os três montam o client com " +
      "createClient de lib/supabase/server (sessão; _handler.ts:961 registra que " +
      "\"nenhum service role entra aqui\"). Definer porque a chave vem de " +
      "private.app_secrets('cpf_key'), que o papel anon não alcança. NÃO escreve: " +
      "devolve só o ciphertext do CPF que a MESMA requisição enviou, então não há " +
      "dado de outro tenant no retorno; volátil porque relê a chave a cada chamada " +
      "(GUC app.cpf_key é por conexão). Sem ela o CHECK contacts_cpf_consistency " +
      "recusa a linha e o contato é salvo sem CPF (#2522).",
  },
  {
    fn: "decrypt_cpf(uuid)",
    razao:
      "GET app/api/v1/contacts/[id]/route.ts → getContactHandler (_handler.ts:418) " +
      "é o único call site, com o client de sessão — e ele só CHEGA ali depois de " +
      "ler user_organizations do contato e exigir roleAtLeast(manager). Definer " +
      "porque lê contacts.cpf_encrypted e ESCREVE o audit contact.cpf_decrypted em " +
      "api_audit_log antes de devolver o texto. A guarda de tenant é a da casa: " +
      "fora de fn_user_org_ids() a função levanta forbidden_org (platform admin " +
      "passa), então cross-org nunca lê CPF (#2522).",
  },
  {
    fn: "fn_honorarios_parcela_pagar(uuid,uuid,uuid,uuid)",
    razao:
      "POST app/api/v1/honorarios/parcelas/[id]/pagar/route.ts usa createClient da " +
      "sessão. Definer porque insere em financial_entries (fora da policy de escrita " +
      "de honorarios_parcelas) e atualiza a parcela sob FOR UPDATE na mesma transação " +
      "— exatamente o desenho de fn_finalizar_comanda logo abaixo, e pela mesma razão: " +
      "sem o lock, dois cliques na mesma parcela liam 'pendente' nos dois e cada um " +
      "lançava o SEU financial_entries, pagando em dobro no caixa. Exige " +
      "fn_role_at_least(p_org, 'manager'), mesmo papel da RLS de honorarios_parcelas.",
  },
  {
    fn: "fn_finalizar_comanda(uuid,uuid,uuid,integer)",
    razao:
      "POST app/api/v1/financeiro/comandas/[id]/finalizar/route.ts e " +
      "app/api/v1/financeiro/comandas/faturar-lote/route.ts usam createClient da " +
      "sessão — e aqui não é preferência, é requisito: a função começa por " +
      "`auth.uid() is null` e recusa, então com a service key ela levanta " +
      "`comanda_forbidden`. Definer porque as seis escritas (venda, item, comissão, " +
      "lançamento na conta, ponto de fidelidade e conclusão do agendamento) têm de " +
      "cair numa transação só, sob FOR UPDATE, e uma delas escreve em tabela que a " +
      "policy do usuário não concede. A guarda de tenant é a mesma da casa: " +
      "`fn_role_at_least(p_org, 'agent')` antes de qualquer escrita, e todo comando " +
      "interno é fechado por organization_id.",
  },
  {
    fn: "fn_estornar_comanda(uuid,uuid,text)",
    razao:
      "POST app/api/v1/financeiro/comandas/[id]/estornar/route.ts usa createClient da " +
      "sessão. Estorno NÃO apaga: insere o contra-lançamento e carimba " +
      "`reversed_at`, e é definer porque `fn_lancamento_pago_e_imutavel` fecha o " +
      "UPDATE de lançamento pago para todo mundo — o caminho tem de ser este, e só " +
      "este. Exige `fn_role_at_least(p_org, 'manager')`: desfazer dinheiro não é do " +
      "mesmo papel que lançar.",
  },
  {
    fn: "fn_passagem_devolvida(uuid,uuid)",
    razao:
      "POST app/api/v1/conversations/[id]/reactivate-bot/route.ts usa createClient da " +
      "sessão e chama por lib/escalacao/retomada.ts — devolver o atendimento ao " +
      "automático fecha a passagem aberta. Ela NÃO pode ser um update daqui: a policy " +
      "de passagens_de_atendimento é `for select` apenas, de propósito, para ninguém " +
      "reescrever um fato. A definer carrega a mesma guarda de fn_conversation_assign: " +
      "com sessão, exige membro `agent`+ DAQUELA organização (fn_role_at_least), e o " +
      "update é fechado por organization_id + conversation_id, só em linha ainda não " +
      "reconhecida. tests/invariants/passagem-se-reconhece-sozinha.test.ts prova " +
      "cross-org, viewer e a idempotência.",
  },
  {
    fn: "fn_reply_action(uuid,uuid,text,text,text,text)",
    razao:
      "POST app/api/v1/ai/replies/[id]/route.ts usa createClient da sessão. " +
      "auth.uid(), role agent, suporte, MFA comprovado, visibilidade, revisão e contexto " +
      "validam aprovação/rejeição. tests/invariants/autonomia-replies.test.ts prova " +
      "ACL direto A/B, viewer/anon/cross-org, CAS e duas aprovações concorrentes.",
  },
  {
    fn: "fn_lgpd_anonymize_contact(uuid,uuid)",
    razao:
      "POST app/api/v1/lgpd/anonymize/route.ts usa createClient da sessão " +
      "no passo transacional do contato: admin ou plataforma fora de suporte, " +
      "MFA comprovada e tenant explícito antes do mutex. " +
      "tests/invariants/lgpd-agenda-lock-order.test.ts prova ator/tenant, " +
      "service_role negado, suporte readonly/expirado, MFA e retomada.",
  },
  {
    fn: "fn_set_channel_routing(uuid,uuid,uuid[],boolean,text,uuid)",
    razao: "PATCH app/api/v1/settings/routing/channels/route.ts usa createClient da sessão; RPC exige manager, suporte de escrita, MFA e canal/membros da org na mesma transação. Os dois últimos parâmetros (#2331) acrescentam o modo do número e o atendente de destino padrão à MESMA escrita dos responsáveis; a assinatura antiga de 4 argumentos foi derrubada na migration 0663, e a de 4 continua atendida pelos DEFAULTs. tests/invariants/channel-routing.test.ts prova viewer, tenants A/B, membro revogado, policy vazia, MFA platform aal1/aal2, modo fora da lista e destino que não é membro recusados sem gravar, e a entrega direta ao destino padrão.",
  },
  {
    fn: "fn_set_channel_ai_wait(uuid,uuid,integer)",
    razao: "PATCH app/api/v1/settings/routing/channels/route.ts usa createClient da sessão, logo depois de fn_set_channel_routing; a RPC repete as MESMAS guardas dela (manager, suporte de escrita, MFA provada) e só grava `ai_wait_minutes` (#3966, opção do número: esperar a IA qualificar, com prazo) numa regra de número que JÁ existe na org informada — não cria regra, então não muda quem pode receber o número. Faixa 15 a 10080 recusada na função e no CHECK. tests/invariants/roteamento-espera-a-qualificacao.test.ts prova viewer recusado, faixa recusada, número sem regra recusado e a gravação pelo gestor.",
  },
  {
    fn: "fn_reserve_channel_connection(uuid,uuid,text,text,boolean)",
    razao: "lib/channels/connect-waha.ts recebe createClient das rotas channel-sessions e onboarding/whatsapp/session; RPC exige admin, suporte e MFA, cria identidade org-owned com recibo privado. tests/invariants/channel-routing.test.ts prova lease/replay/ACL do recibo e MFA platform aal1/aal2.",
  },
  {
    fn: "fn_google_selection(uuid,jsonb,uuid[],uuid)",
    razao:
      "PATCH app/api/v1/agenda/google/calendarios/route.ts chama createClient " +
      "da sessão; auth.uid() exige agent, suporte de escrita e calendários " +
      "do próprio dono. tests/invariants/agenda-google-acl.test.ts prova " +
      "JWT A/B, viewer/cross-org/outro dono/anon sem efeito; " +
      "agenda-google-reconciliacao.test.ts prova suporte full/readonly/expirado.",
  },
  {
    fn: "fn_meet_action(uuid,uuid,text,uuid,text,uuid)",
    razao:
      "Rotas app/api/v1/agenda/agendamentos/[id]/google/meet/retry e deliver " +
      "delegam a _action.ts com createClient da sessão. auth.uid(), owner IS " +
      "DISTINCT FROM, role agent, suporte e CAS validam ação explícita; " +
      "tests/invariants/agenda-meet.test.ts prova ACL SQL direto A/B, dono " +
      "NULL, outro ator, viewer, anon e suporte readonly.",
  },
  {
    fn: "fn_google_resolve(uuid,uuid,text,text,text,text)",
    razao:
      "POST app/api/v1/agenda/agendamentos/[id]/google/resolver/route.ts " +
      "(também chamado por retry/route.ts) usa createClient da sessão; " +
      "auth.uid() exige agent, suporte e dono do compromisso, além de CAS. " +
      "tests/invariants/agenda-google-acl.test.ts prova JWT A/B, " +
      "viewer/cross-org/outro dono/anon sem efeito; " +
      "agenda-google-reconciliacao.test.ts prova suporte full/readonly/expirado.",
  },
  {
    fn: "fn_appointment_change(uuid,uuid,bigint,jsonb)",
    razao:
      "app/api/v1/agenda/agendamentos/route.ts passa createClient da sessão ao " +
      "_handler.ts (alteraComRevisao): auth.uid() valida papel agent, suporte " +
      "e assina o desfecho humano. tests/invariants/agenda-presenca-acl.test.ts " +
      "prova JWT A/B, negação viewer/cross-org e autoria real.",
  },
  {
    fn: "fn_agenda_settings(uuid,jsonb)",
    razao:
      "PATCH app/api/v1/agenda/configuracao/route.ts chama com createClient " +
      "da sessão; auth.uid() exige manager e suporte de escrita. " +
      "tests/invariants/agenda-presenca-acl.test.ts prova manager A/B próprio, " +
      "negação agent/viewer/cross-org e ausência de efeito recusado.",
  },
  {
    fn: "fn_definir_colegas_podem_mexer_na_agenda(uuid,boolean)",
    razao:
      "app/actions/settings/definirAgendaDosColegas.ts chama por rpc com o " +
      "createClient da SESSÃO, e a própria função reconfere auth.uid(), " +
      "manager, suporte de escrita e MFA comprovado antes de gravar. " +
      "tests/invariants/agenda-presenca-acl.test.ts prova manager da própria " +
      "org com fator provado e a negação de agent, viewer, sessão aal1, anon " +
      "e da org vizinha, com a configuração intacta depois das recusas.",
  },
  {
    fn: "fn_definir_google_meet_acesso_aberto(uuid,boolean)",
    razao:
      "app/actions/settings/definirMeetAberto.ts chama por rpc com o " +
      "createClient da SESSÃO, e a própria função reconfere auth.uid(), " +
      "manager, suporte de escrita e MFA comprovado antes de gravar — a " +
      "mesma guarda da irmã fn_definir_colegas_podem_mexer_na_agenda acima. " +
      "Só escreve uma chave de topo de organizations.settings " +
      "(google_meet_acesso_aberto) e devolve {ligado,mudou}; a leitura é " +
      "meetAbertoLigado (lib/schemas/settings.ts), sem nenhuma exposição.",
  },
  {
    fn: "fn_definir_cliente_pela_agenda(uuid,boolean)",
    razao:
      "app/actions/settings/definirClientePelaAgenda.ts chama com createClient da sessão; " +
      "auth.uid() exige admin da própria organização, suporte de escrita e MFA comprovado " +
      "antes de gravar settings.crm e classificar o histórico. " +
      "tests/invariants/cliente-nasce-do-agendamento.test.ts prova admin próprio, negação de " +
      "manager/agent/viewer, admin de outra organização, sem sessão, aal1 com fator e suporte " +
      "somente leitura/vencido.",
  },
  {
    fn: "emit_event(text,text,uuid,jsonb,jsonb,uuid)",
    razao:
      "Server Actions chamam com a sessão do usuário " +
      "(app/actions/settings/updateProfile.ts, updateTenant.ts).",
  },
  {
    fn: "fn_conversation_assign(uuid,uuid,uuid,text,uuid,boolean,uuid,boolean)",
    razao:
      "Rotas de claim/release/transfer chamam com a sessão do usuário " +
      "(app/api/v1/conversations/[id]/*). A função faz a própria checagem de " +
      "membership e papel. O 7º argumento (p_source_job_id) é o da origem do " +
      "turno autônomo: só chega pelo client de service role, e o corpo recusa " +
      "(source_job_requires_runtime_caller) quando o chamador não é o runtime. " +
      "O 8º (p_negocio_so_sem_dono, #4019) é o do negócio aberto: com sessão, o " +
      "corpo só escreve em crm_leads o que crm_leads_update já permitiria ao " +
      "chamador (fn_can_view_lead sobre o responsável atual E o novo), e fora de " +
      "'transfer' só preenche negócio sem responsável — o grant não acrescenta " +
      "alcance nenhum à sessão.",
  },
  {
    fn: "fn_mesclar_contatos(uuid,uuid,uuid[])",
    razao:
      "A rota POST /api/v1/contacts/merge chama com a sessão do usuário, de " +
      "propósito: é `auth.uid()` que faz a função reconferir o papel (piso " +
      "`manager`, o mesmo das policies de merge_queue) e que assina a atividade " +
      "da timeline. Trocar pelo client de service role apagaria as duas coisas. " +
      "Mesmo desenho de fn_conversation_assign, acima.",
  },
  {
    fn: "fn_log_event(uuid,text,jsonb)",
    razao:
      "Chamada de dentro dos triggers de domínio; o grant a authenticated foi " +
      "declarado pela migration 0034 e não há call site de RPC para removê-lo " +
      "com segurança sem medir o disparo de cada trigger.",
  },
  {
    fn: "fn_definir_aviso_de_caso(uuid,uuid,text,text,boolean,boolean,boolean,text,jsonb,uuid)",
    razao:
      "A ÚNICA porta de escrita de `config_aviso_de_caso` — a tabela não tem " +
      "INSERT/UPDATE/DELETE para authenticated, só `grant select` sob RLS de " +
      "admin. A função exige, na MESMA transação: auth.uid() não nulo, " +
      "fn_role_at_least(p_org,'admin'), fn_support_write_allowed(p_org), " +
      "fn_session_mfa_proven(), E.164 no destino, canal DA organização e não " +
      "arquivado, recusa do número da própria organização (o laço robô-com-robô) " +
      "e recusa de número que já é contato, a menos que p_confirma_contato. " +
      "Desde a 9094, também recusa p_modelo de outra organização ou de outra " +
      "conexão (tests/invariants/aviso-por-modelo-aprovado.test.ts). " +
      "tests/invariants/aviso-de-caso-escrita.test.ts prova agent e viewer " +
      "recusados (42501), admin aprovado, admin de OUTRA organização recusado, " +
      "canal do vizinho recusado e anon sem EXECUTE. " +
      "Desde a 9078 (#3803) ela grava também `user_organizations.whatsapp_e164`, " +
      "só na linha de membro ATIVO de p_org (organization_id = p_org e " +
      "revoked_at is null), depois das mesmas checagens de papel. " +
      "⚠️ A TELA QUE A CHAMA COM A SESSÃO DO USUÁRIO É DA ONDA SEGUINTE " +
      "(Configurações › Avisos de atendimento). Até ela existir, o grant é " +
      "superfície sem consumidor — declarado aqui em vez de presumido —, e a " +
      "função recusa tudo que não seja admin da própria organização.",
  },
  {
    fn: "fn_definir_lembrete_do_aviso_de_agendamento(uuid,boolean)",
    razao:
      "A porta de escrita de `config_aviso_de_agendamento.lembrar_antes` (migration 9326). " +
      "PUT app/api/v1/agenda/aviso/route.ts chama com createClient da sessão, logo depois de " +
      "fn_definir_aviso_de_agendamento. Mesmas guardas da irmã: auth.uid(), " +
      "fn_role_at_least(p_org,'admin'), fn_support_write_allowed(p_org) e " +
      "fn_session_mfa_proven(); só atualiza a linha da organização pedida e recusa quem " +
      "ainda não tem configuração. Prova: tests/invariants/lembrete-ao-dono-escrita.test.ts.",
  },
  {
    fn: "fn_definir_aviso_de_agendamento(uuid,boolean,uuid,text,boolean,boolean,text)",
    razao:
      "A ÚNICA porta de escrita de `config_aviso_de_agendamento` (migration 0707) — " +
      "a tabela só tem `grant select` sob RLS de admin. PUT " +
      "app/api/v1/agenda/aviso/route.ts chama com createClient da sessão. A função " +
      "exige auth.uid(), fn_role_at_least(p_org,'admin'), " +
      "fn_support_write_allowed(p_org) e fn_session_mfa_proven(); com número próprio, " +
      "E.164, conexão DA organização e não arquivada, recusa do número da própria " +
      "organização e de número que já é contato sem confirmação. " +
      "tests/invariants/aviso-de-agendamento-escrita.test.ts prova agent e viewer " +
      "recusados, admin aprovado, admin de OUTRA organização recusado, conexão do " +
      "vizinho recusada e anon sem EXECUTE.",
  },
  {
    fn: "fn_vocabulario_de_tags_operar(uuid,text,text,text,text)",
    razao:
      "POST app/api/v1/tags/vocabulario/route.ts chama com createClient da " +
      "sessão (a tela de Tags é manager+, com suporte de escrita e MFA). A " +
      "função exige fn_role_at_least(p_org,'manager') ANTES de qualquer escrita, " +
      "recusa ação fora do vocabulário e roda os três arrays, as sementes da " +
      "organização e as ações add_tag de automation_rules numa transação só — é " +
      "essa atomicidade que impede o rename de deixar a regra do agente " +
      "apontando para o nome velho. " +
      "A assinatura tem CINCO argumentos desde a 0336 (issue #1271): " +
      "`p_cor text default null` para a ação `definir_cor`, que mexe só em " +
      "organizations.settings.tags e sai antes dos laços (cor não mora nas " +
      "linhas). " +
      "tests/invariants/tags-vocabulario.test.ts prova duas orgs com a MESMA " +
      "etiqueta (a de fora não é tocada), viewer recusado, anon sem EXECUTE, " +
      "junção 'vip'+'VIP' sem duplicata e exclusão que informa — sem apagar — a " +
      "regra do agente; tests/invariants/tags-cor-de-etiqueta.test.ts prova a " +
      "cor.",
  },
];

interface Definer {
  readonly assinatura: string;
  readonly anon: boolean;
  readonly authenticated: boolean;
  /** provolatile: 'v' volátil (pode escrever), 's' estável, 'i' imutável. */
  readonly volatilidade: string;
}

function inventario(): Definer[] {
  const out = sql(`
    select p.oid::regprocedure::text
           || '\t' || has_function_privilege('anon', p.oid, 'EXECUTE')::text
           || '\t' || has_function_privilege('authenticated', p.oid, 'EXECUTE')::text
           || '\t' || p.provolatile::text
      from pg_proc p
      join pg_namespace n on n.oid = p.pronamespace
     where n.nspname = 'public' and p.prosecdef
     order by 1;
  `);
  return out
    .split("\n")
    .filter((l) => l.trim() !== "")
    .map((linha) => {
      const [assinatura, anon, auth, vol] = linha.split("\t");
      return {
        assinatura: (assinatura ?? "").replace(/\s+/g, ""),
        anon: anon === "true",
        authenticated: auth === "true",
        volatilidade: vol ?? "",
      };
    });
}

describe("varredura: SECURITY DEFINER de public não fica exposta", () => {
  it("o inventário não vem vazio (guarda de vacuidade)", () => {
    // Sem isto, um `pg_proc` que deixasse de casar (schema renomeado, coluna
    // trocada de nome numa versão futura) faria as asserções abaixo passarem
    // por AUSÊNCIA de dado — instrumento cego devolvendo verde. O número é o
    // medido em 2026-08-05; a asserção é de ordem de grandeza, não de igualdade,
    // para não virar um contador que reprova a cada função nova.
    expect(inventario().length).toBeGreaterThanOrEqual(20);
  });

  it("nenhuma é executável por anon", () => {
    const permitidas = new Set(ANON_PERMITIDO.map((e) => e.fn));
    const expostas = inventario()
      .filter((f) => f.anon)
      .map((f) => f.assinatura)
      .filter((s) => !permitidas.has(s));
    expect(
      expostas,
      "SECURITY DEFINER executável pela anon key (que vai para o browser). O " +
        "`revoke all ... from public` NÃO cobre o grant direto do ALTER DEFAULT " +
        "PRIVILEGES: acrescente `revoke execute on function ... from anon` no " +
        "apêndice do baseline E na migration.",
    ).toEqual([]);
  });

  it("nenhuma definer VOLÁTIL é executável por authenticated sem razão escrita", () => {
    const permitidas = new Set(AUTHENTICATED_PERMITIDO.map((e) => e.fn));
    const expostas = inventario()
      .filter((f) => f.volatilidade === "v" && f.authenticated)
      .map((f) => f.assinatura)
      .filter((s) => !permitidas.has(s));
    expect(
      expostas,
      "SECURITY DEFINER que pode ESCREVER, executável por qualquer usuário " +
        "logado de qualquer tenant. Se o único call site usa o client de " +
        "service role, revogue de authenticated; se a sessão do usuário chama " +
        "de fato, declare em AUTHENTICATED_PERMITIDO com o call site nomeado.",
    ).toEqual([]);
  });

  it("as exceções declaradas ainda existem e ainda são exceção", () => {
    // Duas mortes possíveis desta lista, e cada uma engana de um jeito:
    // entrada de função que sumiu vira ruído que ninguém ousa apagar; entrada
    // de função que JÁ foi revogada mente dizendo "isto é aceito" sobre algo
    // que já está resolvido.
    const inv = inventario();
    const porAssinatura = new Map(inv.map((f) => [f.assinatura, f]));

    for (const { fn } of AUTHENTICATED_PERMITIDO) {
      const alvo = porAssinatura.get(fn);
      expect(alvo, `exceção para função inexistente: ${fn}`).toBeDefined();
      expect(
        alvo?.authenticated,
        `saiu da exceção: ${fn} já não tem EXECUTE para authenticated — remova de AUTHENTICATED_PERMITIDO`,
      ).toBe(true);
    }

    for (const { fn } of ANON_PERMITIDO) {
      expect(
        porAssinatura.get(fn)?.anon,
        `saiu da exceção: ${fn} já não tem EXECUTE para anon — remova de ANON_PERMITIDO`,
      ).toBe(true);
    }
  });

  it("service_role continua com EXECUTE nas de escrita (probe positivo)", () => {
    // Sem este caso, revogar de TODO MUNDO passaria nos três primeiros — o
    // jeito trivial de deixar a varredura verde é quebrar a ingestão.
    const escrita = [
      "public.fn_upsert_wa_contact(uuid, text, text, text, text, text)",
      "public.fn_upsert_wa_conversation(uuid, uuid, uuid)",
      "public.fn_mark_conversation_message(uuid, text, text, timestamptz)",
      "public.fn_publish_ai_agent_version(uuid, uuid, uuid)",
      "public.activate_kb_version(uuid, uuid)",
    ];
    const semExecute = escrita.filter(
      (assinatura) =>
        sql(
          `select has_function_privilege('service_role', '${assinatura}'::regprocedure, 'EXECUTE');`,
        ) !== "t",
    );
    expect(semExecute, "service_role perdeu EXECUTE — a ingestão para de funcionar").toEqual([]);
  });
});
