teqo-backup/tests/int/campaignAuth.int.spec.ts

534 lines
16 KiB
TypeScript

// @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<CampaignUserCreateData>({
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<CampaignUserCreateData>({
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<CampaignUserCreateData>({
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<PayloadRequest>({ 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' } }],
},
}),
)
})
})