diff --git a/docs/plans/c92-corrigir-criacao-link-import-agenda-impl.md b/docs/plans/c92-corrigir-criacao-link-import-agenda-impl.md new file mode 100644 index 00000000..89be1286 --- /dev/null +++ b/docs/plans/c92-corrigir-criacao-link-import-agenda-impl.md @@ -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 diff --git a/docs/plans/c96-ical-feed-intersect-scope-read.md b/docs/plans/c96-ical-feed-intersect-scope-read.md new file mode 100644 index 00000000..49b14c4b --- /dev/null +++ b/docs/plans/c96-ical-feed-intersect-scope-read.md @@ -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. diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx index 95521a18..76a4b27b 100644 --- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -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) }} /> diff --git a/src/app/(campaign)/campanha/actions/calendarFeed.ts b/src/app/(campaign)/campanha/actions/calendarFeed.ts index d6ebd05e..312226ce 100644 --- a/src/app/(campaign)/campanha/actions/calendarFeed.ts +++ b/src/app/(campaign)/campanha/actions/calendarFeed.ts @@ -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 => { 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 => { 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 => { - const { payload, actor } = await getCampaignActionContext() - +export const listCalendarFeedsRecord = async ( + payload: Payload, + actor: CampaignUser, +): Promise => { const result = await payload.find({ collection: 'calendarFeed', depth: 0, @@ -123,3 +153,37 @@ export const listCalendarFeedsRecord = async (): Promise => { return result.docs } + +export const listCalendarFeeds = async (): Promise => { + 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 => { + 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) + } +} diff --git a/src/collections/CalendarFeed.ts b/src/collections/CalendarFeed.ts index 8f82eac1..cd7b82ea 100644 --- a/src/collections/CalendarFeed.ts +++ b/src/collections/CalendarFeed.ts @@ -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 }), diff --git a/src/utilities/access/calendarFeeds.ts b/src/utilities/access/calendarFeeds.ts index 598c114e..7271ba0f 100644 --- a/src/utilities/access/calendarFeeds.ts +++ b/src/utilities/access/calendarFeeds.ts @@ -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)) +} diff --git a/src/utilities/campaignAccess.ts b/src/utilities/campaignAccess.ts index 79796fb0..a780d128 100644 --- a/src/utilities/campaignAccess.ts +++ b/src/utilities/campaignAccess.ts @@ -149,6 +149,8 @@ export { canCreateCalendarFeed, canDeleteCalendarFeed, canReadCalendarFeed, + canSetCalendarFeedRevocation, + canSetCalendarFeedSecret, canSetCalendarFeedSystemField, canUpdateCalendarFeed, } from '@/utilities/access/calendarFeeds' diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index b06bcbca..937fd0e2 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -43,6 +43,7 @@ type CampaignCollection = | 'contact' | 'campaignUser' | 'consent' + | 'calendarFeed' type OwnedIDs = Record> @@ -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 => { - 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) } diff --git a/tests/int/calendarFeed.int.spec.ts b/tests/int/calendarFeed.int.spec.ts new file mode 100644 index 00000000..cbafa5b3 --- /dev/null +++ b/tests/int/calendarFeed.int.spec.ts @@ -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 + +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() + }) +})