// @vitest-environment node import { getPayload, type Payload, type PayloadRequest, type RequiredDataFromCollectionSlug, } from 'payload' import { beforeAll, describe, expect, it } from 'vitest' import { createLeaderSupporterRecord } from '@/app/(campaign)/campanha/actions/leaderSupporter' import { Media } from '@/collections/Media' import { Post } from '@/collections/Post' import { Tag } from '@/collections/Tag' import { HomePage } from '@/globals/HomePage' import { Metadata } from '@/globals/Metadata' import { PrivacyPolicy } from '@/globals/PrivacyPolicy' import { SiteSettings } from '@/globals/SiteSettings' import { relationshipId } from '@/lib/relationship' import config from '@/payload.config' import { canManagePublishedContent, payloadAdminOnly } from '@/utilities/access/shared' import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/utilities/campaignConsent' import { installCampaignFixtures } from '../helpers/campaignFixtures' import { stub } from '../helpers/stub' import { SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, withLeasedConsent, } from '../helpers/testDatabaseLease' let payload: Payload const campaignFixtures = installCampaignFixtures({ getPayload: () => payload, setPayload: (nextPayload) => { payload = nextPayload }, }) /** Runs `operation` with the shared supporter-registration consent guaranteed present. */ const withSupporterRegistrationConsent = ( operation: () => Promise, ): Promise => withLeasedConsent( payload, { consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY, leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, }, operation, ) /** * Collections without explicit `access` fall back to Payload's "any * authenticated user" default — which includes campaign users presenting the * `campaign-token` JWT against `/api/*`. These tests pin the lockdown added to * the CMS/PII collections: campaign users (even the coordinator) must be * denied, admins keep working, and the public keeps read-only access where * intended. */ describe('CMS/PII collection access lockdown', () => { beforeAll(async () => { payload = await getPayload({ config: await config }) }) it('denies campaign users any access to admin accounts (users)', async () => { const coordinator = await campaignFixtures().createCampaignUser('coordinator') await expect( payload.count({ collection: 'users', where: {}, user: coordinator, overrideAccess: false }), ).rejects.toThrow(/permissão|not allowed/i) await expect( payload.create({ collection: 'users', data: { email: 'escalated@example.com', password: 'owned-by-attacker', roles: ['admin'], }, 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() expect(admin.roles).toEqual(['admin']) const result = await payload.count({ collection: 'users', where: { id: { equals: admin.id } }, user: admin, overrideAccess: false, }) expect(result.totalDocs).toBe(1) }) it('lets editors enter the panel for published content but denies PII and campaign data', async () => { const fixtures = campaignFixtures() const editor = await fixtures.createEditorUser() expect(editor.roles).toEqual(['editor']) // Call access directly — creating a Tag would hit revalidateTag outside Next. for (const collection of [Tag, Post, Media]) { expect(collection.access?.create).toBe(canManagePublishedContent) } expect( canManagePublishedContent({ req: stub({ user: editor }), }), ).toBe(true) await expect( payload.count({ collection: 'users', where: {}, user: editor, overrideAccess: false, }), ).rejects.toThrow(/permissão|not allowed/i) for (const collection of [ 'signature', 'subscription', 'consent', 'contact', 'municipality', ] as const) { await expect( payload.count({ collection, where: {}, user: editor, overrideAccess: false }), ).rejects.toThrow(/permissão|not allowed/i) } await expect( payload.create({ collection: 'petition', data: stub>({}), user: editor, overrideAccess: false, }), ).rejects.toThrow(/permissão|not allowed/i) }) it('lets an editor read and update their own users account but not escalate roles', async () => { const fixtures = campaignFixtures() const password = fixtures.value('password') const editor = await fixtures.createEditorUser({ password }) const self = await payload.findByID({ collection: 'users', id: editor.id, user: editor, overrideAccess: false, depth: 0, }) expect(self.id).toBe(editor.id) expect(self.roles).toEqual(['editor']) await expect( payload.update({ collection: 'users', id: editor.id, data: { roles: ['admin', 'editor'] }, user: editor, overrideAccess: false, depth: 0, }), ).rejects.toThrow(/inválido|permissão|not allowed|Papéis/i) const afterEscalate = await payload.findByID({ collection: 'users', id: editor.id, depth: 0, overrideAccess: true, }) expect(afterEscalate.roles).toEqual(['editor']) // Anti-lockout: payload.auth must return the document with roles populated. // If req.user were JWT claims alone, isPayloadAdmin would deny every admin. const login = await payload.login({ collection: 'users', data: { email: editor.email, password }, }) expect(login.user).toBeDefined() expect(login.user?.roles).toEqual(['editor']) const { user: authed } = await payload.auth({ headers: new Headers({ Authorization: `JWT ${login.token}` }), }) expect(authed?.collection).toBe('users') expect(authed && 'roles' in authed ? authed.roles : undefined).toEqual(['editor']) }) 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, // Denied before validation: an empty payload stands in for required fields. data: stub>({}), user: coordinator, overrideAccess: false, }), ).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) }) }, ) }) /** * The globals sibling of the collection lockdown above: until Pass 4 the four * public globals spelled `update` as "any authenticated user", and campaign * JWTs authenticate against `/api/*` — a campaign session (leader included) * could PATCH the public site's settings, home, metadata and privacy policy. * The allow path is asserted on the access function itself because a real * `updateGlobal` fires the afterChange `revalidateTag` outside Next. */ describe('public globals update lockdown', () => { const publicGlobals = [ { config: SiteSettings, slug: 'site-settings' }, { config: HomePage, slug: 'home' }, { config: Metadata, slug: 'metadata' }, { config: PrivacyPolicy, slug: 'privacy-policy' }, ] as const beforeAll(async () => { payload = await getPayload({ config: await config }) }) it('spells update as payloadAdminOnly on every public global', () => { for (const { config: globalConfig } of publicGlobals) { expect(globalConfig.access?.update).toBe(payloadAdminOnly) } }) it.each(publicGlobals)('denies campaign users update on global %s', async ({ slug }) => { const coordinator = await campaignFixtures().createCampaignUser('coordinator') await expect( payload.updateGlobal({ slug, data: {}, user: coordinator, overrideAccess: false, }), ).rejects.toThrow(/permissão|not allowed/i) }) it.each(publicGlobals)('denies editors update on global %s', async ({ slug }) => { const editor = await campaignFixtures().createEditorUser() await expect( payload.updateGlobal({ slug, data: {}, user: editor, overrideAccess: false, }), ).rejects.toThrow(/permissão|not allowed/i) }) it('lets payload admins update through the access function', async () => { const admin = await campaignFixtures().createAdminUser() for (const { config: globalConfig } of publicGlobals) { expect(globalConfig.access?.update?.({ req: stub({ user: admin }) })).toBe( true, ) } }) it.each(publicGlobals)('keeps anonymous read access on global %s', async ({ slug }) => { // Never-saved globals resolve with field defaults (no id) — the pin is // that anonymous read is not denied, not the document shape. await expect(payload.findGlobal({ slug, overrideAccess: false })).resolves.toBeDefined() }) }) 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'), phones: [fixtures.phone()], city: 'Salvador', municipality: linked.id, consentAccepted: true, }), ) fixtures.own('supporter', supporter.id) expect(supporter.source).toBe('lideranca') expect(relationshipId(supporter.createdBy)).toBe(leaderAccount.id) expect(relationshipId(supporter.municipality)).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'), phones: [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'), phones: [fixtures.phone()], city: 'Salvador', municipality: municipality.id, consentAccepted: true, }), ).rejects.toThrow('Somente lideranças podem cadastrar contatos por aqui.') }) })