teqo/tests/int/campaignWebAuthnAccess.int.spec.ts
Francisco Ignácio Fontoura Solla 763f64080a Deliver B40: biometric login for /campanha.
WebAuthn platform passkeys: opt-in enrollment after a password login, a
password-less sign-in that mints the same 14-day campaign-token, and
revocation from the profile. The session is minted through Payload's public
pieces (checkLoginPermission, addSessionToUser, getFieldsToSign, jwtSign)
inside a transaction with an advisory lock, so a passkey login neither
bypasses a lockout nor evicts the account's other sessions. The rpID is
derived from NEXT_PUBLIC_SITE_URL and resolves to null when the request host
disagrees, so previews never offer a ceremony the browser would refuse.
2026-07-28 10:59:23 -03:00

165 lines
4.9 KiB
TypeScript

// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const enrollPasskey = (userID: number, credentialId: string) =>
payload.create({
collection: 'campaignWebAuthnCredential',
data: {
user: userID,
credentialId,
publicKey: 'cHVibGljLWtleQ',
counter: 0,
deviceLabel: 'iPhone de teste',
},
overrideAccess: true,
})
/**
* A passkey is a login factor, so its row is the owner's business only — not
* the coordinator's, not another advisor's. Payload's default access is "any
* authenticated user", and a `campaign-token` JWT reaches
* `/api/campaignWebAuthnCredential` directly, so every deny here has to be
* explicit (roadmap B40).
*/
describe('campaignWebAuthnCredential access', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('denies anonymous reads', async () => {
await expect(
payload.find({ collection: 'campaignWebAuthnCredential', overrideAccess: false }),
).rejects.toThrow(/permissão|not allowed/i)
})
it('shows an owner only their own passkeys', async () => {
const fixtures = campaignFixtures()
const [owner, other] = await Promise.all([
fixtures.createCampaignUser('leader'),
fixtures.createCampaignUser('advisor'),
])
const ownCredential = await enrollPasskey(owner.id, `own-${owner.id}`)
await enrollPasskey(other.id, `other-${other.id}`)
const visible = await payload.find({
collection: 'campaignWebAuthnCredential',
depth: 0,
user: owner,
overrideAccess: false,
})
expect(visible.docs.map((doc) => doc.id)).toEqual([ownCredential.id])
})
it('denies the coordinator reading or deleting another account passkey', async () => {
const fixtures = campaignFixtures()
const [owner, coordinator] = await Promise.all([
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('coordinator'),
])
const credential = await enrollPasskey(owner.id, `owner-${owner.id}`)
const visible = await payload.find({
collection: 'campaignWebAuthnCredential',
depth: 0,
user: coordinator,
overrideAccess: false,
})
expect(visible.docs).toHaveLength(0)
await expect(
payload.delete({
collection: 'campaignWebAuthnCredential',
id: credential.id,
user: coordinator,
overrideAccess: false,
}),
).rejects.toThrow(/permissão|not allowed|not found/i)
await expect(
payload.findByID({
collection: 'campaignWebAuthnCredential',
id: credential.id,
depth: 0,
overrideAccess: true,
}),
).resolves.toMatchObject({ id: credential.id })
})
it('lets the owner delete their own passkey', async () => {
const fixtures = campaignFixtures()
const owner = await fixtures.createCampaignUser('leader')
const credential = await enrollPasskey(owner.id, `revoke-${owner.id}`)
await payload.delete({
collection: 'campaignWebAuthnCredential',
id: credential.id,
user: owner,
overrideAccess: false,
})
await expect(
payload.count({
collection: 'campaignWebAuthnCredential',
where: { user: { equals: owner.id } },
overrideAccess: true,
}),
).resolves.toMatchObject({ totalDocs: 0 })
})
it('refuses a client-shaped enrollment even by the owner', async () => {
const fixtures = campaignFixtures()
const owner = await fixtures.createCampaignUser('leader')
await expect(
payload.create({
collection: 'campaignWebAuthnCredential',
data: {
user: owner.id,
credentialId: `forged-${owner.id}`,
publicKey: 'YXR0YWNrZXI',
counter: 0,
deviceLabel: 'Aparelho do atacante',
},
user: owner,
overrideAccess: false,
}),
).rejects.toThrow(/permissão|not allowed/i)
})
it('revokes the passkeys of a deleted account', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const doomed = await fixtures.createCampaignUser('advisor')
await enrollPasskey(doomed.id, `doomed-${doomed.id}`)
await payload.delete({
collection: 'campaignUser',
id: doomed.id,
user: coordinator,
overrideAccess: false,
})
await expect(
payload.count({
collection: 'campaignWebAuthnCredential',
where: { user: { equals: doomed.id } },
overrideAccess: true,
}),
).resolves.toMatchObject({ totalDocs: 0 })
})
})