feat(B180): gate fail-closed de leader nas tools eleitorais legadas do Sollinha (getTopDeputies/getMunicipalityVotes)
ElectionDataGate compartilhado (3 call sites) + refactor da B177 para o helper.
This commit is contained in:
parent
2076234913
commit
ff53e15e7e
7 changed files with 237 additions and 12 deletions
|
|
@ -10,6 +10,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**Recently resolved (2026-08-09):** **C93** (Issue #437) — botão "Link de import" da agenda destravado **sem filtros** (sucessor direto do C16): com zero filtros ativos, staff gera o link do feed **completo dentro do escopo de leitura do criador** — coordinator/candidate = tudo; advisor = portfólio dos municípios que administra — com fail-closed/revogação de C16 intactos. O servidor já cobria o caso (vazio = escopo do criador em `buildFeedWhere`/`resolveFeedCreatorAccess`; descrição do campo `filterMunicipality`); a entrega removeu o gate `hasFilters → disabled` de `CalendarFeedButton` (prop morto `state` incluído), manteve `hasFilters` só no "Limpar filtros" e neutralizou a copy do diálogo ("este recorte" → "a agenda"). Testes: 3 int de conteúdo sem filtro (coordinator = A+B; advisor = só o portfólio, nada fora vaza; advisor sem município → feed vazio fail-closed) + unit component (botão habilitado com zero filtros e fluxo nomear→criar). Sem migration, access, Consent ou schema. Intenção [`c93-gerar-link-import-sem-filtros.md`](plans/c93-gerar-link-import-sem-filtros.md); impl [`c93-gerar-link-import-sem-filtros-impl.md`](plans/c93-gerar-link-import-sem-filtros-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **B180** (Issue #474) — as tools eleitorais legadas do Sollinha (`getTopDeputies`, `getMunicipalityVotes`) ganharam o **gate fail-closed de leader lockdown** que a B177 introduziu só na tool nova: `assertCanReadElectionData(ctx.user)` no topo do `execute` → `{ error: 'Leitura de dados eleitorais negada.' }` antes de qualquer query (o route `api/ai-chat` expõe todas as tools a todos os papéis). O padrão chegou a **3 call sites** → extraído um helper compartilhado `electionDataGate(ctx)` (`src/utilities/ai/tools/electionDataGate.ts`, `true | { error }`), e `getLeadingMunicipalities` refatorado para usá-lo (mensagem com fonte única). Unit tests novos (`electionToolsLockdown.unit.spec.ts`, 11): helper (leader nega/staff passa), as 3 tools com leader + payload stub intocado (fail-closed antes de query, inclusive no path SQL de terceiro candidato) e staff-proceeds por papel (coordinator/advisor/candidate). Sem migration/Consent/UI/prompt. Intenção [`sollinha-tools-eleitorais-leader-lockdown.md`](plans/sollinha-tools-eleitorais-leader-lockdown.md); impl [`sollinha-tools-eleitorais-leader-lockdown-impl.md`](plans/sollinha-tools-eleitorais-leader-lockdown-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **B176** (Issue #458) — `/campanha/municipios` ganhou três dimensões de filtro na omnibox — **Dobradinha** (nome do contato), **Liderança** (nome do contato) e **Partido** (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos `stateDeputy`/`leadership`/`party` na URL congelada (sentinelas `sem_dobradinha`/`sem_lideranca`, reuso de `sem_partido`); `buildMunicipalityListWhere` ganhou um catálogo request-scoped fail-closed (`municipalityRelationSets.ts`, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (`leadership.municipalities`) e cruzada (`stateDeputy.party`) em `where` — sem migration, paginação nativa mantida; facets estendidas (`stateDeputyIDs`/`leadershipOptions`/`parties`) e mapa compartilha o catálogo. **Coluna Dobradinhas liberada ao Assessor** (staff-wide): servidor já estava scoped (B37/B157 `reloadStaffActor` + `canUpdateMunicipality`), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + `exists:false`" em `campaignListUrl` (`NO_PARTY_FILTER_VALUE`, `splitAbsenceFilterValues`, `collapseListWhereOrBranches`) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção [`filtros-municipios-dobradinha-lideranca.md`](plans/filtros-municipios-dobradinha-lideranca.md); impl [`filtros-municipios-dobradinha-lideranca-impl.md`](plans/filtros-municipios-dobradinha-lideranca-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **B174 reverso** (sem Issue — pedido direto) — a Sollinha (`/campanha/api/ai-chat`) voltou do `@ai-sdk/deepinfra` para o **`@ai-sdk/deepseek`** (API oficial `api.deepseek.com`): o cache-hit do V4-Flash direto custa **$0.0028/M** contra **$0.018/M** na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (`deepseek-v4-flash`), mesmas tools; `@ai-sdk/deepseek` **pinado em `3.0.19`** (não `^3.0.19`, que flutuaria a 3.0.26 e puxaria a ferroviária `provider@4.0.7` duplicada — o lock ficou na `4.0.4/5.0.18` do `ai@7.0.47`, verificado no lockfile). **STT fica na Deep Infra** (B173): `whisper-large-v3` a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); `.env.example` agora documenta **as duas chaves** — `DEEPSEEK_API_KEY` (LLM) e `DEEPINFRA_API_KEY` (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar.
|
||||
|
|
|
|||
94
docs/plans/sollinha-tools-eleitorais-leader-lockdown-impl.md
Normal file
94
docs/plans/sollinha-tools-eleitorais-leader-lockdown-impl.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Impl: B180 — Sollinha: travar tools eleitorais legadas (getTopDeputies/getMunicipalityVotes) contra leader
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #474
|
||||
Intenção: docs/plans/sollinha-tools-eleitorais-leader-lockdown.md
|
||||
Appetite restante: ~0,5 dia eng (herdado); sem migration / Consent / collection / UI
|
||||
|
||||
## Registro de execução (2026-08-09)
|
||||
|
||||
- **Entrega:** `electionDataGate(ctx)` novo (`src/utilities/ai/tools/electionDataGate.ts`, `true | { error }` — try/catch do `assertCanReadElectionData` + mensagem pt-BR com fonte única); gate no topo do `execute` das 2 tools legadas (`getTopDeputies`, `getMunicipalityVotes`); `getLeadingMunicipalityCities` (B177) refatorado do inline para o helper. Nenhuma outra mudança (payload queries, bypasses justificados, prompt e registro intactos).
|
||||
- **Divergência da hipótese da intenção:** nenhuma nos aceites; a decisão de extrair o helper (regra 3+ = extrair, já sinalizada na intenção) foi executada como recomendado no plano.
|
||||
- **Cobertura por tests:** `tests/unit/electionToolsLockdown.unit.spec.ts` (11 testes) — helper (leader nega com o shape do chat / staff passa), as 3 tools com `ctx.user` leader + payload stub que **lança se consultado** (prova fail-closed antes de qualquer query; `getLeadingMunicipalities` com candidato não-campanha para forçar o path SQL), staff-proceeds parametrizado por coordinator/advisor/candidate com `find → { docs: [] }` (município real resolve pelo catálogo; shape vazio normal).
|
||||
- **Simplify:** fixes do review aplicados — comentários de bypass das 2 tools legadas corrigidos (diziam "collection-level canReadElectionData guard", mas `overrideAccess: true` ignora a collection; o gate real agora é o `electionDataGate` no tool level); lockdown de `getLeadingMunicipalities` testado no path de payload (não só no artefato); staff-proceeds cobre os 3 papéis staff (não só coordinator). Rejeitado criar gate genérico HOF para outros domínios — só eleitorais têm lockdown aqui.
|
||||
- **Triage de débitos (gate humano 2026-08-09):** S1 `resolveFederalDeputy`/`resolveFromVoteRows` (exportados, `overrideAccess: true`) não têm assert próprio — único caller é o execute gateado, sem vazamento atual. **Deferido com gatilho: se um novo caller de `resolveFederalDeputy` nascer fora de um tool gateado** (ou a família eleitoral ganhar tool nova), adicionar `assertCanReadElectionData` na resolução (espelho do loader da B177) — threadando `user` na assinatura. S2–S5 descartados (cheap_polish; score ≤2).
|
||||
|
||||
## Explicitamente fora (triage)
|
||||
|
||||
- `resolveFederalDeputy`/`resolveFromVoteRows` sem assert próprio — deferido com gatilho (S1 acima).
|
||||
- Mensagem de erro duplicada entre o throw do assert e o gate — duas superfícies distintas (falha server vs feedback de chat); spec pina o shape do chat.
|
||||
- Boilerplate de cast do spec, stub só de `find`, staff-test do execute da B177 — polish de teste, sem valor funcional.
|
||||
- `searchEntities`/outras tools não-eleitorais para leader (intenção: fora de escopo).
|
||||
- Flake `testDatabaseLease.int.spec.ts` (S2, triage B177).
|
||||
- **Deferidos (registrados aqui, sem Issue):** nada novo; flake `testDatabaseLease` permanece fora de escopo (S2, triage B177).
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `getTopDeputies` e `getMunicipalityVotes` fazem `assertCanReadElectionData(ctx.user)` no topo do `execute` (fail-closed, mesmo padrão da B177); nenhum outro comportamento muda; sem migration/Consent/collection/UI.
|
||||
- **O que NÃO negociar:** leader lockdown (liderança não conversa sobre municípios/dados eleitorais no Sollinha); staff idêntico; sem mudança no system prompt.
|
||||
- **O que reavaliar:** a sugestão de helper compartilhado — "2 call sites = inline; 3+ = extrair; aqui já são 3 com a nova" — é decisão desta engenharia (ver abaixo).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
R[api/ai-chat<br/>buildAITools ctx] --> T1[getTopDeputies]
|
||||
R --> T2[getMunicipalityVotes]
|
||||
R --> T3[getLeadingMunicipalities]
|
||||
T1 --> G[electionDataGate ctx<br/>novo helper compartilhado]
|
||||
T2 --> G
|
||||
T3 --> G
|
||||
G -- leader/deny --> E[{ error: 'Leitura de dados eleitorais negada.' }]
|
||||
G -- staff --> Q[payload.find<br/>comportamento atual intacto]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Inline nas 2 tools legadas:** replicar o try/catch da B177 em `getTopDeputies` e `getMunicipalityVotes` (3 cópias no total, uma por tool).
|
||||
- **B — Helper compartilhado `electionDataGate(ctx)`** em `src/utilities/ai/tools/electionDataGate.ts` (retorna `true | { error }`), usado pelas 3 tools — incluindo refatorar `getLeadingMunicipalities` para usá-lo no lugar do try/catch inline da B177.
|
||||
- **C — Gate só na collection (tirar `overrideAccess`):** descartada — a Local API sem `req.user` trata como anônimo e as tools chamam `payload.find` sem contexto autenticado; `canReadElectionData` nega anônimo, então remover o bypass quebraria **staff** também (e o comentário existente já justifica o bypass: "access gated at the tool level").
|
||||
|
||||
**Recomendação: B.** A intenção fixa a regra 2=inline/3+=extrair e o padrão chega a 3 call sites; o texto de erro é um contrato fail-closed do chat e merece uma única fonte de verdade. `electionDataGate` é um módulo raso mas de profundidade real: centraliza o try/catch + a mensagem e encapsula o conhecimento "deny no chat = mensagem pt-BR fixa" que hoje vazaria em 3 tools. **Rejeitada:** A (3 cópias da mensagem; drift futuro no texto), C (quebra staff — a Local API das tools não carrega usuário autenticado).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`electionDataGate`** (`src/utilities/ai/tools/electionDataGate.ts`, novo): `(ctx: AIToolContext): true | { error: string }` — `try { assertCanReadElectionData(ctx.user); return true } catch { return { error: 'Leitura de dados eleitorais negada.' } }`. Sem `server-only` (não importa payload/db-postgres em runtime — só `campaignAccess` + tipo; o guard de convenção só exige `server-only` para import de payload/next/db-postgres). Sem migration.
|
||||
- **`getTopDeputies`** (`src/utilities/ai/tools/getTopDeputies.ts`): primeira instrução do `execute`: `const gate = electionDataGate(ctx); if (gate !== true) return gate`. O resto intacto (bypass justificado continua no `payload.find`).
|
||||
- **`getMunicipalityVotes`** (`src/utilities/ai/tools/getMunicipalityVotes.ts`): idem — gate no topo do `execute`, antes de qualquer resolução de município.
|
||||
- **`getLeadingMunicipalities`** (`src/utilities/ai/tools/getLeadingMunicipalities.ts`): substituir o try/catch inline (linhas 342–346) pelo helper; sem outras mudanças.
|
||||
- **Access / Consent:** reusa `assertCanReadElectionData` (única redação da regra, `src/utilities/access/elections.ts`); nenhuma chave de Consent.
|
||||
- **UI:** Impeccable A — N/A; resposta de erro já é o shape `{ error }` do chat.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — sem dados novos na UI; a mensagem de deny já existe em produção na B177.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / server** — helper + gates nas 2 tools legadas + refactor da B177; unit tests novos (`tests/unit/electionToolsLockdown.unit.spec.ts`):
|
||||
- helper: leader → `{ error }`, coordinator → `true`;
|
||||
- as 3 tools com `ctx.user` leader → `{ error: 'Leitura de dados eleitorais negada.' }` e payload stub **não tocado** (fail-closed antes de qualquer query);
|
||||
- staff-proceeds: `getTopDeputies`/`getMunicipalityVotes` com coordinator + payload stub (`find` → `{ docs: [] }`) retornam o shape vazio normal — prova que staff não mudou (nome real de município, e.g. "Feira de Santana", para os resolves puros).
|
||||
2. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`.
|
||||
3. **Docs** — entrada curta em `docs/CHANGELOG-AGENTS.md` + este impl plan commitado.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não criar "chat gate" genérico (HOF `withX`) para outras domains — só as eleitorais têm lockdown aqui; `calculate`/`searchEntities` etc. continuam como estão (intenção: fora de escopo).
|
||||
- Não tocar no system prompt, nem em `buildAITools`/route (nenhuma mudança de registro).
|
||||
- Não mexer em `searchEntities`/outras tools não-eleitorais (explicitamente fora da intenção).
|
||||
- Não introduzir `server-only` nas tools legadas (não importam payload/db-postgres; o guard de convenção não pede).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Refactor da B177 (merged ontem):** padrão idêntico, troca mecânica; mitigado por unit test do lockdown nas 3 tools + staff-proceeds.
|
||||
- **Drift de mensagem:** a string de erro passa a ter uma única fonte (o helper); os testes pinam o texto nas 3 tools.
|
||||
- **Gate antes da resolução de input:** leader recebe deny mesmo com município inválido — intencional (fail-closed, sem leak de comportamento).
|
||||
- **Flake de CI `testDatabaseLease.int.spec.ts`:** fora de escopo, descartado no triage da B177 (S2); reavaliar se recorrer.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto (2 tools legadas gateadas; leader nega; staff idêntico; sem migration/Consent/UI/prompt)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (access por `assertCanReadElectionData` existente; copy pt-BR; identificadores em inglês)
|
||||
- [ ] Testes previstos: unit (lockdown nas 3 tools + helper + staff-proceeds) — onde o path de acesso muda
|
||||
20
src/utilities/ai/tools/electionDataGate.ts
Normal file
20
src/utilities/ai/tools/electionDataGate.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
|
||||
|
||||
/**
|
||||
* Fail-closed election-data gate for the AI chat tools. Returns `true` for
|
||||
* staff actors and the chat-shaped denial for everyone else (leader lockdown:
|
||||
* "liderança não tem essa conversa"). The error object is the exact shape
|
||||
* tools return, so call sites just early-return it — one source of truth for
|
||||
* the message across the three election tools.
|
||||
*/
|
||||
export const electionDataGate = (
|
||||
ctx: AIToolContext,
|
||||
): true | { error: 'Leitura de dados eleitorais negada.' } => {
|
||||
try {
|
||||
assertCanReadElectionData(ctx.user)
|
||||
return true
|
||||
} catch {
|
||||
return { error: 'Leitura de dados eleitorais negada.' }
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
type LeadingMunicipalityRow,
|
||||
} from '@/lib/leadingMunicipalities'
|
||||
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
|
||||
import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
|
||||
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
|
||||
import { drizzleResultRows } from '@/utilities/drizzleBulk'
|
||||
|
||||
|
|
@ -339,11 +340,8 @@ export const getLeadingMunicipalities = (ctx: AIToolContext) =>
|
|||
}),
|
||||
execute: async ({ candidate, year, topN }) => {
|
||||
// Leader lockdown: municipal ranking conversations are staff-only.
|
||||
try {
|
||||
assertCanReadElectionData(ctx.user)
|
||||
} catch {
|
||||
return { error: 'Leitura de dados eleitorais negada.' }
|
||||
}
|
||||
const gate = electionDataGate(ctx)
|
||||
if (gate !== true) return gate
|
||||
|
||||
if (!VOTE_YEARS.includes(year)) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '@/lib/electionResults'
|
||||
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
|
||||
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
|
||||
import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
|
||||
import { municipalityElectionGeographyForSlug } from '@/utilities/municipality/municipalityElectionGeography'
|
||||
|
||||
const VOTE_YEARS = [ELECTION_YEAR_2014, ELECTION_YEAR_2018, ELECTION_YEAR_2022] as const
|
||||
|
|
@ -31,6 +32,10 @@ export const getMunicipalityVotes = (ctx: AIToolContext) =>
|
|||
municipality: z.string().describe('Municipality name or slug.'),
|
||||
}),
|
||||
execute: async ({ municipality }) => {
|
||||
// Leader lockdown: municipal election conversations are staff-only.
|
||||
const gate = electionDataGate(ctx)
|
||||
if (gate !== true) return gate
|
||||
|
||||
const { payload } = ctx
|
||||
|
||||
// Resolve to catalog entry
|
||||
|
|
@ -80,9 +85,7 @@ async function queryVotes(
|
|||
limit: 0,
|
||||
pagination: false,
|
||||
select: { votes: true },
|
||||
// Bypass: election data is public TSE results; access gated by
|
||||
// the collection-level canReadElectionData guard. Query scope
|
||||
// was resolved against the authenticated user's municipality.
|
||||
// Bypass: public TSE vote rows; access gated by electionDataGate at the tool level.
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
|
|
@ -100,8 +103,7 @@ async function queryVotes(
|
|||
limit: 0,
|
||||
pagination: false,
|
||||
select: { votosValidos: true },
|
||||
// Bypass: election data is public TSE results; access gated by
|
||||
// the collection-level canReadElectionData guard.
|
||||
// Bypass: public TSE tally rows; access gated by electionDataGate at the tool level.
|
||||
overrideAccess: true,
|
||||
}),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { AIToolContext } from '@/lib/ai/types'
|
|||
import { ELECTION_YEAR_2022, FEDERAL_DEPUTY_OFFICE } from '@/lib/electionResults'
|
||||
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
|
||||
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
|
||||
import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
|
||||
import { municipalityElectionGeographyForSlug } from '@/utilities/municipality/municipalityElectionGeography'
|
||||
|
||||
type RankedDeputy = {
|
||||
|
|
@ -32,6 +33,10 @@ export const getTopDeputies = (ctx: AIToolContext) =>
|
|||
limit: z.number().optional().default(5).describe('Number of deputies to return (max 20).'),
|
||||
}),
|
||||
execute: async ({ municipality, year, limit }) => {
|
||||
// Leader lockdown: municipal election conversations are staff-only.
|
||||
const gate = electionDataGate(ctx)
|
||||
if (gate !== true) return gate
|
||||
|
||||
const { payload } = ctx
|
||||
const canonical = resolveMunicipalityName(municipality) ?? municipality
|
||||
const entries = municipalityCatalogEntriesForCity(canonical)
|
||||
|
|
@ -63,8 +68,7 @@ export const getTopDeputies = (ctx: AIToolContext) =>
|
|||
limit: 0,
|
||||
pagination: false,
|
||||
select: { candidateNumber: true, candidateName: true, party: true, votes: true },
|
||||
// Bypass: election data is public TSE results; access gated by
|
||||
// the collection-level canReadElectionData guard.
|
||||
// Bypass: public TSE vote rows; access gated by electionDataGate at the tool level.
|
||||
overrideAccess: true,
|
||||
sort: '-votes',
|
||||
})
|
||||
|
|
|
|||
105
tests/unit/electionToolsLockdown.unit.spec.ts
Normal file
105
tests/unit/electionToolsLockdown.unit.spec.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { Payload } from 'payload'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
|
||||
import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicipalities'
|
||||
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
|
||||
import { getTopDeputies } from '@/utilities/ai/tools/getTopDeputies'
|
||||
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
const denied = { error: 'Leitura de dados eleitorais negada.' }
|
||||
|
||||
/** A payload whose find throws — any query after a failed gate is a bug. */
|
||||
const untouchablePayload = stub<Payload>({
|
||||
find: () => {
|
||||
throw new Error('gate must fail closed before any payload query')
|
||||
},
|
||||
})
|
||||
|
||||
const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user, payload })
|
||||
|
||||
const leader = stub<CampaignUser>({ collection: 'campaignUser', role: 'leader' })
|
||||
const coordinator = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
|
||||
|
||||
const emptyDocsPayload = stub<Payload>({
|
||||
find: async () => ({
|
||||
docs: [],
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
limit: 0,
|
||||
nextPage: null,
|
||||
pagingCounter: 0,
|
||||
prevPage: null,
|
||||
totalDocs: 0,
|
||||
totalPages: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
type ExecutableTool = {
|
||||
execute: (args: unknown, options?: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
describe('electionDataGate (B180)', () => {
|
||||
it('denies a leader with the chat-shaped error', () => {
|
||||
expect(electionDataGate(ctxFor(leader, untouchablePayload))).toEqual(denied)
|
||||
})
|
||||
|
||||
it('lets staff pass', () => {
|
||||
expect(electionDataGate(ctxFor(coordinator, untouchablePayload))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('election tools leader lockdown (B180)', () => {
|
||||
it('getTopDeputies fails closed before any payload query', async () => {
|
||||
const execute = (
|
||||
getTopDeputies(ctxFor(leader, untouchablePayload)) as unknown as ExecutableTool
|
||||
).execute
|
||||
expect(execute).toBeTypeOf('function')
|
||||
await expect(execute!({ municipality: 'Feira de Santana' })).resolves.toEqual(denied)
|
||||
})
|
||||
|
||||
it('getMunicipalityVotes fails closed before any payload query', async () => {
|
||||
const execute = (
|
||||
getMunicipalityVotes(ctxFor(leader, untouchablePayload)) as unknown as ExecutableTool
|
||||
).execute
|
||||
expect(execute).toBeTypeOf('function')
|
||||
await expect(execute!({ municipality: 'Feira de Santana' })).resolves.toEqual(denied)
|
||||
})
|
||||
|
||||
it('getLeadingMunicipalities fails closed before any payload query', async () => {
|
||||
const execute = (
|
||||
getLeadingMunicipalities(ctxFor(leader, untouchablePayload)) as unknown as ExecutableTool
|
||||
).execute
|
||||
expect(execute).toBeTypeOf('function')
|
||||
// A non-campaign candidate forces the SQL/payload path — the default
|
||||
// candidate reads the committed artifact and never touches payload.
|
||||
await expect(execute!({ candidate: '5000' })).resolves.toEqual(denied)
|
||||
})
|
||||
})
|
||||
|
||||
describe('election tools staff behavior unchanged (B180)', () => {
|
||||
const staffRoles = ['coordinator', 'advisor', 'candidate'] as const
|
||||
const staffUser = (role: (typeof staffRoles)[number]) =>
|
||||
stub<CampaignUser>({ collection: 'campaignUser', role })
|
||||
|
||||
for (const role of staffRoles) {
|
||||
it(`getTopDeputies still resolves a municipality for ${role}`, async () => {
|
||||
const execute = (
|
||||
getTopDeputies(ctxFor(staffUser(role), emptyDocsPayload)) as unknown as ExecutableTool
|
||||
).execute
|
||||
const result = await execute!({ municipality: 'Feira de Santana' })
|
||||
expect(result).toMatchObject({ municipality: 'Feira de Santana', topDeputies: [] })
|
||||
})
|
||||
|
||||
it(`getMunicipalityVotes still resolves a municipality for ${role}`, async () => {
|
||||
const execute = (
|
||||
getMunicipalityVotes(ctxFor(staffUser(role), emptyDocsPayload)) as unknown as ExecutableTool
|
||||
).execute
|
||||
const result = await execute!({ municipality: 'Feira de Santana' })
|
||||
expect(result).toMatchObject({ municipality: 'Feira de Santana' })
|
||||
})
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue