teqo/tests/int/collectionAccessLockdown.int.spec.ts
Francisco Solla 6de8fad8f6 feat(C112): múltiplos telefones por pessoa — Contact.phones array (ordem = prioridade)
- Contact.phone (text único) → phones (array field Payload, subcampo value):
  fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios,
  rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado)
- migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid)
  antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json
  gerado mantém as colunas C99/C100 já existentes)
- primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts
  (ordem = prioridade; inline edit = seta o principal preservando o resto)
- findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz
  append dos faltantes; locks em todos os números digitados)
- sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não
  tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones
- convite redemption: telefone digitado vira principal, demais preservados
- import CSV: preview sinaliza compartilhado também por número secundário;
  bulk insere contact_phones (JOIN nas leituras, uuid no insert)
- busca por telefone (apoiadores, pickers, combobox) casa qualquer número
- UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓,
  modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista),
  apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard
- UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone;
  as ações mapeiam phone → phones[0]
- testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99
  atualizados, e2e wizard/apoiador ajustados
2026-08-11 10:47:02 -03:00

394 lines
13 KiB
TypeScript

// @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 = <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',
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<PayloadRequest>({ 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<RequiredDataFromCollectionSlug<'petition'>>({}),
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<RequiredDataFromCollectionSlug<typeof collection>>({}),
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<PayloadRequest>({ 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.')
})
})