From 473c82d09d8d1b8b6c3508ae46f7c5294282e0c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 23 Jul 2026 22:25:18 -0300 Subject: [PATCH] Lock down CMS/PII collection access and leader routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users/signature/subscription/consent: all operations Payload-admin-only (Payload's default allowed ANY authenticated user, incl. campaign JWTs via /api — privilege escalation + PII exposure) - post/tag/media/petition: public read kept, writes admin-only, readVersions admin-only on drafted collections - leader lockdown: staff gates on /campanha/demandas* and /campanha/planos*; removed the dead leader branch of the demand detail page - fix createLeaderSupporter: scope now asserted via the leader's engaged leadership municipalities (canReadMunicipality always denied leaders) - candidate alignment: supporter area, invite creation, and escalated demand decisions now treat candidate as unrestricted staff consistently - tests: collectionAccessLockdown.int.spec pins the lockdown; leased-consent helpers generalized (supporter keys); fixtures purge deletes residue invites before leaderships; stale leader-phone tests updated to the lockdown model; 'sem Praça' copy renamed to 'sem município' --- .../campanha/(app)/apoiadores/[id]/page.tsx | 6 +- .../(app)/apoiadores/novo/formActions.ts | 2 +- .../campanha/(app)/apoiadores/novo/page.tsx | 6 +- .../campanha/(app)/apoiadores/page.tsx | 7 +- .../campanha/(app)/demandas/[slug]/page.tsx | 140 ++++++------ .../campanha/(app)/demandas/nova/page.tsx | 2 + .../campanha/(app)/demandas/page.tsx | 2 + .../campanha/(app)/planos/[slug]/page.tsx | 1 + .../(campaign)/campanha/(app)/planos/page.tsx | 1 + .../campanha/actions/leaderSupporter.ts | 24 +- .../(campaign)/campanha/actions/supporter.ts | 2 +- src/collections/Consent.ts | 10 + src/collections/Media.ts | 6 + src/collections/Petition.ts | 5 + src/collections/Post.ts | 5 + src/collections/Signature.ts | 9 + src/collections/Subscription.ts | 9 + src/collections/Tag.ts | 4 + src/collections/Users.ts | 12 + .../campaign/DemandWorkflowCard.tsx | 9 +- src/utilities/campaignAccess.ts | 39 ++++ src/utilities/campaignInviteCreation.ts | 5 +- src/utilities/supporterUi.ts | 3 +- tests/helpers/campaignFixtures.ts | 45 +++- tests/helpers/testDatabaseLease.ts | 84 ++++--- tests/int/campaignSupporter.int.spec.ts | 119 ++++++---- tests/int/campaignUserPhone.int.spec.ts | 30 ++- .../int/collectionAccessLockdown.int.spec.ts | 207 ++++++++++++++++++ 28 files changed, 593 insertions(+), 201 deletions(-) create mode 100644 tests/int/collectionAccessLockdown.int.spec.ts diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index 3142cc6e..9460a666 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -13,7 +13,7 @@ import { VoteIntentionControl } from '@/components/campaign/VoteIntentionControl import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { isCampaignCoordinator } from '@/utilities/campaignAccess' +import { campaignRoleLabels } from '@/utilities/campaignUserProfile' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadSupporterDetailConsentData, @@ -69,9 +69,7 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag Voltar para apoiadores - - {isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'} - + {campaignRoleLabels[user.role]}

{supporter.name}

