Merge pull request #456 from fsolla/C92-link-de-import-corrigir-criacao-do-feed

fix(C92): corrige criação do link de import da agenda (feed)
This commit is contained in:
Francisco Solla 2026-08-08 23:39:43 -03:00 committed by GitHub
commit c9134becd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 620 additions and 129 deletions

View file

@ -0,0 +1,95 @@
# Impl: C92 — Link de import da agenda: corrigir criação do feed
Status: em execução
Atualizado em: 2026-08-08
Issue: #436
Intenção: docs/plans/c92-corrigir-criacao-link-import-agenda.md
Appetite restante: ~0,5–1 dia eng (herdado; entrega menor que o appetite)
## Leitura da intenção
- **Outcome:** staff (coordenador, assessor, candidato) gera o link de import nomeando o feed, sem erro; o link responde; revogar e listar continuam; erro real não é engolido; não vaza município fora do escopo do criador.
- **O que NÃO negociar:** leader lockdown; fail-closed no read do feed (o segredo não vaza); credencial revogável; sem sync bidirecional; invariante C16 de escopo do criador.
- **O que reavaliar:** a "forma do create" — a hipótese da intenção (ajuste de acesso vs forma) foi respondida por repro: o padrão que já funciona no repo é **hook-stamp do `createdBy` + acesso de campo relaxado onde o servidor minta o valor**, exatamente como `Organization`/`Leadership`/`StateDeputy` (que criam com `overrideAccess:false`).
## Causa raiz (reproduzida localmente com actor real `campaignUser`)
`createCalendarFeedLinkRecord` cria com `overrideAccess: false` + `user: campaignUser`, `data` inclui `secretSlug` e `createdBy` — ambos `required` com `access.create: canSetCalendarFeedSystemField` (só Payload admin). Payload **strip** os campos negados → `ValidationError` (400) → catch engole numa mensagem genérica.
**Prova (repro em `tests/int`, DB local `teqo_wt92`):**
- `payload.create(calendarFeed, { secretSlug, createdBy }, user: coordinator, overrideAccess:false)` → `ValidationError: ... Secret Slug, Criado por` (o bug).
- Mesmo create **com `beforeChange` hook que grava `createdBy = req.user.id`** (e sem passar `createdBy` do lado da action) → **OK**, `createdBy` gravado = id do actor. ← o fix (padrão `stampCampaignCreatedBy`).
- **Bug nº 2 (mesma raiz, silencioso):** `revokeCalendarFeedRecord` faz `payload.update({ revokedAt })` com `overrideAccess:false`; `revokedAt.access.update` é admin-only → `revokedAt` é stripado → update retorna ok mas **nunca revoga**. Aceite C16 "revogar continua funcionando" está quebrado. Prova: `revokedAt set? false` com update `overrideAccess:false`.
- Leader create → bloqueado (acesso de collection já OK). Confirmado.
## Componentes / mudanças
### 1. `src/utilities/access/calendarFeeds.ts`
- **Manter** `canSetCalendarFeedSystemField` (admin-only) — continua para `secretSlug.update`, `revokedAt.create` e `createdBy`.
- **`canCreateCalendarFeed`** agora recebe `data` e verifica `filterMunicipality` no escopo do assessor (precedente `canCreateSupporter` em `supporters.ts`), fail-closed — fecha o bypass por REST/`payload.create` cru, não só pela action.
- **Novo `canSetCalendarFeedSecret: FieldAccess`** — `isPayloadAdmin || isCampaignStaff(getFreshCampaignUser)`; usado só em `secretSlug.access.create` (staff minta o segredo **no create**; `read: () => false` e `update` admin-only inalterados → segredo nunca re-lido, nunca rotacionado).
- **Novo `canSetCalendarFeedRevocation: FieldAccess`** — `isPayloadAdmin || isCampaignStaff(getFreshCampaignUser)`; usado só em `revokedAt.access.update` (staff marca revogado; `canUpdateCalendarFeed` já escopa advisor a `createdBy` próprio → staff só revoga feed próprio).
### 2. `src/collections/CalendarFeed.ts`
- `secretSlug.access.create` → `canSetCalendarFeedSecret` (`read` e `update` inalterados).
- `revokedAt.access.update` → `canSetCalendarFeedRevocation` (`create` admin-only inalterado).
- Adicionar `hooks.beforeChange: [stampCampaignCreatedBy]` (mesmo hook de Organization/Leadership/StateDeputy — `campaignAuditFields.ts`).
- **Sem migration** — mudança de config, não de schema.
### 3. `src/app/(campaign)/campanha/actions/calendarFeed.ts`
- Núcleo testável `createCalendarFeedLinkRecord(payload, actor, input)` + wrappers `createCalendarFeedLink`/`revokeCalendarFeed`/`listCalendarFeeds` (padrão `*Record` do repo — `createActivity`/`createSupporter`; `/simplify` corrigiu a inversão `With`).
- **Parar de passar `createdBy`** no create → `hookFilledCreateData<'calendarFeed'>` (tipos: campo `required` preenchido pelo hook; mesmo cast de Organization).
- **Escopo de assessor no create (fechar furo do filtro):** validar fail-closed que `filterMunicipality` está no escopo — espelhando o precedente de tour (`activity.ts:166-185`): `payload.find` scoped (`user`, `overrideAccess:false`) e conferir `docs.length === 1`; senão retorna erro amigável. A defesa principal hoje está no acesso de collection (item 1); esta checagem dá a mensagem específica e cobre o caminho da action.
- **Não engolir o erro real:** catch do create/revoke passa a `console.error` no servidor (rastro: quebra de acesso/configuração) mantendo a mensagem amigável para o usuário (contrato de UX inalterado; a mensagem de fora-de-escopo é preservada via `error.message`).
### Access / Consent
- Sem `Consent` novo (nenhuma PII nova; leitura de dados com acesso já controlado).
- `secretSlug`: create=staff, read=fail-closed (`() => false`), update=admin (credencial só nasce no create).
- `createdBy`: hook grava de `req.user.id` (inauthenticável por client); admin de /admin ainda seta manualmente via `canSetCalendarFeedSystemField`.
## Decisões de engenharia
- **Opções:** A) relaxar `create` do `secretSlug` + `update` do `revokedAt` para staff e hook-stamp `createdBy`; B) `overrideAccess:true` no create (como `Supporter`); C) hook que gera o segredo e action relê com override.
- **Recomendação: A** — porque mantém `overrideAccess:false` (invariante: Local API com user → sem override), `read` do segredo continuamente fechado, autor atomizado por hook (sem spoof), e revoga de novo funcionando. Alinha com Organization/Leadership/StateDeputy, os irmãos do mesmo padrão `systemStampedActorField`.
- **Rejeitadas:** B porque `overrideAccess:true` desliga TODO o access control do doc (inclui create de leader se a action falhar em checar; e esconde que o write depende de RBAC); C porque o create response strip `secretSlug` (prova: `hasSecret:false` no retorno com `read:false`) — a action não consegue ler o segredo que o hook gerou sem override, então o servidor deve fornecê-lo (como hoje, `randomUUID()`).
## Fases verificáveis
1. **Schema/server** — access novo (incl. `canCreateCalendarFeed` com `data` scope) + collection hooks + action (núcleo `*Record`, drop `createdBy`, scope-check advisor, `console.error` no catch).
2. **Testes (int)** — `tests/int/calendarFeed.int.spec.ts` novo (10 testes), estilo `campaignSupporter.int.spec.ts` com `installCampaignFixtures`:
- coordinator cria → ok, `createdBy` = actor, URL com secret;
- candidate cria;
- `createdBy` forjado (create cru com `createdBy` de outro) sobrescrito pelo hook;
- revoke → `revokedAt` setado (update staff OK);
- advisor with `filterMunicipality` no escopo → ok;
- advisor fora do escopo → erro (pela action) e `payload.create` cru negado (acesso de collection);
- leader → negado;
- advisor não revoga feed de outro;
- secret nunca retornado em leitura não-admin.
- fixture: `calendarFeed` adicionado a `CampaignCollection`/`emptyOwnedIDs`/cleanup loop/`purgeMunicipalityResidue`.
3. **Gates** — `pnpm gate:fast` (lint/types/unit), `pnpm test` (unit+int), `pnpm build` local.
## Rabbit holes / Não escopo (engenharia)
- **Refactor de todos os `canSet*SystemField` do repo** — corte: mudança local em `calendarFeed` (a intenção manda).
- **Fechar o furo de `filterMunicipality` também no read** (`buildFeedWhere` interseptar escopo no GET) — **registrado como Issue #455 (C96)** via capture-review-debts (gate humano aprovado): defesa em profundidade, `depends: [436]`, destrava sozinha quando o C92 flipar `done`.
- **Create via `/admin` UI** — `secretSlug` hidden+required e `createdBy` readOnly+required: `stampCampaignCreatedBy` só grava `campaignUser`, então admin pelos forms do painel não preenche esses campos. Pré-existente ao C92 (não regressão), fora do escopo (staff cria pelo diálogo da agenda, não pelo painel). Anotado como nota, sem Issue.
- **UI (diálogo/lista)** — C94.
## Riscos e mitigação
- **Regressão no read do feed:** nenhum toque em `calendarFeed.ts`/route iCal — read intacto.
- **Field access relaxado abre escape de `createdBy`?** Não: `createdBy` permanece com `setAccess` admin-only; o hook grava de `req.user` (prova local); client não atinge o valor.
- **CampaignUser admin (Users) cria por /admin:** ok — admin tem `canSetCalendarFeedSystemField`.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (create staff ok, URL responde, revogar ok, erro não engolido, sem leak de escopo)
- [ ] Invariantes AGENTS/engineering-standards (`overrideAccess:false` com user, copy pt-BR, identificadores EN)
- [ ] Testes int de access/write (staff cria; leader não; revoke grava; advisor escopo fail-closed; secret não lido)
- [ ] `pnpm gate:fast` + `pnpm test` + `pnpm build` verdes

View file

@ -0,0 +1,36 @@
# C96 — Feed iCal: interseptar escopo do criador no read (defesa em profundidade)
Status: rascunho
Atualizado em: 2026-08-08
Issue: #455
Priority: P3
Model: composer-2.5
Model-local: deepseek-v4-flash-high
Impeccable: A — N/A sem UI
Appetite: ~0,25–0,5 dia eng
Responsável: —
## Intenção
Débito do `/simplify` do C92 (#436), aprovado no gate humano. O write-side do feed (`canCreateCalendarFeed` + `assertFilterMunicipalityInScope`) já impede um assessor de criar feed pinado a município fora do escopo. Mas o read-side (`buildFeedWhere`, `src/utilities/calendarFeed.ts:104-112`) **confia no `feed.filterMunicipality` sem interseptar os ids acessíveis do criador** quando `accessibleMunicipalityIds` é não-nulo. Consequência: se um assessor é **removido** de um município depois de criar o feed, o feed continua servindo atividades daquele município até o feed ser revogado.
## Aceite
- No GET `/campanha/agenda/ical/[secret]`, quando o criador do feed é advisor e `feed.filterMunicipality` está setado, a consulta intersepta o município do filtro com os municípios atualmente administrados pelo criador — município fora do escopo atual → feed não inclui (404/feed vazio, alinhado ao fail-closed do C16).
- Coordinator/candidate (unrestricted) e admin: comportamento inalterado.
- Sem mudança no write; sem migration (config/consulta only).
## Direção no codebase
- `src/utilities/calendarFeed.ts` → `buildFeedWhere`: quando `feed.filterMunicipality` setado E `accessibleMunicipalityIds` não-nulo (advisor), exigir `{ id: { equals: filterMunicipality } } AND { id: { in: accessible } }` (ou validar antes e 404). `resolveFeedCreatorAccess` já calcula `accessibleMunicipalityIds` no read.
- Precedente: `advisorMunicipalityScopeWhere` (`src/utilities/access/shared.ts`).
- Teste int: `tests/int/calendarFeed.int.spec.ts` — feed criado por advisor em município X, depois advisor removido de X (`assignMunicipalityAdvisors(mun, [])`), GET do feed não lista atividades de X.
## Fora de escopo
- Remodelar o modelo de acesso do feed (maior refactor).
- UI (C94) / sem-filtros (C93), que já são Issues separadas.
## Questões em aberto
- Nenhuma — fail-closed orientado pelo C16 e pelo C92.

View file

@ -5,9 +5,9 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import {
createCalendarFeedLinkRecord,
listCalendarFeedsRecord,
revokeCalendarFeedRecord,
createCalendarFeedLink,
listCalendarFeeds,
revokeCalendarFeed,
} from '@/app/(campaign)/campanha/actions/calendarFeed'
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
@ -47,7 +47,7 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
const [municipalityOptions, knownTags, feeds] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadAccessibleActivityTags(payload, user),
listCalendarFeedsRecord().catch(() => []),
listCalendarFeeds().catch(() => []),
])
const state = restrictActivityAgendaState(
resolvedUrl.state,
@ -86,7 +86,7 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
}))}
onCreateFeed={async (label) => {
'use server'
const result = await createCalendarFeedLinkRecord({
const result = await createCalendarFeedLink({
label,
filterMunicipality: state.municipality,
filterDeputyPresent: state.deputyPresent,
@ -96,7 +96,7 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
}}
onRevokeFeed={async (feedId) => {
'use server'
return revokeCalendarFeedRecord(feedId)
return revokeCalendarFeed(feedId)
}}
/>
<CampaignListResults>

View file

@ -3,15 +3,21 @@
import { randomUUID } from 'node:crypto'
import { calendarFeedCreateSchema, type CalendarFeedCreateInput } from '@/lib/schemas/calendarFeed'
import type { CalendarFeed } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import type { CalendarFeed, CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import type { Payload } from 'payload'
const CALENDAR_FEED_STAFF_ONLY_MESSAGE =
'Apenas a equipe da campanha pode criar feeds de calendário.'
const CALENDAR_FEED_CREATE_FAILED_MESSAGE = 'Não foi possível criar o feed de calendário.'
const CALENDAR_FEED_FILTER_OUT_OF_SCOPE_MESSAGE =
'Não foi possível criar o feed: o município do filtro está fora do seu escopo.'
export type CalendarFeedLinkResult =
| { ok: true; feedUrl: string; feedId: number }
@ -23,93 +29,117 @@ const buildFeedUrl = (secretSlug: string): string => {
}
export const createCalendarFeedLinkRecord = async (
payload: Payload,
actor: CampaignUser,
input: CalendarFeedCreateInput,
): Promise<CalendarFeedLinkResult> => {
const data = calendarFeedCreateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(
payload,
actor,
CALENDAR_FEED_STAFF_ONLY_MESSAGE,
req,
)
if (data.filterMunicipality) {
await assertFilterMunicipalityInScope(payload, currentActor, data.filterMunicipality, req)
}
const secretSlug = randomUUID()
const feed = (await payload.create({
collection: 'calendarFeed',
// `createdBy` is filled by the `stampCampaignCreatedBy` beforeChange hook
// from the acting campaign user — never taken from the client.
data: hookFilledCreateData<'calendarFeed'>({
secretSlug,
label: data.label,
filterMunicipality: data.filterMunicipality,
filterDeputyPresent: data.filterDeputyPresent ?? false,
filterTag: data.filterTag,
}),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})) as CalendarFeed
return {
ok: true as const,
feedUrl: buildFeedUrl(secretSlug),
feedId: feed.id,
}
},
{ beginFailureMessage: 'Não foi possível iniciar a criação do feed de calendário.' },
)
}
export const createCalendarFeedLink = async (
input: CalendarFeedCreateInput,
): Promise<CalendarFeedLinkResult> => {
const { payload, actor } = await getCampaignActionContext()
try {
return await withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(
payload,
actor,
CALENDAR_FEED_STAFF_ONLY_MESSAGE,
req,
)
if (!isCampaignStaff(currentActor)) {
return { ok: false as const, message: CALENDAR_FEED_STAFF_ONLY_MESSAGE }
}
const secretSlug = randomUUID()
const feed = (await payload.create({
collection: 'calendarFeed',
data: {
secretSlug,
label: data.label,
filterMunicipality: data.filterMunicipality,
filterDeputyPresent: data.filterDeputyPresent ?? false,
filterTag: data.filterTag,
createdBy: currentActor.id,
},
depth: 0,
draft: false,
user: currentActor,
overrideAccess: false,
req,
})) as CalendarFeed
return {
ok: true as const,
feedUrl: buildFeedUrl(secretSlug),
feedId: feed.id,
}
},
{ beginFailureMessage: 'Não foi possível iniciar a criação do feed de calendário.' },
)
} catch {
return { ok: false, message: CALENDAR_FEED_CREATE_FAILED_MESSAGE }
return await createCalendarFeedLinkRecord(payload, actor, input)
} catch (error) {
console.error('createCalendarFeedLink failed', error)
const message =
error instanceof Error && error.message === CALENDAR_FEED_FILTER_OUT_OF_SCOPE_MESSAGE
? error.message
: CALENDAR_FEED_CREATE_FAILED_MESSAGE
return { ok: false, message }
}
}
export const revokeCalendarFeedRecord = async (feedId: number): Promise<{ ok: boolean }> => {
export const revokeCalendarFeedRecord = async (
payload: Payload,
actor: CampaignUser,
feedId: number,
): Promise<{ ok: boolean }> => {
await withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(
payload,
actor,
CALENDAR_FEED_STAFF_ONLY_MESSAGE,
req,
)
await payload.update({
collection: 'calendarFeed',
id: feedId,
data: { revokedAt: new Date().toISOString() },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível revogar o feed de calendário.' },
)
return { ok: true }
}
export const revokeCalendarFeed = async (feedId: number): Promise<{ ok: boolean }> => {
const { payload, actor } = await getCampaignActionContext()
try {
await withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(
payload,
actor,
CALENDAR_FEED_STAFF_ONLY_MESSAGE,
req,
)
await payload.update({
collection: 'calendarFeed',
id: feedId,
data: { revokedAt: new Date().toISOString() },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível revogar o feed de calendário.' },
)
return { ok: true }
} catch {
return await revokeCalendarFeedRecord(payload, actor, feedId)
} catch (error) {
console.error('revokeCalendarFeed failed', error)
return { ok: false }
}
}
export const listCalendarFeedsRecord = async (): Promise<CalendarFeed[]> => {
const { payload, actor } = await getCampaignActionContext()
export const listCalendarFeedsRecord = async (
payload: Payload,
actor: CampaignUser,
): Promise<CalendarFeed[]> => {
const result = await payload.find({
collection: 'calendarFeed',
depth: 0,
@ -123,3 +153,37 @@ export const listCalendarFeedsRecord = async (): Promise<CalendarFeed[]> => {
return result.docs
}
export const listCalendarFeeds = async (): Promise<CalendarFeed[]> => {
const { payload, actor } = await getCampaignActionContext()
return listCalendarFeedsRecord(payload, actor)
}
/**
* Verifies a staff actor may pin `filterMunicipality` to a given municipality —
* fail-closed for advisors (the collection create access is a plain staff
* boolean and cannot express a per-município constraint, the same reason the
* tour composer re-checks its stops in `activity.ts`). The scoped read resolves
* against the feed author's own accessible set, so an out-of-scope id never
* survives.
*/
const assertFilterMunicipalityInScope = async (
payload: Payload,
actor: CampaignUser,
municipalityId: number,
req?: PayloadTransactionRequest,
): Promise<void> => {
const readable = await payload.find({
collection: 'municipality',
where: { id: { equals: municipalityId } },
depth: 0,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
req,
})
if (readable.docs.length !== 1) {
throw new Error(CALENDAR_FEED_FILTER_OUT_OF_SCOPE_MESSAGE)
}
}

View file

@ -4,10 +4,12 @@ import {
canCreateCalendarFeed,
canDeleteCalendarFeed,
canReadCalendarFeed,
canSetCalendarFeedRevocation,
canSetCalendarFeedSecret,
canSetCalendarFeedSystemField,
canUpdateCalendarFeed,
} from '@/utilities/campaignAccess'
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
export const CalendarFeed: CollectionConfig = {
slug: 'calendarFeed',
@ -32,6 +34,9 @@ export const CalendarFeed: CollectionConfig = {
update: canUpdateCalendarFeed,
delete: canDeleteCalendarFeed,
},
hooks: {
beforeChange: [stampCampaignCreatedBy],
},
fields: [
{
name: 'secretSlug',
@ -43,7 +48,7 @@ export const CalendarFeed: CollectionConfig = {
hidden: true,
},
access: {
create: canSetCalendarFeedSystemField,
create: canSetCalendarFeedSecret,
read: () => false,
update: canSetCalendarFeedSystemField,
},
@ -90,7 +95,7 @@ export const CalendarFeed: CollectionConfig = {
},
access: {
create: canSetCalendarFeedSystemField,
update: canSetCalendarFeedSystemField,
update: canSetCalendarFeedRevocation,
},
},
systemStampedActorField({ required: true, setAccess: canSetCalendarFeedSystemField }),

View file

@ -1,5 +1,7 @@
import type { Access, FieldAccess } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
getFreshCampaignUser,
isCampaignStaff,
@ -7,11 +9,21 @@ import {
isPayloadAdmin,
} from '@/utilities/access/shared'
export const canCreateCalendarFeed: Access = async ({ req }) => {
export const canCreateCalendarFeed: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return isCampaignStaff(currentUser)
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser || !isCampaignStaff(currentUser)) return false
// The remaining staff role is advisor. A pinned filter municipality must be one
// the advisor administers (same shape as `canCreateSupporter`); the same scope
// is re-derived on every read, so an unfiltered feed stays within scope too.
const municipalityID = relationshipId(data?.filterMunicipality)
if (!municipalityID) return true
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return municipalityIDs?.includes(municipalityID) ?? false
}
export const canReadCalendarFeed: Access = async ({ req }) => {
@ -45,3 +57,25 @@ export const canDeleteCalendarFeed: Access = async ({ req }) => {
}
export const canSetCalendarFeedSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
/**
* Staff may mint a feed's secret on create; only Payload admin rotates it.
* The read side is kept `() => false` in the collection, so the credential is
* never re-read; `update` stays admin-only (revoke+recreate, never in place).
*/
export const canSetCalendarFeedSecret: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
/**
* Staff may mark a feed revoked (update only). `canUpdateCalendarFeed` already
* scopes advisors to their own feeds, so a staff member can only revoke a feed
* they created.
*/
export const canSetCalendarFeedRevocation: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}

View file

@ -149,6 +149,8 @@ export {
canCreateCalendarFeed,
canDeleteCalendarFeed,
canReadCalendarFeed,
canSetCalendarFeedRevocation,
canSetCalendarFeedSecret,
canSetCalendarFeedSystemField,
canUpdateCalendarFeed,
} from '@/utilities/access/calendarFeeds'

View file

@ -43,6 +43,7 @@ type CampaignCollection =
| 'contact'
| 'campaignUser'
| 'consent'
| 'calendarFeed'
type OwnedIDs = Record<CampaignCollection, Set<number>>
@ -126,6 +127,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({
contact: new Set(),
campaignUser: new Set(),
consent: new Set(),
calendarFeed: new Set(),
})
/**
@ -234,50 +236,59 @@ const purgeMunicipalityResidue = async (
payload: Payload,
municipalityID: number,
): Promise<void> => {
const [pledges, updates, demands, leaderships, supporters, activities] = await Promise.all([
payload.find({
collection: 'votePledge',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'municipalityUpdate',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'campaignDemand',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'leadership',
where: { municipalities: { in: [municipalityID] } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'supporter',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'activity',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
])
const [pledges, updates, demands, leaderships, supporters, activities, feeds] = await Promise.all(
[
payload.find({
collection: 'votePledge',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'municipalityUpdate',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'campaignDemand',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'leadership',
where: { municipalities: { in: [municipalityID] } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'supporter',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'activity',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'calendarFeed',
where: { filterMunicipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
],
)
// Residue invites reference residue leaderships (FK), so they must be
// found and deleted before the leaderships — otherwise the leadership
@ -303,6 +314,7 @@ const purgeMunicipalityResidue = async (
| 'leadership'
| 'supporter'
| 'activity'
| 'calendarFeed'
ids: number[]
}> = [
{ collection: 'campaignInvite', ids: invites.docs.map((doc) => doc.id) },
@ -310,6 +322,7 @@ const purgeMunicipalityResidue = async (
{ collection: 'municipalityUpdate', ids: updates.docs.map((doc) => doc.id) },
{ collection: 'campaignDemand', ids: demands.docs.map((doc) => doc.id) },
{ collection: 'activity', ids: activities.docs.map((doc) => doc.id) },
{ collection: 'calendarFeed', ids: feeds.docs.map((doc) => doc.id) },
{ collection: 'leadership', ids: leadershipIDs },
{ collection: 'supporter', ids: supporters.docs.map((doc) => doc.id) },
]
@ -1131,6 +1144,7 @@ export class CampaignFixtures {
'organization',
'stateDeputy',
'contact',
'calendarFeed',
] as const) {
await this.deleteOwned(collection, req)
}

View file

@ -0,0 +1,241 @@
// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import {
createCalendarFeedLinkRecord,
listCalendarFeedsRecord,
revokeCalendarFeedRecord,
type CalendarFeedLinkResult,
} from '@/app/(campaign)/campanha/actions/calendarFeed'
import config from '@/payload.config'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
type CalendarFeedWithSecret = { secretSlug?: string }
/** `{ ok: true }` branch of the create result, narrowed for the caller. */
type CalendarFeedOk = Extract<CalendarFeedLinkResult, { ok: true }>
const requireOk = (result: CalendarFeedLinkResult): CalendarFeedOk => {
if (!result.ok) throw new Error(result.message)
return result
}
describe('campaign calendar feed domain', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('lets a coordinator create a feed and resolves the secret into a URL', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, coordinator, {
label: 'Agenda do comitê',
filterMunicipality: relationId(municipality),
filterDeputyPresent: true,
}),
)
expect(ok.feedUrl).toMatch(/\/campanha\/agenda\/ical\/.{36}$/)
const feed = await payload.findByID({
collection: 'calendarFeed',
id: ok.feedId,
depth: 0,
overrideAccess: true,
})
expect(feed.label).toBe('Agenda do comitê')
expect(relationId(feed.createdBy)).toBe(coordinator.id)
expect(feed.filterDeputyPresent).toBe(true)
expect(relationId(feed.filterMunicipality)).toBe(relationId(municipality))
campaignFixtures().own('calendarFeed', ok.feedId)
})
it('allows a candidate to create a feed', async () => {
const candidate = await campaignFixtures().createCampaignUser('candidate')
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, candidate, {
label: 'Agenda do candidato',
}),
)
const feed = await payload.findByID({
collection: 'calendarFeed',
id: ok.feedId,
depth: 0,
overrideAccess: true,
})
expect(relationId(feed.createdBy)).toBe(candidate.id)
campaignFixtures().own('calendarFeed', ok.feedId)
})
it('stamps createdBy from the acting staff user, overriding a forged value', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const other = await campaignFixtures().createCampaignUser('coordinator')
// The hook must win over a client-supplied `createdBy`, even a bare create
// that does not go through the action (a REST `/api/calendarFeeds` hit).
const feed = await payload.create({
collection: 'calendarFeed',
data: {
secretSlug: crypto.randomUUID(),
label: 'Forjado',
createdBy: other.id,
},
depth: 0,
draft: false,
user: coordinator,
overrideAccess: false,
})
expect(relationId(feed.createdBy)).toBe(coordinator.id)
campaignFixtures().own('calendarFeed', feed.id)
})
it('lets the owning coordinator mark the feed revoked (field access fix)', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Revogável' }),
)
campaignFixtures().own('calendarFeed', ok.feedId)
const revoked = await revokeCalendarFeedRecord(payload, coordinator, ok.feedId)
expect(revoked.ok).toBe(true)
const feed = await payload.findByID({
collection: 'calendarFeed',
id: ok.feedId,
depth: 0,
overrideAccess: true,
})
expect(feed.revokedAt).toBeTruthy()
})
it('hides revoked feeds from an active listing', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const kept = requireOk(
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Ativo' }),
)
campaignFixtures().own('calendarFeed', kept.feedId)
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Revogar logo' }),
)
campaignFixtures().own('calendarFeed', ok.feedId)
await revokeCalendarFeedRecord(payload, coordinator, ok.feedId)
const listing = await listCalendarFeedsRecord(payload, coordinator)
const ids = listing.map((feed) => feed.id)
expect(ids).toContain(kept.feedId)
expect(ids).not.toContain(ok.feedId)
})
it('lets an advisor create a feed pinned to a municipality they administer', async () => {
const advisor = await campaignFixtures().createCampaignUser('advisor')
const administered = await campaignFixtures().getMunicipality()
await campaignFixtures().assignMunicipalityAdvisors(administered, [advisor.id])
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, advisor, {
label: 'Meu município',
filterMunicipality: relationId(administered),
}),
)
const feed = await payload.findByID({
collection: 'calendarFeed',
id: ok.feedId,
depth: 0,
overrideAccess: true,
})
expect(relationId(feed.createdBy)).toBe(advisor.id)
expect(relationId(feed.filterMunicipality)).toBe(relationId(administered))
campaignFixtures().own('calendarFeed', ok.feedId)
})
it('fails close when an advisor pins a municipality outside their scope (action path)', async () => {
const advisor = await campaignFixtures().createCampaignUser('advisor')
const administered = await campaignFixtures().getMunicipality()
const outOfScope = await campaignFixtures().getMunicipality()
await campaignFixtures().assignMunicipalityAdvisors(administered, [advisor.id])
await expect(
createCalendarFeedLinkRecord(payload, advisor, {
label: 'Fora do escopo',
filterMunicipality: relationId(outOfScope),
}),
).rejects.toThrow(/fora do seu escopo/)
})
it('fails close when an advisor pins an out-of-scope municipality via raw create (collection access)', async () => {
const advisor = await campaignFixtures().createCampaignUser('advisor')
const administered = await campaignFixtures().getMunicipality()
const outOfScope = await campaignFixtures().getMunicipality()
await campaignFixtures().assignMunicipalityAdvisors(administered, [advisor.id])
await expect(
payload.create({
collection: 'calendarFeed',
// `createdBy` is filled by the stamp hook at runtime; `hookFilledCreateData`
// keeps the type honest about what the client actually supplies.
data: hookFilledCreateData<'calendarFeed'>({
secretSlug: crypto.randomUUID(),
label: 'Fora do escopo (REST)',
filterMunicipality: relationId(outOfScope),
}),
depth: 0,
draft: false,
user: advisor,
overrideAccess: false,
}),
).rejects.toThrow(/não tem permissão/i)
})
it('blocks a leader from creating a feed', async () => {
const leader = await campaignFixtures().createCampaignUser('leader')
await expect(
createCalendarFeedLinkRecord(payload, leader, {
label: 'Não permitido',
}),
).rejects.toThrow(/apenas a equipe da campanha/i)
})
it('refuses an advisor revoking a feed they do not own', async () => {
const advisor = await campaignFixtures().createCampaignUser('advisor')
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Do coordenador' }),
)
campaignFixtures().own('calendarFeed', ok.feedId)
await expect(revokeCalendarFeedRecord(payload, advisor, ok.feedId)).rejects.toThrow()
})
it('never exposes the secret slug to a staff read', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Segredo' }),
)
campaignFixtures().own('calendarFeed', ok.feedId)
const feed = await payload.findByID({
collection: 'calendarFeed',
id: ok.feedId,
depth: 0,
user: coordinator,
overrideAccess: false,
})
expect((feed as unknown as CalendarFeedWithSecret).secretSlug).toBeUndefined()
})
})