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:
parent
3b2e99ac40
commit
473c82d09d
28 changed files with 593 additions and 201 deletions
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
207
tests/int/collectionAccessLockdown.int.spec.ts
Normal file
207
tests/int/collectionAccessLockdown.int.spec.ts
Normal 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.')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue