Lock down CMS/PII collection access and leader routes

- 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'
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-23 22:25:18 -03:00
parent 3b2e99ac40
commit 473c82d09d
28 changed files with 593 additions and 201 deletions

View file

@ -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
</Link>
</Button>
<CampaignScopeBadge>
{isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'}
</CampaignScopeBadge>
<CampaignScopeBadge>{campaignRoleLabels[user.role]}</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">{supporter.name}</h1>
<p className="text-muted-foreground">
{supporter.city ?? 'Município não informado'}

View file

@ -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

View file

@ -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() {
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
</Button>
<CampaignScopeBadge>
{isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'}
</CampaignScopeBadge>
<CampaignScopeBadge>{campaignRoleLabels[user.role]}</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">Novo apoiador</h1>
<p className="text-muted-foreground">
Cadastre um apoiador com telefone obrigatório e consentimento LGPD.

View file

@ -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.
</p>
<CampaignScopeBadge>
{isCampaignCoordinator(user)
? 'Coordenador Geral · todos os apoiadores'
{isCampaignUnrestricted(user)
? `${campaignRoleLabels[user.role]} · todos os apoiadores`
: getSupporterScopeLabel(result.totalDocs)}
</CampaignScopeBadge>
</div>

View file

@ -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 (
<CampaignPageShell>
<header className="flex flex-col gap-2">
@ -105,82 +104,67 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
</section>
) : null}
{isStaffView ? (
<>
<DemandWorkflowCard
demandID={demand.id}
status={demand.status}
isCoordinator={user.role === 'coordinator'}
currentCost={demand.cost}
transitionFormAction={transitionDemandFormAction}
costFormAction={setDemandCostFormAction}
receiptFormAction={attachDemandReceiptFormAction}
/>
<DemandWorkflowCard
demandID={demand.id}
status={demand.status}
canDecideEscalated={isCampaignUnrestricted(user)}
currentCost={demand.cost}
transitionFormAction={transitionDemandFormAction}
costFormAction={setDemandCostFormAction}
receiptFormAction={attachDemandReceiptFormAction}
/>
<section
aria-labelledby="demand-internal-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<h2 id="demand-internal-title" className="text-base font-medium">
Controle interno
</h2>
<p className="text-sm text-muted-foreground">
Custo:{' '}
{demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
</p>
{demand.receipts.length ? (
<ul className="flex flex-col gap-1">
{demand.receipts.map((receipt) => (
<li key={receipt.id} className="flex items-center gap-2 text-sm">
<PaperclipIcon className="size-4 text-muted-foreground" aria-hidden="true" />
{receipt.url ? (
<a
href={receipt.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{receipt.filename ?? `Comprovante ${receipt.id}`}
</a>
) : (
<span>{receipt.filename ?? `Comprovante ${receipt.id}`}</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum comprovante anexado.</p>
)}
{demand.statusHistory.length ? (
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Histórico</h3>
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{demand.statusHistory.map((entry, index) => (
<li key={`${entry.status}-${index}`}>
{campaignDemandStatusLabels[entry.status]}
{entry.authorName ? ` — ${entry.authorName}` : ''}
{entry.createdAt
? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
: ''}
{entry.note ? ` · ${entry.note}` : ''}
</li>
))}
</ul>
</div>
) : null}
</section>
</>
) : (
<section aria-label="Acompanhamento" className="rounded-xl border px-4 py-6">
<p className="text-sm text-muted-foreground">
{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.'}
</p>
</section>
)}
<section
aria-labelledby="demand-internal-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<h2 id="demand-internal-title" className="text-base font-medium">
Controle interno
</h2>
<p className="text-sm text-muted-foreground">
Custo: {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
</p>
{demand.receipts.length ? (
<ul className="flex flex-col gap-1">
{demand.receipts.map((receipt) => (
<li key={receipt.id} className="flex items-center gap-2 text-sm">
<PaperclipIcon className="size-4 text-muted-foreground" aria-hidden="true" />
{receipt.url ? (
<a
href={receipt.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{receipt.filename ?? `Comprovante ${receipt.id}`}
</a>
) : (
<span>{receipt.filename ?? `Comprovante ${receipt.id}`}</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum comprovante anexado.</p>
)}
{demand.statusHistory.length ? (
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Histórico</h3>
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{demand.statusHistory.map((entry, index) => (
<li key={`${entry.status}-${index}`}>
{campaignDemandStatusLabels[entry.status]}
{entry.authorName ? ` — ${entry.authorName}` : ''}
{entry.createdAt
? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
: ''}
{entry.note ? ` · ${entry.note}` : ''}
</li>
))}
</ul>
</div>
) : null}
</section>
</CampaignPageShell>
)
}

View file

@ -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)

View file

@ -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(

View file

@ -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)

View file

@ -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([

View file

@ -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,

View file

@ -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(

View file

@ -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',

View file

@ -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: [
{

View file

@ -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<typeof slug> = {
},
access: {
read: () => true,
readVersions: payloadAdminOnly,
create: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
beforeChange: [

View file

@ -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<typeof slug> = {
},
access: {
read: () => true,
readVersions: payloadAdminOnly,
create: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
afterChange: [

View file

@ -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',

View file

@ -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',

View file

@ -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<typeof slug> = {
},
access: {
read: () => true,
create: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
afterChange: [

View file

@ -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

View file

@ -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 (

View file

@ -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<Payload, 'find'>,
userID: number,
req?: CampaignTransactionRequest,
): Promise<number[]> => {
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
/**

View file

@ -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<CampaignUser> => {
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

View file

@ -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'

View file

@ -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<Payload['create']>[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<Consent> {
const marker = this.value('consent')
const consent = await this.rootPayload.create({

View file

@ -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 = <Row>(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<WaitingAdvisoryLock>(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 <Result>(
}
}
export const ensureInviteConsent = async (payload: Payload): Promise<Consent> =>
withTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, async () => {
export const ensureLeasedConsent = async (
payload: Payload,
{ consentKey, leaseKey }: LeasedConsentFixture,
): Promise<Consent> =>
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<Consent> =>
return payload.create({
collection: 'consent',
data: {
key: CAMPAIGN_INVITE_CONSENT_KEY,
key: consentKey,
text: fixtureConsentText,
},
depth: 0,
})
})
export const withInviteConsent = async <Result>(
export const withLeasedConsent = async <Result>(
payload: Payload,
{ consentKey, leaseKey }: LeasedConsentFixture,
operation: (consent: Consent) => Promise<Result>,
): Promise<Result> => {
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 <Result>(
}
}
export const ensureInviteConsent = async (payload: Payload): Promise<Consent> =>
ensureLeasedConsent(payload, {
consentKey: CAMPAIGN_INVITE_CONSENT_KEY,
leaseKey: CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
})
export const withInviteConsent = async <Result>(
payload: Payload,
operation: (consent: Consent) => Promise<Result>,
): Promise<Result> =>
withLeasedConsent(
payload,
{ consentKey: CAMPAIGN_INVITE_CONSENT_KEY, leaseKey: CAMPAIGN_INVITE_CONSENT_LEASE_KEY },
operation,
)
export const withMissingInviteConsentFixture = async <Result>(
payload: Payload,
operation: () => Promise<Result>,
@ -453,9 +471,7 @@ export const withMissingInviteConsentFixture = async <Result>(
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 <Result>(
}
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")

View file

@ -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<typeof campaignFixtures>, 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<typeof campaignFixtures>) => {
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({

View file

@ -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)
})
})

View file

@ -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 = <Result>(
operation: () => Promise<Result>,
): Promise<Result> =>
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.')
})
})