{supporter.city ?? 'Município não informado'} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts index 4a43e2c4..1fdfc67e 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -34,7 +34,7 @@ const safeActionMessages = [ 'Esta pessoa já está cadastrada como apoiador nesta Praça.', 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', 'Somente a coordenação e a assessoria podem gerenciar apoiadores.', - 'Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.', + 'Somente o Coordenador Geral pode cadastrar apoiadores sem município.', 'Consentimento de cadastro de apoiador ainda não configurado.', 'Consentimento de intenção de voto ainda não configurado.', ] as const diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx index baeeeb9f..73785b44 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx @@ -7,7 +7,7 @@ import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoia import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' import { SupporterForm } from '@/components/campaign/SupporterForm' import { Button } from '@/components/ui/button' -import { isCampaignCoordinator } from '@/utilities/campaignAccess' +import { campaignRoleLabels } from '@/utilities/campaignUserProfile' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadSupporterCreatePageData } from '@/utilities/supporterPageData' import { canAccessSupporterArea } from '@/utilities/supporterUi' @@ -26,9 +26,7 @@ export default async function NewSupporterPage() { - - {isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'} - + {campaignRoleLabels[user.role]}

Novo apoiador

Cadastre um apoiador com telefone obrigatório e consentimento LGPD. diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx index 737119f4..d3de2acc 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx @@ -19,8 +19,9 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/Empty' -import { isCampaignCoordinator } from '@/utilities/campaignAccess' +import { isCampaignCoordinator, isCampaignUnrestricted } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' +import { campaignRoleLabels } from '@/utilities/campaignUserProfile' import { loadSupportersPageData } from '@/utilities/supporterPageData' import { buildSupporterFiltersKey, @@ -58,8 +59,8 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro Base nominal de apoio com intenção de voto e vínculo opcional a Praças.

- {isCampaignCoordinator(user) - ? 'Coordenador Geral · todos os apoiadores' + {isCampaignUnrestricted(user) + ? `${campaignRoleLabels[user.role]} · todos os apoiadores` : getSupporterScopeLabel(result.totalDocs)} diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx index 19c15e86..daf83c54 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx @@ -13,7 +13,7 @@ import { campaignDemandStatusLabels, type CampaignDemandStatus, } from '@/lib/schemas/campaignDemand' -import { isCampaignStaff } from '@/utilities/campaignAccess' +import { isCampaignStaff, isCampaignUnrestricted } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadDemandDetail } from '@/utilities/campaignDemandData' import { @@ -51,12 +51,11 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps const { slug } = await params const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) if (!user) redirect('/campanha/login') + if (!isCampaignStaff(user)) redirect('/campanha') const demand = await loadDemandDetail(payload, user, slug) if (!demand) notFound() - const isStaffView = isCampaignStaff(user) - return (
@@ -105,82 +104,67 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps ) : null} - {isStaffView ? ( - <> - + -
-

- Controle interno -

-

- Custo:{' '} - {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'} -

- {demand.receipts.length ? ( - - ) : ( -

Nenhum comprovante anexado.

- )} - {demand.statusHistory.length ? ( -
-

Histórico

-
    - {demand.statusHistory.map((entry, index) => ( -
  • - {campaignDemandStatusLabels[entry.status]} - {entry.authorName ? ` — ${entry.authorName}` : ''} - {entry.createdAt - ? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}` - : ''} - {entry.note ? ` · ${entry.note}` : ''} -
  • - ))} -
-
- ) : null} -
- - ) : ( -
-

- {demand.status === 'aberta' - ? 'Sua demanda foi registrada e aguarda análise da assessoria.' - : demand.status === 'em_analise' || demand.status === 'escalada' - ? 'Sua demanda está em análise pela coordenação.' - : 'Sua demanda foi decidida — veja a nota acima.'} -

-
- )} +
+

+ Controle interno +

+

+ Custo: {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'} +

+ {demand.receipts.length ? ( + + ) : ( +

Nenhum comprovante anexado.

+ )} + {demand.statusHistory.length ? ( +
+

Histórico

+
    + {demand.statusHistory.map((entry, index) => ( +
  • + {campaignDemandStatusLabels[entry.status]} + {entry.authorName ? ` — ${entry.authorName}` : ''} + {entry.createdAt + ? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}` + : ''} + {entry.note ? ` · ${entry.note}` : ''} +
  • + ))} +
+
+ ) : null} +
) } diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx index 3821e9c7..435ea8ba 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx @@ -7,6 +7,7 @@ import { getPayload } from 'payload' import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' import { DemandForm } from '@/components/campaign/DemandForm' import { Button } from '@/components/ui/button' +import { isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' import { createDemandFormAction } from './formActions' @@ -14,6 +15,7 @@ import { createDemandFormAction } from './formActions' export default async function NewDemandPage() { const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) if (!user) redirect('/campanha/login') + if (!isCampaignStaff(user)) redirect('/campanha') const municipalityOptions = await loadMunicipalityOptions(payload, user) diff --git a/src/app/(campaign)/campanha/(app)/demandas/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/page.tsx index a7fc2d5c..efaca7a3 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/page.tsx @@ -30,6 +30,7 @@ import { campaignDemandStatuses, type CampaignDemandStatus, } from '@/lib/schemas/campaignDemand' +import { isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadDemandListPageData, parseDemandListParams } from '@/utilities/campaignDemandData' @@ -54,6 +55,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) { const rawSearchParams = await searchParams const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) if (!user) redirect('/campanha/login') + if (!isCampaignStaff(user)) redirect('/campanha') const state = parseDemandListParams(rawSearchParams) const { rows, totalDocs, totalPages, openCount } = await loadDemandListPageData( diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx index 223a989a..4a2f6447 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx @@ -48,6 +48,7 @@ export default async function ActionPlanDetailPage({ ]) if (!user) redirect('/campanha/login') + if (!isCampaignStaff(user)) redirect('/campanha') if (!slug) notFound() const activeTab = resolveActionPlanDetailTab(query) diff --git a/src/app/(campaign)/campanha/(app)/planos/page.tsx b/src/app/(campaign)/campanha/(app)/planos/page.tsx index 57e25cfc..6bfa09a4 100644 --- a/src/app/(campaign)/campanha/(app)/planos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/page.tsx @@ -40,6 +40,7 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) if (!user) return null + if (!isCampaignStaff(user)) redirect('/campanha') const now = new Date() const [{ result, state }, municipalityOptions] = await Promise.all([ diff --git a/src/app/(campaign)/campanha/actions/leaderSupporter.ts b/src/app/(campaign)/campanha/actions/leaderSupporter.ts index 1545ce43..d9d1e230 100644 --- a/src/app/(campaign)/campanha/actions/leaderSupporter.ts +++ b/src/app/(campaign)/campanha/actions/leaderSupporter.ts @@ -3,14 +3,11 @@ import { revalidatePath } from 'next/cache' import type { Payload } from 'payload' -import { - assertMunicipalityManagement, - upsertContactByPhone, -} from '@/app/(campaign)/campanha/actions/supporter' +import { upsertContactByPhone } from '@/app/(campaign)/campanha/actions/supporter' import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData' import { leaderSupporterCreateSchema } from '@/lib/schemas/supporter' import type { CampaignUser, Supporter } from '@/payload-types' -import { isCampaignLeader } from '@/utilities/campaignAccess' +import { getEngagedLeaderMunicipalityIds, isCampaignLeader } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' import { requireConsentByKey, @@ -38,11 +35,15 @@ export type LeaderSupporterFormValues = { municipality?: string } +const MUNICIPALITY_OUT_OF_SCOPE_MESSAGE = + 'Você só pode cadastrar contatos nos municípios da sua liderança.' + const safeActionMessages = [ 'Esta pessoa já está cadastrada como apoiador neste município.', 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', 'Somente lideranças podem cadastrar contatos por aqui.', 'Consentimento de cadastro de apoiador ainda não configurado.', + MUNICIPALITY_OUT_OF_SCOPE_MESSAGE, ] as const const getLeaderSupporterFormError = ( @@ -82,7 +83,18 @@ export const createLeaderSupporterRecord = async ( payload, async ({ req }) => { const currentActor = await getFreshLeaderActor(payload, actor, req) - await assertMunicipalityManagement(payload, currentActor, data.municipality, req) + + // Leaders cannot read the municipality collection, so scope is asserted + // against their own engaged leadership's municipalities (mirroring the + // leader branch of the collection-level `canCreateSupporter`). + const accessibleMunicipalityIDs = await getEngagedLeaderMunicipalityIds( + payload, + currentActor.id, + req, + ) + if (!accessibleMunicipalityIDs.includes(data.municipality)) { + throw new Error(MUNICIPALITY_OUT_OF_SCOPE_MESSAGE) + } const registrationConsent = await requireConsentByKey( payload, diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts index 4a8f58ee..b097c3e2 100644 --- a/src/app/(campaign)/campanha/actions/supporter.ts +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -193,7 +193,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i if (data.municipality) { await assertMunicipalityManagement(payload, currentActor, data.municipality, req) } else if (currentActor.role !== 'coordinator') { - throw new Error('Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.') + throw new Error('Somente o Coordenador Geral pode cadastrar apoiadores sem município.') } const registrationConsent = await requireSupporterRegistrationConsent( diff --git a/src/collections/Consent.ts b/src/collections/Consent.ts index 69df3b24..6389ce35 100644 --- a/src/collections/Consent.ts +++ b/src/collections/Consent.ts @@ -1,3 +1,4 @@ +import { payloadAdminOnly } from '@/utilities/campaignAccess' import { CollectionConfig } from 'payload' export const Consent: CollectionConfig = { @@ -10,6 +11,15 @@ export const Consent: CollectionConfig = { group: 'Contatos', useAsTitle: 'text', }, + // Versioned legal texts referenced by signatures/subscriptions/supporters. + // Server flows resolve them via the Local API without a user; only admins + // may create or alter them. + access: { + create: payloadAdminOnly, + read: payloadAdminOnly, + update: payloadAdminOnly, + delete: payloadAdminOnly, + }, fields: [ { name: 'key', diff --git a/src/collections/Media.ts b/src/collections/Media.ts index c02edab0..8bdb4ace 100644 --- a/src/collections/Media.ts +++ b/src/collections/Media.ts @@ -1,3 +1,4 @@ +import { payloadAdminOnly } from '@/utilities/campaignAccess' import type { CollectionConfig } from 'payload' export const Media: CollectionConfig = { @@ -9,8 +10,13 @@ export const Media: CollectionConfig = { admin: { group: 'Coleções', }, + // Campaign flows (avatar, demand receipts) upload via server actions with an + // explicit overrideAccess: true after their own role checks. access: { read: () => true, + create: payloadAdminOnly, + update: payloadAdminOnly, + delete: payloadAdminOnly, }, fields: [ { diff --git a/src/collections/Petition.ts b/src/collections/Petition.ts index 4723d539..5ebc81bf 100644 --- a/src/collections/Petition.ts +++ b/src/collections/Petition.ts @@ -1,4 +1,5 @@ import { normalizeFacebookPixelId } from '@/lib/facebookPixel' +import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateDocumentById } from '@/utilities/documents' import type { CollectionConfig } from 'payload' @@ -25,6 +26,10 @@ export const Petition: CollectionConfig = { }, access: { read: () => true, + readVersions: payloadAdminOnly, + create: payloadAdminOnly, + update: payloadAdminOnly, + delete: payloadAdminOnly, }, hooks: { beforeChange: [ diff --git a/src/collections/Post.ts b/src/collections/Post.ts index b285fa9a..6bacef48 100644 --- a/src/collections/Post.ts +++ b/src/collections/Post.ts @@ -1,3 +1,4 @@ +import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents' import { slugify } from '@/utilities/slug' import type { CollectionConfig } from 'payload' @@ -22,6 +23,10 @@ export const Post: CollectionConfig = { }, access: { read: () => true, + readVersions: payloadAdminOnly, + create: payloadAdminOnly, + update: payloadAdminOnly, + delete: payloadAdminOnly, }, hooks: { afterChange: [ diff --git a/src/collections/Signature.ts b/src/collections/Signature.ts index 72864516..e5c89635 100644 --- a/src/collections/Signature.ts +++ b/src/collections/Signature.ts @@ -1,3 +1,4 @@ +import { payloadAdminOnly } from '@/utilities/campaignAccess' import { signatureContactToCSV, signaturePetitionToCSV } from '@/utilities/signatureExport' import { CollectionConfig } from 'payload' @@ -10,6 +11,14 @@ export const Signature: CollectionConfig = { admin: { group: 'Abaixo-assinados', }, + // Citizen PII. Public petition flows write via the Local API without a user + // (overrideAccess defaults to true), so admin-only access does not affect them. + access: { + create: payloadAdminOnly, + read: payloadAdminOnly, + update: payloadAdminOnly, + delete: payloadAdminOnly, + }, fields: [ { type: 'relationship', diff --git a/src/collections/Subscription.ts b/src/collections/Subscription.ts index 3da3465e..d7ce4106 100644 --- a/src/collections/Subscription.ts +++ b/src/collections/Subscription.ts @@ -1,3 +1,4 @@ +import { payloadAdminOnly } from '@/utilities/campaignAccess' import { CollectionConfig } from 'payload' export const Subscription: CollectionConfig = { @@ -9,6 +10,14 @@ export const Subscription: CollectionConfig = { admin: { group: 'Contatos', }, + // Citizen PII. Public opt-in flows write via the Local API without a user + // (overrideAccess defaults to true), so admin-only access does not affect them. + access: { + create: payloadAdminOnly, + read: payloadAdminOnly, + update: payloadAdminOnly, + delete: payloadAdminOnly, + }, fields: [ { type: 'relationship', diff --git a/src/collections/Tag.ts b/src/collections/Tag.ts index 06bb2be6..95a038f7 100644 --- a/src/collections/Tag.ts +++ b/src/collections/Tag.ts @@ -1,3 +1,4 @@ +import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents' import { slugify } from '@/utilities/slug' import type { CollectionConfig } from 'payload' @@ -16,6 +17,9 @@ export const Tag: CollectionConfig = { }, access: { read: () => true, + create: payloadAdminOnly, + update: payloadAdminOnly, + delete: payloadAdminOnly, }, hooks: { afterChange: [ diff --git a/src/collections/Users.ts b/src/collections/Users.ts index eb4f2b7a..2998534f 100644 --- a/src/collections/Users.ts +++ b/src/collections/Users.ts @@ -1,3 +1,4 @@ +import { isPayloadAdmin, payloadAdminOnly } from '@/utilities/campaignAccess' import type { CollectionConfig } from 'payload' export const Users: CollectionConfig = { @@ -11,6 +12,17 @@ export const Users: CollectionConfig = { useAsTitle: 'email', }, auth: true, + // Admin accounts are managed only by admin accounts. Without this, Payload's + // "any authenticated user" default would let a campaignUser JWT manage admins + // through /api/users — a privilege-escalation path. + access: { + admin: ({ req }) => isPayloadAdmin(req.user), + create: payloadAdminOnly, + read: payloadAdminOnly, + update: payloadAdminOnly, + delete: payloadAdminOnly, + unlock: payloadAdminOnly, + }, fields: [ // Email added by default // Add more fields as needed diff --git a/src/components/campaign/DemandWorkflowCard.tsx b/src/components/campaign/DemandWorkflowCard.tsx index e627ffe6..9ab64098 100644 --- a/src/components/campaign/DemandWorkflowCard.tsx +++ b/src/components/campaign/DemandWorkflowCard.tsx @@ -24,7 +24,8 @@ type FormAction = ( type DemandWorkflowCardProps = { demandID: number status: CampaignDemandStatus - isCoordinator: boolean + /** Coordinator or candidate — the roles allowed to decide escalated demands. */ + canDecideEscalated: boolean currentCost: number | null transitionFormAction: FormAction costFormAction: FormAction @@ -38,7 +39,7 @@ const transitionVariant = (target: CampaignDemandStatus) => export const DemandWorkflowCard = ({ demandID, status, - isCoordinator, + canDecideEscalated, currentCost, transitionFormAction, costFormAction, @@ -53,7 +54,9 @@ export const DemandWorkflowCard = ({ const availableTransitions = campaignDemandTransitions[status].filter( (target) => - isCoordinator || status !== 'escalada' || (target !== 'aprovada' && target !== 'rejeitada'), + canDecideEscalated || + status !== 'escalada' || + (target !== 'aprovada' && target !== 'rejeitada'), ) return ( diff --git a/src/utilities/campaignAccess.ts b/src/utilities/campaignAccess.ts index 3aa25ad4..ba2cf32c 100644 --- a/src/utilities/campaignAccess.ts +++ b/src/utilities/campaignAccess.ts @@ -33,6 +33,14 @@ type DynamicFind = (args: { export const isPayloadAdmin = (user: CampaignActor): user is User => user?.collection === 'users' +/** + * Payload-admin-only collection access. Collections without explicit access fall + * back to Payload's "any authenticated user" default — which includes campaign + * users hitting `/api/*` with a `campaign-token` JWT — so every CMS/PII + * collection must set this (or something stricter) explicitly. + */ +export const payloadAdminOnly: Access = ({ req }) => isPayloadAdmin(req.user) + const isCampaignUser = (user: CampaignActor): user is CampaignUser => user?.collection === 'campaignUser' @@ -176,6 +184,37 @@ export const getAdvisorMunicipalityIds = async ( return result.docs.map((doc) => relationshipId(doc.id)).filter((id): id is number => id !== null) } +/** + * Municipality IDs linked to `userID`'s engaged leadership, without requiring a + * `PayloadRequest`. Transaction-safe counterpart of the leader branch in + * `getAccessibleMunicipalityIds`, for server actions running inside + * `withPayloadTransaction` (where only `{ transactionID }` is available). + */ +export const getEngagedLeaderMunicipalityIds = async ( + payload: Pick, + userID: number, + req?: CampaignTransactionRequest, +): Promise => { + const find = payload.find.bind(payload) as unknown as DynamicFind + const result = await find({ + collection: 'leadership', + where: { + and: [{ user: { equals: userID } }, { supportStatus: { equals: 'engajado' } }], + }, + depth: 0, + limit: 1, + pagination: false, + select: { municipalities: true }, + overrideAccess: true, + ...(req ? { req } : {}), + }) + + const doc = result.docs[0] + return (Array.isArray(doc?.municipalities) ? doc.municipalities : []) + .map(relationshipId) + .filter((id): id is number => id !== null) +} + type OwnLeadership = { id: number; municipalityIDs: number[]; organizationIDs: number[] } | null /** diff --git a/src/utilities/campaignInviteCreation.ts b/src/utilities/campaignInviteCreation.ts index d51c863a..a5ab73c0 100644 --- a/src/utilities/campaignInviteCreation.ts +++ b/src/utilities/campaignInviteCreation.ts @@ -7,6 +7,7 @@ import { type CampaignInviteCreateInput, } from '@/lib/schemas/invite' import type { CampaignUser } from '@/payload-types' +import { isCampaignStaff } from '@/utilities/campaignAccess' import { reloadCampaignActor } from '@/utilities/campaignActionContext' import { buildCampaignInviteWhatsAppLink, @@ -31,7 +32,9 @@ const getFreshInviteCreator = async ( req: PayloadTransactionRequest, ): Promise => { const currentActor = await reloadCampaignActor(payload, actor, req) - if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') { + // Staff only (coordinator, advisor, candidate) — matches the collection-level + // `canCreateCampaignInvite` scope; the leadership read below narrows advisors. + if (!isCampaignStaff(currentActor)) { throw new Error('Somente a coordenação pode criar convites.') } return currentActor diff --git a/src/utilities/supporterUi.ts b/src/utilities/supporterUi.ts index 8f2633f6..aaa97024 100644 --- a/src/utilities/supporterUi.ts +++ b/src/utilities/supporterUi.ts @@ -120,5 +120,6 @@ export const resolveSupporterListUrl = ( export const getSupporterScopeLabel = (total: number): string => `${total} ${total === 1 ? 'apoiador nas suas Praças' : 'apoiadores nas suas Praças'}` +/** Staff roles with supporter-area access — matches collection-level supporter access. */ export const canAccessSupporterArea = (role: CampaignUser['role']): boolean => - role === 'coordinator' || role === 'advisor' + role === 'coordinator' || role === 'advisor' || role === 'candidate' diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index d4140e25..77d4d121 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -114,7 +114,17 @@ const emptyOwnedIDs = (): OwnedIDs => ({ consent: new Set(), }) -const inviteConsentKey = 'lideranca-autopreenchimento' +/** + * Consent rows shared across spec files (stable keys the app resolves at + * runtime). They are leased via `testDatabaseLease.ts`, never owned by a + * fixture instance — deleting them at cleanup would steal them from + * concurrently running spec files. + */ +const leasedConsentKeys = new Set([ + 'lideranca-autopreenchimento', + 'apoiador-cadastro', + 'apoiador-intencao-voto', +]) const defaultConsentText = (text: string): Consent['text'] => ({ root: { @@ -237,8 +247,24 @@ const purgeMunicipalityResidue = async ( }), ]) + // Residue invites reference residue leaderships (FK), so they must be + // found and deleted before the leaderships — otherwise the leadership + // delete aborts and the residue poisons every later claim of this municipality. + const leadershipIDs = leaderships.docs.map((doc) => doc.id) + const invites = + leadershipIDs.length > 0 + ? await payload.find({ + collection: 'campaignInvite', + where: { leadership: { in: leadershipIDs } }, + depth: 0, + pagination: false, + select: {}, + }) + : { docs: [] } + const deletions: Array<{ collection: + | 'campaignInvite' | 'votePledge' | 'municipalityUpdate' | 'campaignDemand' @@ -247,11 +273,12 @@ const purgeMunicipalityResidue = async ( | 'actionPlan' ids: number[] }> = [ + { collection: 'campaignInvite', ids: invites.docs.map((doc) => doc.id) }, { collection: 'votePledge', ids: pledges.docs.map((doc) => doc.id) }, { collection: 'municipalityUpdate', ids: updates.docs.map((doc) => doc.id) }, { collection: 'campaignDemand', ids: demands.docs.map((doc) => doc.id) }, { collection: 'actionPlan', ids: plans.docs.map((doc) => doc.id) }, - { collection: 'leadership', ids: leaderships.docs.map((doc) => doc.id) }, + { collection: 'leadership', ids: leadershipIDs }, { collection: 'supporter', ids: supporters.docs.map((doc) => doc.id) }, ] for (const { collection, ids } of deletions) { @@ -283,13 +310,14 @@ export class CampaignFixtures { const trackedCreate = async (args: Parameters[0]) => { const document = await rootPayload.create(args as never) const collection = args.collection - const isLeasedInviteConsent = + const isLeasedConsent = collection === 'consent' && typeof args.data === 'object' && args.data !== null && 'key' in args.data && - args.data.key === inviteConsentKey - if (collection in this.owned && typeof document.id === 'number' && !isLeasedInviteConsent) { + typeof args.data.key === 'string' && + leasedConsentKeys.has(args.data.key) + if (collection in this.owned && typeof document.id === 'number' && !isLeasedConsent) { this.own(collection as CampaignCollection, document.id) } return document @@ -519,6 +547,13 @@ export class CampaignFixtures { return user } + /** + * Creates an OWNED consent row (deleted at cleanup) — even for a leased + * shared key, since calling this helper is an explicit ownership request. + * For the shared stable keys, prefer `ensureLeasedConsent` / + * `withLeasedConsent` (testDatabaseLease.ts) so parallel spec files are not + * robbed of the row at cleanup. + */ async createConsent(input: ConsentInput = {}): Promise { const marker = this.value('consent') const consent = await this.rootPayload.create({ diff --git a/tests/helpers/testDatabaseLease.ts b/tests/helpers/testDatabaseLease.ts index 8fe7d017..1026c575 100644 --- a/tests/helpers/testDatabaseLease.ts +++ b/tests/helpers/testDatabaseLease.ts @@ -51,7 +51,15 @@ type ConsentSnapshot = { } export const CAMPAIGN_INVITE_CONSENT_LEASE_KEY = 'campaign-invite-consent' +export const SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY = 'supporter-registration-consent' +export const SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY = 'supporter-vote-intention-consent' const CAMPAIGN_INVITE_CONSENT_KEY = 'lideranca-autopreenchimento' + +/** A consent row shared across spec files, protected by an advisory-lock lease. */ +export type LeasedConsentFixture = { + consentKey: string + leaseKey: string +} const fixtureConsentText: Consent['text'] = { root: { type: 'root', @@ -88,11 +96,8 @@ const rowsFrom = (result: unknown): Row[] => { const combineErrors = (primary: unknown, cleanup: unknown, message: string): AggregateError => new AggregateError([primary, cleanup], message) -const addFailure = ( - primary: unknown | undefined, - secondary: unknown, - message: string, -): unknown => (primary === undefined ? secondary : combineErrors(primary, secondary, message)) +const addFailure = (primary: unknown | undefined, secondary: unknown, message: string): unknown => + primary === undefined ? secondary : combineErrors(primary, secondary, message) const rollbackWithFault = async ( lease: InternalTestDatabaseLease, @@ -236,19 +241,14 @@ export const startTestDatabaseLeaseAcquisition = ( resolveBackendPID = resolve rejectBackendPID = reject }) - const acquisition = beginTestDatabaseLease( - payload, - leaseKey, - mode, - async ({ transactionID }) => { - try { - resolveBackendPID(await getTestTransactionBackendPID(payload, transactionID)) - } catch (error) { - rejectBackendPID(error) - throw error - } - }, - ) + const acquisition = beginTestDatabaseLease(payload, leaseKey, mode, async ({ transactionID }) => { + try { + resolveBackendPID(await getTestTransactionBackendPID(payload, transactionID)) + } catch (error) { + rejectBackendPID(error) + throw error + } + }) return { acquisition, backendPID } } @@ -302,9 +302,7 @@ export const waitForAdvisoryLockWaiter = async ( const waiting = rowsFrom(result)[0] if (waiting) return waiting } - throw new Error( - `Timed out waiting for backend ${waiterPID} on the exact ${mode} advisory lock.`, - ) + throw new Error(`Timed out waiting for backend ${waiterPID} on the exact ${mode} advisory lock.`) } export const acquireTestDatabaseLease = async ( @@ -373,11 +371,14 @@ export const withSharedTestDatabaseLease = async ( } } -export const ensureInviteConsent = async (payload: Payload): Promise => - withTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, async () => { +export const ensureLeasedConsent = async ( + payload: Payload, + { consentKey, leaseKey }: LeasedConsentFixture, +): Promise => + withTestDatabaseLease(payload, leaseKey, async () => { const existing = await payload.find({ collection: 'consent', - where: { key: { equals: CAMPAIGN_INVITE_CONSENT_KEY } }, + where: { key: { equals: consentKey } }, limit: 1, depth: 0, }) @@ -386,27 +387,28 @@ export const ensureInviteConsent = async (payload: Payload): Promise => return payload.create({ collection: 'consent', data: { - key: CAMPAIGN_INVITE_CONSENT_KEY, + key: consentKey, text: fixtureConsentText, }, depth: 0, }) }) -export const withInviteConsent = async ( +export const withLeasedConsent = async ( payload: Payload, + { consentKey, leaseKey }: LeasedConsentFixture, operation: (consent: Consent) => Promise, ): Promise => { for (;;) { - await ensureInviteConsent(payload) - const lease = await beginTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, 'shared') + await ensureLeasedConsent(payload, { consentKey, leaseKey }) + const lease = await beginTestDatabaseLease(payload, leaseKey, 'shared') let failure: unknown let result: Result | undefined let retry = false try { const configured = await payload.find({ collection: 'consent', - where: { key: { equals: CAMPAIGN_INVITE_CONSENT_KEY } }, + where: { key: { equals: consentKey } }, limit: 1, depth: 0, }) @@ -433,6 +435,22 @@ export const withInviteConsent = async ( } } +export const ensureInviteConsent = async (payload: Payload): Promise => + ensureLeasedConsent(payload, { + consentKey: CAMPAIGN_INVITE_CONSENT_KEY, + leaseKey: CAMPAIGN_INVITE_CONSENT_LEASE_KEY, + }) + +export const withInviteConsent = async ( + payload: Payload, + operation: (consent: Consent) => Promise, +): Promise => + withLeasedConsent( + payload, + { consentKey: CAMPAIGN_INVITE_CONSENT_KEY, leaseKey: CAMPAIGN_INVITE_CONSENT_LEASE_KEY }, + operation, + ) + export const withMissingInviteConsentFixture = async ( payload: Payload, operation: () => Promise, @@ -453,9 +471,7 @@ export const withMissingInviteConsentFixture = async ( LIMIT 1 `), )[0] - await setupLease.transaction.execute( - sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`, - ) + await setupLease.transaction.execute(sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`) } catch (error) { setupError = error } @@ -509,9 +525,7 @@ export const withMissingInviteConsentFixture = async ( } if (restoreLease) { try { - await restoreLease.transaction.execute( - sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`, - ) + await restoreLease.transaction.execute(sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`) if (snapshot) { await restoreLease.transaction.execute(sql` INSERT INTO "consent" ("id", "key", "text", "updated_at", "created_at") diff --git a/tests/int/campaignSupporter.int.spec.ts b/tests/int/campaignSupporter.int.spec.ts index f7df966a..5b977974 100644 --- a/tests/int/campaignSupporter.int.spec.ts +++ b/tests/int/campaignSupporter.int.spec.ts @@ -23,6 +23,12 @@ import { import { buildSupporterListWhere } from '@/utilities/supporterUi' import { installCampaignFixtures } from '../helpers/campaignFixtures' +import { + ensureLeasedConsent, + SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY, + withMissingInviteConsentFixture, +} from '../helpers/testDatabaseLease' let payload: Payload const campaignFixtures = installCampaignFixtures({ @@ -32,24 +38,23 @@ const campaignFixtures = installCampaignFixtures({ }, }) -const ensureConsentByKey = async (fixtures: ReturnType, key: string) => { - const existing = await payload.find({ - collection: 'consent', - where: { key: { equals: key } }, - depth: 0, - limit: 1, - pagination: false, - }) - if (existing.docs[0]) { - fixtures.own('consent', existing.docs[0].id) - return existing.docs[0] - } - return fixtures.createConsent({ key }) -} +/** + * Shared consent rows (stable keys) are leased fixtures: ensured under an + * advisory-lock lease and never owned/deleted, so parallel spec files can rely + * on them (see `leasedConsentKeys` in campaignFixtures.ts). + */ +const ensureConsentByKey = (key: string, leaseKey: string) => + ensureLeasedConsent(payload, { consentKey: key, leaseKey }) -const ensureSupporterConsents = async (fixtures: ReturnType) => { - const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) - const voteIntention = await ensureConsentByKey(fixtures, SUPPORTER_VOTE_INTENTION_CONSENT_KEY) +const ensureSupporterConsents = async () => { + const registration = await ensureConsentByKey( + SUPPORTER_REGISTRATION_CONSENT_KEY, + SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + ) + const voteIntention = await ensureConsentByKey( + SUPPORTER_VOTE_INTENTION_CONSENT_KEY, + SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY, + ) return { registration, voteIntention } } @@ -87,28 +92,27 @@ describe('campaign supporter domain', () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') - const existing = await payload.find({ - collection: 'consent', - where: { key: { equals: SUPPORTER_REGISTRATION_CONSENT_KEY } }, - depth: 0, - limit: 1, - }) - if (existing.docs[0]) { - await payload.delete({ collection: 'consent', id: existing.docs[0].id }) - } - - await expect( - createSupporterRecord(payload, coordinator, { - name: fixtures.value('Apoiador'), - phone: fixtures.phone(), - consentAccepted: true, - }), - ).rejects.toThrow(/Consentimento de cadastro de apoiador ainda não configurado/) + await withMissingInviteConsentFixture( + payload, + async () => { + await expect( + createSupporterRecord(payload, coordinator, { + name: fixtures.value('Apoiador'), + phone: fixtures.phone(), + consentAccepted: true, + }), + ).rejects.toThrow(/Consentimento de cadastro de apoiador ainda não configurado/) + }, + { + consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY, + leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + }, + ) }) it('creates a supporter with contact upsert and blocks leadership coexistence', async () => { const fixtures = campaignFixtures() - await ensureSupporterConsents(fixtures) + await ensureSupporterConsents() const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() const phone = fixtures.phone() @@ -144,7 +148,7 @@ describe('campaign supporter domain', () => { it('scopes advisor create to administered municipalities and blocks municipality-less create', async () => { const fixtures = campaignFixtures() - await ensureSupporterConsents(fixtures) + await ensureSupporterConsents() const advisor = await fixtures.createCampaignUser('advisor') const municipality = await fixtures.getMunicipality() const otherMunicipality = await fixtures.getMunicipality() @@ -180,7 +184,10 @@ describe('campaign supporter domain', () => { it('requires highlighted vote-intention consent before setting intention', async () => { const fixtures = campaignFixtures() - const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + const registration = await ensureConsentByKey( + SUPPORTER_REGISTRATION_CONSENT_KEY, + SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + ) const coordinator = await fixtures.createCampaignUser('coordinator') const contact = await fixtures.createContact() const supporter = await fixtures.createSupporter({ @@ -212,7 +219,10 @@ describe('campaign supporter domain', () => { }), ).rejects.toThrow(/Consentimento de intenção de voto ainda não configurado/) - await ensureConsentByKey(fixtures, SUPPORTER_VOTE_INTENTION_CONSENT_KEY) + await ensureConsentByKey( + SUPPORTER_VOTE_INTENTION_CONSENT_KEY, + SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY, + ) const updated = await setSupporterVoteIntentionRecord(payload, coordinator, { id: supporter.id, @@ -225,7 +235,7 @@ describe('campaign supporter domain', () => { it('previews and confirms CSV import for the coordinator only', async () => { const fixtures = campaignFixtures() - await ensureSupporterConsents(fixtures) + await ensureSupporterConsents() const coordinator = await fixtures.createCampaignUser('coordinator') const advisor = await fixtures.createCampaignUser('advisor') const phoneOk = fixtures.phone() @@ -292,7 +302,7 @@ describe('campaign supporter domain', () => { it('rejects a tampered import token and a token issued to another actor', async () => { const fixtures = campaignFixtures() - await ensureSupporterConsents(fixtures) + await ensureSupporterConsents() const coordinator = await fixtures.createCampaignUser('coordinator') const otherCoordinator = await fixtures.createCampaignUser('coordinator') const phoneOk = fixtures.phone() @@ -343,7 +353,7 @@ describe('campaign supporter domain', () => { it('removes supporter and anonymizes contact when no other joins remain', async () => { const fixtures = campaignFixtures() - await ensureSupporterConsents(fixtures) + await ensureSupporterConsents() const coordinator = await fixtures.createCampaignUser('coordinator') const phone = fixtures.phone() const created = await createSupporterRecord(payload, coordinator, { @@ -370,7 +380,10 @@ describe('campaign supporter domain', () => { it('aggregates supporter overview KPIs in a single SQL query for the coordinator', async () => { const fixtures = campaignFixtures() - const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + const registration = await ensureConsentByKey( + SUPPORTER_REGISTRATION_CONSENT_KEY, + SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + ) const coordinator = await fixtures.createCampaignUser('coordinator') const intentions: Array<{ @@ -424,7 +437,10 @@ describe('campaign supporter domain', () => { it('scopes the overview aggregate to administered municipalities for an advisor and applies filters', async () => { const fixtures = campaignFixtures() - const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + const registration = await ensureConsentByKey( + SUPPORTER_REGISTRATION_CONSENT_KEY, + SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + ) const advisor = await fixtures.createCampaignUser('advisor') const assigned = await fixtures.getMunicipality() const other = await fixtures.getMunicipality() @@ -471,7 +487,10 @@ describe('campaign supporter domain', () => { it('ignores single-character search queries in list and overview filters', async () => { const fixtures = campaignFixtures() - const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + const registration = await ensureConsentByKey( + SUPPORTER_REGISTRATION_CONSENT_KEY, + SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + ) const coordinator = await fixtures.createCampaignUser('coordinator') const uniqueName = fixtures.value('ApoiadorUnico') @@ -509,7 +528,10 @@ describe('campaign supporter domain', () => { it('keeps list and overview totals aligned for multi-character search', async () => { const fixtures = campaignFixtures() - const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + const registration = await ensureConsentByKey( + SUPPORTER_REGISTRATION_CONSENT_KEY, + SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + ) const coordinator = await fixtures.createCampaignUser('coordinator') const uniqueName = fixtures.value('ApoiadorBusca') @@ -547,11 +569,14 @@ describe('campaign supporter domain', () => { it('loads supporters page data scoped to the administered municipalities of an advisor', async () => { const fixtures = campaignFixtures() - await ensureSupporterConsents(fixtures) + await ensureSupporterConsents() const advisor = await fixtures.createCampaignUser('advisor') const assigned = await fixtures.getMunicipality() await fixtures.assignMunicipalityAdvisors(assigned, [advisor]) - const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + const registration = await ensureConsentByKey( + SUPPORTER_REGISTRATION_CONSENT_KEY, + SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + ) const contact = await fixtures.createContact() await fixtures.createSupporter({ diff --git a/tests/int/campaignUserPhone.int.spec.ts b/tests/int/campaignUserPhone.int.spec.ts index 5f4025cc..e30b525a 100644 --- a/tests/int/campaignUserPhone.int.spec.ts +++ b/tests/int/campaignUserPhone.int.spec.ts @@ -62,7 +62,7 @@ describe('campaign user contact phone', () => { }) }) - it('denies leaders from reading advisor contact phones', async () => { + it('omits advisor contact phones from leader reads (lockdown)', async () => { await withCampaignFixtures(payload, async (fixtures) => { const coordinator = await fixtures.createCampaignUser('coordinator') const advisorPhone = fixtures.phone() @@ -76,16 +76,19 @@ describe('campaign user contact phone', () => { await fixtures.assignMunicipalityAdvisors(municipality, [advisor]) await createEngagedPhoneAccessGraph(fixtures, coordinator, leader, municipality.id) - await expect( - payload.findByID({ - collection: 'campaignUser', - id: advisor.id, - depth: 0, - select: { name: true, phone: true }, - user: leader, - overrideAccess: false, - }), - ).rejects.toThrow(/permissão/i) + // Leaders are lockdown accounts: even for advisors of their own linked + // municipality, the phone field must be withheld. + const advisorRead = await payload.findByID({ + collection: 'campaignUser', + id: advisor.id, + depth: 0, + select: { name: true, phone: true }, + user: leader, + overrideAccess: false, + }) + + expect(advisorRead.phone).toBeUndefined() + expect(JSON.stringify(advisorRead)).not.toContain(advisorPhone) }) }) @@ -154,8 +157,11 @@ describe('campaign user contact phone', () => { }) const byId = new Map(result.docs.map((doc) => [doc.id, doc])) - expect(byId.get(advisor.id)?.phone).toBe(advisorPhone) + // Leaders are lockdown accounts: no staff phone is visible, linked + // municipality or not. + expect(byId.get(advisor.id)?.phone).toBeUndefined() expect(byId.get(foreignAdvisor.id)?.phone).toBeUndefined() + expect(JSON.stringify(result.docs)).not.toContain(advisorPhone) expect(JSON.stringify(result.docs)).not.toContain(foreignPhone) }) }) diff --git a/tests/int/collectionAccessLockdown.int.spec.ts b/tests/int/collectionAccessLockdown.int.spec.ts new file mode 100644 index 00000000..2a49f31f --- /dev/null +++ b/tests/int/collectionAccessLockdown.int.spec.ts @@ -0,0 +1,207 @@ +// @vitest-environment node + +import { getPayload, type Payload } from 'payload' +import { beforeAll, describe, expect, it } from 'vitest' + +import { createLeaderSupporterRecord } from '@/app/(campaign)/campanha/actions/leaderSupporter' +import config from '@/payload.config' +import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/utilities/campaignConsent' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' +import { + SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + withLeasedConsent, +} from '../helpers/testDatabaseLease' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +/** Runs `operation` with the shared supporter-registration consent guaranteed present. */ +const withSupporterRegistrationConsent = ( + operation: () => Promise, +): Promise => + withLeasedConsent( + payload, + { + consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY, + leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + }, + operation, + ) + +/** + * Collections without explicit `access` fall back to Payload's "any + * authenticated user" default — which includes campaign users presenting the + * `campaign-token` JWT against `/api/*`. These tests pin the lockdown added to + * the CMS/PII collections: campaign users (even the coordinator) must be + * denied, admins keep working, and the public keeps read-only access where + * intended. + */ +describe('CMS/PII collection access lockdown', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('denies campaign users any access to admin accounts (users)', async () => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + + await expect( + payload.count({ collection: 'users', where: {}, user: coordinator, overrideAccess: false }), + ).rejects.toThrow(/permissão|not allowed/i) + + await expect( + payload.create({ + collection: 'users', + data: { email: 'escalated@example.com', password: 'owned-by-attacker' }, + user: coordinator, + overrideAccess: false, + }), + ).rejects.toThrow(/permissão|not allowed/i) + }) + + it('lets admin users keep managing the users collection', async () => { + const fixtures = campaignFixtures() + const admin = await fixtures.createAdminUser() + + const result = await payload.count({ + collection: 'users', + where: { id: { equals: admin.id } }, + user: admin, + overrideAccess: false, + }) + expect(result.totalDocs).toBe(1) + }) + + it.each(['signature', 'subscription', 'consent'] as const)( + 'denies campaign users and anonymous requests access to %s (PII/legal)', + async (collection) => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + + await expect( + payload.count({ collection, where: {}, user: coordinator, overrideAccess: false }), + ).rejects.toThrow(/permissão|not allowed/i) + + await expect(payload.count({ collection, where: {}, overrideAccess: false })).rejects.toThrow( + /permissão|not allowed/i, + ) + }, + ) + + it('lets admin users keep reading signatures and subscriptions', async () => { + const fixtures = campaignFixtures() + const admin = await fixtures.createAdminUser() + + await expect( + payload.count({ collection: 'signature', where: {}, user: admin, overrideAccess: false }), + ).resolves.toMatchObject({ totalDocs: expect.any(Number) }) + await expect( + payload.count({ collection: 'subscription', where: {}, user: admin, overrideAccess: false }), + ).resolves.toMatchObject({ totalDocs: expect.any(Number) }) + }) + + it.each(['post', 'tag', 'petition', 'media'] as const)( + 'denies campaign users create access to public-content collection %s', + async (collection) => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + + await expect( + payload.create({ + collection, + data: {}, + user: coordinator, + overrideAccess: false, + } as never), + ).rejects.toThrow(/permissão|not allowed/i) + }, + ) + + it.each(['post', 'tag', 'petition', 'media'] as const)( + 'keeps anonymous read access to public-content collection %s', + async (collection) => { + await expect( + payload.count({ collection, where: {}, overrideAccess: false }), + ).resolves.toMatchObject({ totalDocs: expect.any(Number) }) + }, + ) +}) + +describe('leader supporter creation scope', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('lets an engaged leader register a supporter in a linked municipality', async () => { + const fixtures = campaignFixtures() + const leaderAccount = await fixtures.createCampaignUser('leader') + const leaderContact = await fixtures.createContact() + const linked = await fixtures.getMunicipality() + await fixtures.createLeadership({ + contact: leaderContact.id, + municipalities: [linked.id], + user: leaderAccount.id, + supportStatus: 'engajado', + }) + + const supporter = await withSupporterRegistrationConsent(() => + createLeaderSupporterRecord(payload, leaderAccount, { + name: fixtures.value('Apoiador'), + phone: fixtures.phone(), + city: 'Salvador', + municipality: linked.id, + consentAccepted: true, + }), + ) + fixtures.own('supporter', supporter.id) + + expect(supporter.source).toBe('lideranca') + expect(fixtures.id(supporter.createdBy as never)).toBe(leaderAccount.id) + expect(fixtures.id(supporter.municipality as never)).toBe(linked.id) + }) + + it('rejects a supporter outside the leadership municipalities', async () => { + const fixtures = campaignFixtures() + const leaderAccount = await fixtures.createCampaignUser('leader') + const leaderContact = await fixtures.createContact() + const linked = await fixtures.getMunicipality() + const outside = await fixtures.getMunicipality() + await fixtures.createLeadership({ + contact: leaderContact.id, + municipalities: [linked.id], + user: leaderAccount.id, + supportStatus: 'engajado', + }) + + await expect( + withSupporterRegistrationConsent(() => + createLeaderSupporterRecord(payload, leaderAccount, { + name: fixtures.value('Apoiador'), + phone: fixtures.phone(), + city: 'Salvador', + municipality: outside.id, + consentAccepted: true, + }), + ), + ).rejects.toThrow('Você só pode cadastrar contatos nos municípios da sua liderança.') + }) + + it('rejects staff accounts on the leader-only contact tool', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const municipality = await fixtures.getMunicipality() + + await expect( + createLeaderSupporterRecord(payload, advisor, { + name: fixtures.value('Apoiador'), + phone: fixtures.phone(), + city: 'Salvador', + municipality: municipality.id, + consentAccepted: true, + }), + ).rejects.toThrow('Somente lideranças podem cadastrar contatos por aqui.') + }) +})