// @vitest-environment node import type { Payload, PayloadRequest, RequiredDataFromCollectionSlug } from 'payload' import { assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { assignMunicipalityAdvisorsRecord } from '@/app/(campaign)/campanha/actions/municipality' import { CampaignUser as CampaignUserCollection } from '@/collections/CampaignUser' import { CAMPAIGN_SESSION_TTL_LONG } from '@/lib/campaignSessionTtl' import { campaignLoginSchema } from '@/lib/schemas/campaign-login' import type { CampaignUser } from '@/payload-types' import config from '@/payload.config' import { getAccessibleMunicipalityIds, isCampaignCoordinator } from '@/utilities/campaignAccess' import { authenticateCampaignToken, revokeCampaignSession } from '@/utilities/campaignAuth' import { getPayload } from 'payload' import { installCampaignFixtures } from '../helpers/campaignFixtures' import { stub } from '../helpers/stub' /** Create data with `role` intentionally omitted so the collection default applies. */ type CampaignUserCreateData = RequiredDataFromCollectionSlug<'campaignUser'> let payload: Payload const campaignFixtures = installCampaignFixtures({ getPayload: () => payload, setPayload: (nextPayload) => { payload = nextPayload }, }) describe('campaign authentication foundation', () => { beforeAll(async () => { payload = await getPayload({ config: await config }) }) beforeEach(() => { vi.restoreAllMocks() }) it('configures optional username and email login', () => { expect(CampaignUserCollection.auth).toMatchObject({ tokenExpiration: CAMPAIGN_SESSION_TTL_LONG, loginWithUsername: { allowEmailLogin: true, requireEmail: false, requireUsername: false, }, }) }) it('defaults newly created campaign users to leader', async () => { const username = campaignFixtures().phone() const user = await payload.create({ collection: 'campaignUser', data: stub({ name: 'Liderança padrão', username, password: campaignFixtures().value('password'), }), }) expect(user.role).toBe('leader') }) it('authenticates staff by email and leadership by normalized phone', async () => { const password = campaignFixtures().value('password') const email = `${campaignFixtures().value('staff')}@example.com` const phone = campaignFixtures().phone() await payload.create({ collection: 'campaignUser', data: { name: 'Coordenação', email, password, role: 'coordinator', }, }) await payload.create({ collection: 'campaignUser', data: { name: 'Liderança', username: phone, password, role: 'leader', }, }) const staffLogin = await payload.login({ collection: 'campaignUser', data: { email, password }, }) const leadershipLogin = await payload.login({ collection: 'campaignUser', data: { username: phone, password }, }) expect(staffLogin.user?.email).toBe(email) expect(leadershipLogin.user?.username).toBe(phone) }) it('stops authenticating a token after its server session is revoked', async () => { const email = `${campaignFixtures().value('logout')}@example.com` const password = campaignFixtures().value('password') await payload.create({ collection: 'campaignUser', data: { name: 'Sessão encerrada', email, password, role: 'advisor', }, }) const login = await payload.login({ collection: 'campaignUser', data: { email, password }, }) assert(login.token) const token = login.token await expect(authenticateCampaignToken(token, payload)).resolves.not.toBeNull() await revokeCampaignSession(token, payload) await expect(authenticateCampaignToken(token, payload)).resolves.toBeNull() }) it('preserves leading and trailing password spaces through validation and Payload login', async () => { const password = ` ${campaignFixtures().value('exact-password')} ` const trimmedPassword = password.trim() const email = `${campaignFixtures().value('spaced-password')}@example.com` await payload.create({ collection: 'campaignUser', data: { name: 'Senha com espaços', email, password, role: 'coordinator', }, }) const parsed = campaignLoginSchema.parse({ identifier: ` ${email} `, password, }) expect(parsed).toEqual({ identifier: email, password }) await expect( payload.login({ collection: 'campaignUser', data: { email: parsed.identifier, password: parsed.password }, }), ).resolves.toHaveProperty('token') await expect( payload.login({ collection: 'campaignUser', data: { email, password: trimmedPassword }, }), ).rejects.toThrow() }) it('re-reads the campaign user so a role downgrade applies immediately', async () => { const staleJwtUser = { id: 42, collection: 'campaignUser', role: 'coordinator', } as CampaignUser const currentUser = { ...staleJwtUser, role: 'advisor', } as CampaignUser const auth = vi.fn().mockResolvedValue({ user: staleJwtUser }) const findByID = vi.fn().mockResolvedValue(currentUser) const result = await authenticateCampaignToken('token', { auth, findByID, }) expect(findByID).toHaveBeenCalledWith({ collection: 'campaignUser', id: staleJwtUser.id, depth: 0, }) expect(result?.email).toBe('') expect(result?.role).toBe('advisor') expect(isCampaignCoordinator(result)).toBe(false) }) it('denies management when a stale JWT still says coordinator after downgrade', async () => { const staleCoordinator = await payload.create({ collection: 'campaignUser', data: { name: 'Coordenação rebaixada', email: `${campaignFixtures().value('downgraded')}@example.com`, password: campaignFixtures().value('password'), role: 'coordinator', }, }) await payload.update({ collection: 'campaignUser', id: staleCoordinator.id, data: { role: 'advisor', }, }) await expect( payload.create({ collection: 'campaignUser', data: { name: 'Criação indevida', email: `${campaignFixtures().value('denied')}@example.com`, password: campaignFixtures().value('password'), role: 'coordinator', }, user: staleCoordinator, overrideAccess: false, }), ).rejects.toThrow() }) it.each(['coordinator', 'advisor', 'leader'] as const)( 'hides identity and private auth fields from another %s user', async (role) => { const viewer = await payload.create({ collection: 'campaignUser', data: { name: `Viewer ${role}`, email: `${campaignFixtures().value(role)}@example.com`, password: campaignFixtures().value('password'), role, }, }) const target = await payload.create({ collection: 'campaignUser', data: { name: 'Outra liderança', username: campaignFixtures().phone(), password: campaignFixtures().value('password'), role: 'leader', }, }) const visibleTarget = await payload.findByID({ collection: 'campaignUser', id: target.id, user: viewer, overrideAccess: false, }) expect(visibleTarget.name).toBe(target.name) expect(visibleTarget.email).toBeUndefined() expect(visibleTarget.username).toBeUndefined() expect(visibleTarget.hash).toBeUndefined() expect(visibleTarget.salt).toBeUndefined() expect(visibleTarget.sessions).toBeUndefined() expect(visibleTarget.loginAttempts).toBeUndefined() expect(visibleTarget.lockUntil).toBeUndefined() expect(visibleTarget.resetPasswordToken).toBeUndefined() expect(visibleTarget.resetPasswordExpiration).toBeUndefined() }, ) it('allows a campaign user to read their own identity fields', async () => { const username = campaignFixtures().phone() const target = await payload.create({ collection: 'campaignUser', data: { name: 'Liderança titular', username, password: campaignFixtures().value('password'), role: 'leader', }, }) const visibleTarget = await payload.findByID({ collection: 'campaignUser', id: target.id, user: target, overrideAccess: false, }) expect(visibleTarget.username).toBe(username) }) it('allows a Payload admin to read campaign user identity fields', async () => { const admin = await campaignFixtures().createAdminUser({ email: `${campaignFixtures().value('admin')}@example.com`, password: campaignFixtures().value('password'), }) const username = campaignFixtures().phone() const target = await payload.create({ collection: 'campaignUser', data: stub({ name: 'Liderança administrada', username, password: campaignFixtures().value('password'), }), }) const visibleTarget = await payload.findByID({ collection: 'campaignUser', id: target.id, user: admin, overrideAccess: false, }) expect(visibleTarget.username).toBe(username) }) it.each([ { actorKind: 'admin', targetRole: 'advisor' }, { actorKind: 'coordinator', targetRole: 'coordinator' }, ] as const)( 'blocks an assigned $targetRole downgrade from a Payload $actorKind while advisor of a municipality', async ({ actorKind, targetRole }) => { const coordinator = await payload.create({ collection: 'campaignUser', data: { name: 'Coordenação responsável', email: `${campaignFixtures().value('coordinator')}@example.com`, password: campaignFixtures().value('password'), role: 'coordinator', }, }) const admin = actorKind === 'admin' ? await campaignFixtures().createAdminUser({ email: `${campaignFixtures().value('admin')}@example.com`, password: campaignFixtures().value('password'), }) : null const target = await payload.create({ collection: 'campaignUser', data: { name: 'Responsável ainda designado', email: `${campaignFixtures().value('assigned')}@example.com`, password: campaignFixtures().value('password'), role: targetRole, }, }) const municipality = await campaignFixtures().getMunicipality() await campaignFixtures().assignMunicipalityAdvisors(municipality, [target]) const actor = admin ?? coordinator await expect( payload.update({ collection: 'campaignUser', id: target.id, data: { role: 'leader' }, user: actor, overrideAccess: false, }), ).rejects.toThrow( 'Remova ou substitua este usuário da assessoria de municípios, dobradinhas ou lideranças antes de alterar o papel para liderança.', ) if (admin) { await payload.update({ collection: 'municipality', id: municipality.id, data: { advisors: [] }, user: admin, overrideAccess: false, depth: 0, }) } else { await assignMunicipalityAdvisorsRecord(payload, coordinator, { municipality: municipality.id, advisors: [], }) } const downgraded = await payload.update({ collection: 'campaignUser', id: target.id, data: { role: 'leader' }, user: actor, overrideAccess: false, }) expect(downgraded.role).toBe('leader') }, ) it.each(['advisor', 'leader'] as const)( 'denies campaignUser CRUD management to an ordinary %s', async (role) => { const actor = await payload.create({ collection: 'campaignUser', data: { name: `Actor ${role}`, email: `${campaignFixtures().value(role)}@example.com`, password: campaignFixtures().value('password'), role, }, }) const target = await payload.create({ collection: 'campaignUser', data: { name: 'Alvo', email: `${campaignFixtures().value('target')}@example.com`, password: campaignFixtures().value('password'), role: 'leader', }, }) await expect( payload.create({ collection: 'campaignUser', data: stub({ name: 'Criação indevida', email: `${campaignFixtures().value('denied')}@example.com`, password: campaignFixtures().value('password'), }), user: actor, overrideAccess: false, }), ).rejects.toThrow() await expect( payload.update({ collection: 'campaignUser', id: target.id, data: { name: 'Atualização indevida' }, user: actor, overrideAccess: false, }), ).rejects.toThrow() await expect( payload.delete({ collection: 'campaignUser', id: target.id, user: actor, overrideAccess: false, }), ).rejects.toThrow() }, ) it('returns only municipalities administered by an advisor', async () => { const advisor = await campaignFixtures().createCampaignUser('advisor') const assignedMunicipality = await campaignFixtures().getMunicipality() const otherMunicipality = await campaignFixtures().getMunicipality() await campaignFixtures().assignMunicipalityAdvisors(assignedMunicipality, [advisor]) const req = stub({ context: {}, payload }) const ids = await getAccessibleMunicipalityIds(req, advisor) expect(ids).toEqual([assignedMunicipality.id]) expect(ids).not.toContain(otherMunicipality.id) }) it('uses the current role when resolving accessible municipalities', async () => { const find = vi.fn().mockResolvedValue({ docs: [{ id: 7 }], }) const staleCoordinator = { id: 11, collection: 'campaignUser', role: 'coordinator', } as CampaignUser const currentAdvisor = { ...staleCoordinator, role: 'advisor', } as CampaignUser const req = { context: {}, payload: { collections: { municipality: {}, }, find, findByID: vi.fn().mockResolvedValue(currentAdvisor), }, user: staleCoordinator, } as unknown as PayloadRequest const ids = await getAccessibleMunicipalityIds(req) expect(ids).toEqual([7]) expect(req.payload.findByID).toHaveBeenCalledWith( expect.objectContaining({ collection: 'campaignUser', id: staleCoordinator.id, overrideAccess: true, req, }), ) expect(find).toHaveBeenCalledWith( expect.objectContaining({ collection: 'municipality', req, where: { advisors: { contains: staleCoordinator.id, }, }, }), ) }) it('returns only engaged municipalities for a leader and propagates req', async () => { const find = vi.fn().mockResolvedValue({ docs: [{ id: 3, municipalities: [7], organizations: [] }], }) const req = { context: {}, payload: { collections: { leadership: {}, }, find, }, } as unknown as PayloadRequest const user = { id: 12, collection: 'campaignUser', role: 'leader', } as CampaignUser const ids = await getAccessibleMunicipalityIds(req, user) expect(ids).toEqual([7]) expect(find).toHaveBeenCalledWith( expect.objectContaining({ collection: 'leadership', overrideAccess: true, req, where: { and: [{ user: { equals: user.id } }, { supportStatus: { equals: 'engajado' } }], }, }), ) }) })