mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 14:55:16 +00:00
- campaignSupporter: destaca voteIntentionConsent dos supporters antes de purgar o consent canônico de intenção de voto — o seed mínimo cria vínculos vivos nessa chave, e o delete passou a depender do SET NULL silencioso que o guard agora bloqueia (falha determinística em DB fresca de CI). - campaignInvite: purga da chave lideranca-autopreenchimento na janela mutável passa a raw SQL (precedente Onda0/lease) — lifecycle de harness nunca deve passar pelo caminho admin/beforeDelete.
1859 lines
66 KiB
TypeScript
1859 lines
66 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { sql } from '@payloadcms/db-postgres'
|
|
import { getPayload, type Payload } from 'payload'
|
|
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { Consent } from '@/collections/Consent'
|
|
import type { CampaignUser, Consent as ConsentDocument } from '@/payload-types'
|
|
import config from '@/payload.config'
|
|
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
|
import {
|
|
getTestTransactionBackendPID,
|
|
waitForAdvisoryLockWaiter,
|
|
withInviteConsent,
|
|
withMutableConsentFixture,
|
|
} from '../helpers/testDatabaseLease'
|
|
|
|
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
|
|
|
let payload: Payload
|
|
const campaignFixtures = installCampaignFixtures({
|
|
getPayload: () => payload,
|
|
setPayload: (nextPayload) => {
|
|
payload = nextPayload
|
|
},
|
|
})
|
|
const authState = vi.hoisted(() => ({
|
|
user: null as CampaignUser | null,
|
|
}))
|
|
const cookieState = vi.hoisted(() => ({
|
|
set: vi.fn(),
|
|
}))
|
|
const requestHeadersState = vi.hoisted(() => ({
|
|
value: new Headers({ origin: 'http://localhost:3000' }),
|
|
}))
|
|
|
|
vi.mock('@/utilities/campaignAuth', () => ({
|
|
getCampaignUser: async () => authState.user,
|
|
setCampaignAuthCookie: cookieState.set,
|
|
}))
|
|
vi.mock('next/headers', () => ({
|
|
headers: async () => requestHeadersState.value,
|
|
}))
|
|
const resultRows = <Row>(result: unknown): Row[] => {
|
|
if (Array.isArray(result)) return result as Row[]
|
|
if (
|
|
typeof result === 'object' &&
|
|
result !== null &&
|
|
'rows' in result &&
|
|
Array.isArray(result.rows)
|
|
) {
|
|
return result.rows as Row[]
|
|
}
|
|
return []
|
|
}
|
|
|
|
const expectExactAdvisoryWaiter = async (key: string, waiterPID: number) => {
|
|
const waiting = await waitForAdvisoryLockWaiter(payload, {
|
|
key,
|
|
mode: 'ExclusiveLock',
|
|
waiterPID,
|
|
})
|
|
expect(waiting).toMatchObject({
|
|
activityDatabaseOID: waiting.databaseOID,
|
|
classID: waiting.expectedClassID,
|
|
granted: false,
|
|
mode: 'ExclusiveLock',
|
|
objectID: waiting.expectedObjectID,
|
|
objectSubID: 1,
|
|
pid: waiterPID,
|
|
})
|
|
}
|
|
|
|
const expectExactAdvisoryLockGranted = async (key: string, holderPID: number) => {
|
|
const result = await payload.db.drizzle.execute(sql`
|
|
WITH expected AS (
|
|
SELECT
|
|
hashtextextended(${key}, 0) AS "key",
|
|
(SELECT oid FROM pg_database WHERE datname = current_database()) AS "databaseOID"
|
|
)
|
|
SELECT
|
|
activity.datid::integer AS "activityDatabaseOID",
|
|
locks.classid::text AS "classID",
|
|
locks.database::integer AS "databaseOID",
|
|
((expected."key" >> 32) & 4294967295)::text AS "expectedClassID",
|
|
(expected."key" & 4294967295)::text AS "expectedObjectID",
|
|
locks.granted,
|
|
locks.mode,
|
|
locks.objid::text AS "objectID",
|
|
locks.objsubid::integer AS "objectSubID",
|
|
activity.pid::integer AS "pid"
|
|
FROM pg_locks AS locks
|
|
INNER JOIN pg_stat_activity AS activity
|
|
ON activity.pid = locks.pid
|
|
AND activity.datid = locks.database
|
|
CROSS JOIN expected
|
|
WHERE locks.locktype = 'advisory'
|
|
AND locks.database = expected."databaseOID"
|
|
AND locks.classid = ((expected."key" >> 32) & 4294967295)::oid
|
|
AND locks.objid = (expected."key" & 4294967295)::oid
|
|
AND locks.objsubid = 1
|
|
AND locks.mode = 'ExclusiveLock'
|
|
AND locks.pid = ${holderPID}
|
|
AND locks.granted = true
|
|
`)
|
|
const granted = resultRows<{
|
|
activityDatabaseOID: number
|
|
classID: string
|
|
databaseOID: number
|
|
expectedClassID: string
|
|
expectedObjectID: string
|
|
granted: true
|
|
mode: 'ExclusiveLock'
|
|
objectID: string
|
|
objectSubID: 1
|
|
pid: number
|
|
}>(result)[0]
|
|
|
|
expect(granted).toMatchObject({
|
|
activityDatabaseOID: granted?.databaseOID,
|
|
classID: granted?.expectedClassID,
|
|
granted: true,
|
|
mode: 'ExclusiveLock',
|
|
objectID: granted?.expectedObjectID,
|
|
objectSubID: 1,
|
|
pid: holderPID,
|
|
})
|
|
}
|
|
|
|
const withBlockedInviteAction = async <Result>({
|
|
key,
|
|
start,
|
|
whileWaiting,
|
|
}: {
|
|
key: string
|
|
start: () => Promise<Result>
|
|
whileWaiting: (waiterPID: number) => Promise<void>
|
|
}): Promise<Result> => {
|
|
const holderID = await payload.db.beginTransaction()
|
|
if (holderID === null) throw new Error('Expected an advisory-lock holder transaction.')
|
|
await acquireTextAdvisoryLocks(payload, { transactionID: holderID }, [key])
|
|
|
|
const originalBegin = payload.db.beginTransaction.bind(payload.db)
|
|
let actionTransactionID: number | string | undefined
|
|
let resolveWaiterPID!: (pid: number) => void
|
|
let rejectWaiterPID!: (error: unknown) => void
|
|
const waiterPID = new Promise<number>((resolve, reject) => {
|
|
resolveWaiterPID = resolve
|
|
rejectWaiterPID = reject
|
|
})
|
|
const beginSpy = vi.spyOn(payload.db, 'beginTransaction').mockImplementation(async () => {
|
|
try {
|
|
const transactionID = await originalBegin()
|
|
if (transactionID === null) throw new Error('Expected the invite transaction to start.')
|
|
actionTransactionID = transactionID
|
|
resolveWaiterPID(await getTestTransactionBackendPID(payload, transactionID))
|
|
return transactionID
|
|
} catch (error) {
|
|
rejectWaiterPID(error)
|
|
throw error
|
|
}
|
|
})
|
|
|
|
let holderReleased = false
|
|
let pending: Promise<Result> | undefined
|
|
try {
|
|
pending = start()
|
|
const expectedPID = await waiterPID
|
|
await expectExactAdvisoryWaiter(key, expectedPID)
|
|
await whileWaiting(expectedPID)
|
|
await payload.db.rollbackTransaction(holderID)
|
|
holderReleased = true
|
|
return await pending
|
|
} finally {
|
|
beginSpy.mockRestore()
|
|
if (!holderReleased) {
|
|
await payload.db.rollbackTransaction(holderID).catch(() => undefined)
|
|
}
|
|
await pending?.catch(() => undefined)
|
|
if (
|
|
actionTransactionID !== undefined &&
|
|
payload.db.sessions?.[String(actionTransactionID)] !== undefined
|
|
) {
|
|
await payload.db.rollbackTransaction(actionTransactionID).catch(() => undefined)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Builds the owned Contact → Leadership graph required by invite scenarios.
|
|
const createInviteLeadershipGraph = async (
|
|
actor: CampaignUser,
|
|
municipality: number,
|
|
supportStatus: 'engajado' | 'a_abordar' = 'engajado',
|
|
) => {
|
|
const contact = await campaignFixtures().createContact({
|
|
name: campaignFixtures().value('Liderança convite'),
|
|
})
|
|
const leadership = await campaignFixtures().createLeadership({
|
|
contact,
|
|
municipalities: [municipality],
|
|
supportStatus,
|
|
notes: 'Nota interna protegida',
|
|
createdBy: actor,
|
|
})
|
|
return { contact, leadership }
|
|
}
|
|
|
|
// Exercises the authenticated action and returns the derived raw-token metadata.
|
|
const createInviteThroughAction = async (
|
|
actor: CampaignUser,
|
|
input: { leadership: number; kind: 'login' | 'autopreenchimento' },
|
|
) => {
|
|
authState.user = actor
|
|
const [actions, inviteUtilities] = await Promise.all([
|
|
import('@/app/(campaign)/campanha/actions/invite'),
|
|
import('@/utilities/campaignInvite'),
|
|
])
|
|
const result = await actions.createCampaignInvite(input)
|
|
const token = new URL(result.inviteUrl).pathname.split('/').pop()!
|
|
return {
|
|
...result,
|
|
token,
|
|
tokenHash: inviteUtilities.hashCampaignInviteToken(token),
|
|
}
|
|
}
|
|
|
|
// Retained as a scenario builder: recovery tests need the same linked
|
|
// Contact → Leadership → CampaignUser → login-invite graph every time.
|
|
const createLinkedLoginInvite = async (actor: CampaignUser) => {
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { contact, leadership } = await createInviteLeadershipGraph(actor, municipality.id)
|
|
const account = await payload.create({
|
|
collection: 'campaignUser',
|
|
data: {
|
|
name: campaignFixtures().value('Conta vinculada'),
|
|
username: contact.phone,
|
|
phone: contact.phone,
|
|
password: campaignFixtures().value('senha-anterior'),
|
|
role: 'leader',
|
|
},
|
|
depth: 0,
|
|
})
|
|
await payload.update({
|
|
collection: 'leadership',
|
|
id: leadership.id,
|
|
data: { user: account.id },
|
|
depth: 0,
|
|
})
|
|
const invite = await createInviteThroughAction(actor, {
|
|
leadership: leadership.id,
|
|
kind: 'login',
|
|
})
|
|
return { account, contact, invite, leadership }
|
|
}
|
|
|
|
const expectLoginInviteUnchanged = async ({
|
|
accountID,
|
|
accountName,
|
|
contactID,
|
|
contactName,
|
|
inviteHash,
|
|
leadershipID,
|
|
}: {
|
|
accountID: number
|
|
accountName: string
|
|
contactID: number
|
|
contactName: string
|
|
inviteHash: string
|
|
leadershipID: number
|
|
}) => {
|
|
const [account, contact, invite, leadership] = await Promise.all([
|
|
payload.findByID({ collection: 'campaignUser', id: accountID, depth: 0 }),
|
|
payload.findByID({ collection: 'contact', id: contactID, depth: 0 }),
|
|
payload.find({
|
|
collection: 'campaignInvite',
|
|
where: { tokenHash: { equals: inviteHash } },
|
|
depth: 0,
|
|
limit: 1,
|
|
}),
|
|
payload.findByID({ collection: 'leadership', id: leadershipID, depth: 0 }),
|
|
])
|
|
expect(account).toMatchObject({ id: accountID, name: accountName })
|
|
expect(contact).toMatchObject({ id: contactID, name: contactName })
|
|
expect(invite.docs[0]).toMatchObject({ usedAt: null })
|
|
expect(leadership).toMatchObject({ user: accountID })
|
|
}
|
|
|
|
const itWithMutableConsent = (name: string, test: (consent: ConsentDocument) => Promise<void>) =>
|
|
it(name, () => withMutableConsentFixture(payload, test))
|
|
const itWithInviteConsent = (name: string, test: (consent: ConsentDocument) => Promise<void>) =>
|
|
it(name, () => withInviteConsent(payload, test))
|
|
|
|
describe('campaign invite domain', () => {
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config: await config })
|
|
})
|
|
|
|
it('exposes an optional unique stable key on consent documents', () => {
|
|
const keyField = Consent.fields.find((field) => 'name' in field && field.name === 'key')
|
|
|
|
expect(keyField).toMatchObject({
|
|
name: 'key',
|
|
type: 'text',
|
|
unique: true,
|
|
index: true,
|
|
required: false,
|
|
})
|
|
})
|
|
|
|
it('declares secure invite fields and hides the persisted token hash', async () => {
|
|
const inviteCollection = await import('@/collections/CampaignInvite').catch(() => null)
|
|
|
|
expect(inviteCollection).not.toBeNull()
|
|
const fields = inviteCollection!.CampaignInvite.fields
|
|
const tokenHash = fields.find((field) => 'name' in field && field.name === 'tokenHash')
|
|
|
|
expect(inviteCollection!.CampaignInvite).toMatchObject({
|
|
slug: 'campaignInvite',
|
|
admin: { group: 'Campanha' },
|
|
})
|
|
expect(tokenHash).toMatchObject({
|
|
type: 'text',
|
|
unique: true,
|
|
index: true,
|
|
admin: { hidden: true },
|
|
})
|
|
})
|
|
|
|
it('declares protected consent version acceptance fields on leaderships', async () => {
|
|
const leadershipCollection = await import('@/collections/Leadership')
|
|
const fields = leadershipCollection.Leadership.fields
|
|
const consentContentHash = fields.find(
|
|
(field) => 'name' in field && field.name === 'consentContentHash',
|
|
)
|
|
const consentedAt = fields.find((field) => 'name' in field && field.name === 'consentedAt')
|
|
|
|
expect(consentContentHash).toMatchObject({
|
|
type: 'text',
|
|
label: 'Hash do conteúdo consentido',
|
|
admin: { readOnly: true },
|
|
access: {
|
|
create: expect.any(Function),
|
|
update: expect.any(Function),
|
|
},
|
|
})
|
|
expect(consentedAt).toMatchObject({
|
|
type: 'date',
|
|
label: 'Consentimento confirmado em',
|
|
admin: { readOnly: true },
|
|
access: {
|
|
create: expect.any(Function),
|
|
update: expect.any(Function),
|
|
},
|
|
})
|
|
})
|
|
|
|
it('generates a 32-byte token and stores only its SHA-256 digest', async () => {
|
|
const inviteUtilities = await import('@/utilities/campaignInvite').catch(() => null)
|
|
|
|
expect(inviteUtilities).not.toBeNull()
|
|
const generated = inviteUtilities!.generateCampaignInviteToken()
|
|
|
|
expect(Buffer.from(generated.token, 'base64url')).toHaveLength(32)
|
|
expect(generated.tokenHash).toMatch(/^[a-f0-9]{64}$/)
|
|
expect(generated.tokenHash).toBe(inviteUtilities!.hashCampaignInviteToken(generated.token))
|
|
expect(generated.tokenHash).not.toContain(generated.token)
|
|
})
|
|
|
|
it('hashes equivalent consent content deterministically with recursive key ordering', async () => {
|
|
const utility = await import('@/utilities/consentContentHash').catch(() => null)
|
|
|
|
expect(utility).not.toBeNull()
|
|
const knownHash = utility!.hashConsentContent({
|
|
root: { children: [{ text: 'Aceito', version: 1 }], type: 'root' },
|
|
})
|
|
expect(knownHash).toBe('c0581788cb6892c8781835988d9b8261cab05db10a3f67c9f8ce0262393ac0e8')
|
|
expect(knownHash).toBe(
|
|
utility!.hashConsentContent({
|
|
root: { type: 'root', children: [{ version: 1, text: 'Aceito' }] },
|
|
}),
|
|
)
|
|
expect(
|
|
utility!.hashConsentContent({
|
|
root: { children: [{ text: 'Texto alterado', version: 1 }], type: 'root' },
|
|
}),
|
|
).not.toBe(
|
|
utility!.hashConsentContent({
|
|
root: { children: [{ text: 'Aceito', version: 1 }], type: 'root' },
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('accepts only an exact HTTPS non-local production origin', async () => {
|
|
const inviteUtilities = await import('@/utilities/campaignInviteOrigin')
|
|
const production = { environment: 'production' as const }
|
|
|
|
expect(
|
|
inviteUtilities.getCampaignInviteBaseURL({
|
|
...production,
|
|
configuredURL: 'https://campanha.example.org/',
|
|
}),
|
|
).toBe('https://campanha.example.org')
|
|
|
|
const rejectedProductionURLs = [
|
|
undefined,
|
|
'not-an-absolute-url',
|
|
'http://campanha.example.org',
|
|
'https://localhost:3000',
|
|
'https://127.0.0.1',
|
|
'https://[::1]',
|
|
'https://user@campanha.example.org',
|
|
'https://user:password@campanha.example.org',
|
|
'https://campanha.example.org/base',
|
|
'https://campanha.example.org/?preview=1',
|
|
'https://campanha.example.org/#fragment',
|
|
'https://localhost.attacker.example',
|
|
'https://attacker.localhost',
|
|
'https://127.0.0.1.attacker.example',
|
|
] as const
|
|
|
|
for (const configuredURL of rejectedProductionURLs) {
|
|
expect(
|
|
() => inviteUtilities.getCampaignInviteBaseURL({ ...production, configuredURL }),
|
|
String(configuredURL),
|
|
).toThrow()
|
|
}
|
|
})
|
|
|
|
it('accepts exact local request origins and normalizes them to HTTP outside production', async () => {
|
|
const inviteUtilities = await import('@/utilities/campaignInviteOrigin')
|
|
|
|
const localCases = [
|
|
{
|
|
input: { requestOrigin: 'http://localhost:3000' },
|
|
expected: 'http://localhost:3000',
|
|
},
|
|
{
|
|
input: { requestOrigin: 'https://LOCALHOST:3443' },
|
|
expected: 'http://localhost:3443',
|
|
},
|
|
{
|
|
input: { requestOrigin: 'http://127.0.0.1:3001' },
|
|
expected: 'http://127.0.0.1:3001',
|
|
},
|
|
{
|
|
input: { requestOrigin: 'https://[::1]:3443' },
|
|
expected: 'http://[::1]:3443',
|
|
},
|
|
{
|
|
input: { forwardedHost: '[::1]:3002', forwardedProto: 'https' },
|
|
expected: 'http://[::1]:3002',
|
|
},
|
|
] as const
|
|
|
|
for (const { input, expected } of localCases) {
|
|
expect(
|
|
inviteUtilities.getCampaignInviteBaseURL({
|
|
environment: 'development',
|
|
configuredURL: undefined,
|
|
...input,
|
|
}),
|
|
).toBe(expected)
|
|
}
|
|
|
|
expect(
|
|
inviteUtilities.getCampaignInviteBaseURL({
|
|
environment: 'development',
|
|
configuredURL: undefined,
|
|
requestOrigin: 'https://localhost:3443',
|
|
allowLocalTLS: true,
|
|
}),
|
|
).toBe('https://localhost:3443')
|
|
})
|
|
|
|
it('rejects hostile request-derived origins and uses only an explicit valid fallback', async () => {
|
|
const inviteUtilities = await import('@/utilities/campaignInviteOrigin')
|
|
const hostileRequestInputs = [
|
|
{ requestOrigin: 'https://attacker.example' },
|
|
{ requestOrigin: 'http://localhost.attacker.example:3000' },
|
|
{ requestOrigin: 'http://127.0.0.2:3000' },
|
|
{ requestOrigin: 'http://0.0.0.0:3000' },
|
|
{ requestOrigin: 'http://user@localhost:3000' },
|
|
{ requestOrigin: 'http://localhost:0' },
|
|
{ requestOrigin: 'http://localhost:3000, https://attacker.example' },
|
|
{ requestOrigin: 'http://localhost:3000\r\nx-evil: injected' },
|
|
{ forwardedHost: 'attacker.example', forwardedProto: 'https' },
|
|
{ forwardedHost: 'localhost.attacker.example:3000', forwardedProto: 'http' },
|
|
{ forwardedHost: 'localhost:3000,attacker.example', forwardedProto: 'http' },
|
|
{ forwardedHost: 'localhost:3000\r\nx-evil: injected', forwardedProto: 'http' },
|
|
{ forwardedHost: 'localhost:3000', forwardedProto: 'http,https' },
|
|
{ forwardedHost: 'user@localhost:3000', forwardedProto: 'http' },
|
|
] as const
|
|
|
|
for (const hostileInput of hostileRequestInputs) {
|
|
expect(
|
|
inviteUtilities.getCampaignInviteBaseURL({
|
|
environment: 'test',
|
|
configuredURL: 'https://dev-campaign.example.org/',
|
|
...hostileInput,
|
|
}),
|
|
).toBe('https://dev-campaign.example.org')
|
|
expect(() =>
|
|
inviteUtilities.getCampaignInviteBaseURL({
|
|
environment: 'test',
|
|
configuredURL: undefined,
|
|
...hostileInput,
|
|
}),
|
|
).toThrow('origem segura')
|
|
}
|
|
})
|
|
|
|
it('fails closed without leaking invite tokens in origin errors or logs', async () => {
|
|
const inviteUtilities = await import('@/utilities/campaignInviteOrigin')
|
|
const token = 'secret-invite-token-that-must-not-leak'
|
|
const consoleSpies = [
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined),
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined),
|
|
vi.spyOn(console, 'log').mockImplementation(() => undefined),
|
|
]
|
|
|
|
let thrown: unknown
|
|
try {
|
|
inviteUtilities.getCampaignInviteBaseURL({
|
|
environment: 'development',
|
|
configuredURL: undefined,
|
|
requestOrigin: `https://${token}.attacker.example`,
|
|
})
|
|
} catch (error) {
|
|
thrown = error
|
|
}
|
|
|
|
expect(thrown).toBeInstanceOf(Error)
|
|
expect(String(thrown)).not.toContain(token)
|
|
expect(consoleSpies.flatMap((spy) => spy.mock.calls).join(' ')).not.toContain(token)
|
|
for (const spy of consoleSpies) spy.mockRestore()
|
|
})
|
|
|
|
it('ignores all request headers in production', async () => {
|
|
const inviteUtilities = await import('@/utilities/campaignInviteOrigin')
|
|
|
|
expect(
|
|
inviteUtilities.getCampaignInviteBaseURL({
|
|
environment: 'production',
|
|
configuredURL: 'https://campanha.example.org/',
|
|
requestOrigin: 'https://attacker.example',
|
|
forwardedHost: 'evil.vercel.app',
|
|
forwardedProto: 'https',
|
|
}),
|
|
).toBe('https://campanha.example.org')
|
|
})
|
|
|
|
it('builds exact decoded WhatsApp messages for both invite kinds', async () => {
|
|
const inviteUtilities = await import('@/utilities/campaignInvite').catch(() => null)
|
|
|
|
expect(inviteUtilities).not.toBeNull()
|
|
const input = {
|
|
phone: '(71) 99999-0000',
|
|
recipientName: 'Maria',
|
|
senderName: 'João',
|
|
inviteUrl: 'https://example.com/campanha/convite/a+b',
|
|
}
|
|
const expectations = {
|
|
autopreenchimento:
|
|
'Oi Maria, aqui é João da campanha do Solla. Complete e confirme seu cadastro neste link: https://example.com/campanha/convite/a+b',
|
|
login:
|
|
'Oi Maria, aqui é João da campanha do Solla. Crie ou recupere seu acesso à plataforma neste link: https://example.com/campanha/convite/a+b',
|
|
} as const
|
|
|
|
for (const kind of ['autopreenchimento', 'login'] as const) {
|
|
const url = new URL(inviteUtilities!.buildCampaignInviteWhatsAppLink({ ...input, kind }))
|
|
expect(url.origin).toBe('https://wa.me')
|
|
expect(url.pathname).toBe('/5571999990000')
|
|
expect(url.searchParams.get('text')).toBe(expectations[kind])
|
|
}
|
|
})
|
|
|
|
it('strips every non-whitelisted field from self-service submissions', async () => {
|
|
const inviteSchemas = await import('@/lib/schemas/invite').catch(() => null)
|
|
|
|
expect(inviteSchemas).not.toBeNull()
|
|
const parsed = inviteSchemas!.campaignInviteAutofillSchema.parse({
|
|
token: 'raw-token-long-enough-for-validation',
|
|
name: ' Maria da Silva ',
|
|
phone: '+55 (71) 99999-0000',
|
|
email: 'maria@example.com',
|
|
gender: 'feminino',
|
|
sector: 'comunitario',
|
|
sectorNotes: 'forjado',
|
|
consentAccepted: true,
|
|
leadership: 999,
|
|
supportStatus: 'engajado',
|
|
notes: 'forged',
|
|
municipalities: [999],
|
|
user: 999,
|
|
consent: 999,
|
|
})
|
|
|
|
expect(parsed).toEqual({
|
|
token: 'raw-token-long-enough-for-validation',
|
|
name: 'Maria da Silva',
|
|
phone: '71999990000',
|
|
email: 'maria@example.com',
|
|
gender: 'feminino',
|
|
consentAccepted: true,
|
|
})
|
|
})
|
|
|
|
itWithMutableConsent(
|
|
'fails safely when the stable consent document is not configured',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite').catch(() => null)
|
|
expect(actions).not.toBeNull()
|
|
|
|
// Harness purge (Onda0-provision / lease precedent): raw SQL so the
|
|
// admin-path beforeDelete guard and access checks never apply to this
|
|
// lifecycle setup — the window lease already guarantees isolation.
|
|
await payload.db.drizzle.execute(
|
|
sql`DELETE FROM "consent" WHERE "key" = ${'lideranca-autopreenchimento'}`,
|
|
)
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
|
|
await expect(
|
|
createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
}),
|
|
).rejects.toThrow('Consentimento ainda não configurado')
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'limits creation and reads to the coordinator or the assigned advisor scope',
|
|
async () => {
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const assigned = await campaignFixtures().createCampaignUser('advisor')
|
|
const outsider = await campaignFixtures().createCampaignUser('advisor')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
await campaignFixtures().assignMunicipalityAdvisors(municipality, [assigned])
|
|
const { leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
|
|
await expect(
|
|
createInviteThroughAction(outsider, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
}),
|
|
).rejects.toThrow()
|
|
const created = await createInviteThroughAction(assigned, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
|
|
const assignedRead = await payload.find({
|
|
collection: 'campaignInvite',
|
|
user: assigned,
|
|
overrideAccess: false,
|
|
depth: 0,
|
|
})
|
|
const outsiderRead = await payload.find({
|
|
collection: 'campaignInvite',
|
|
user: outsider,
|
|
overrideAccess: false,
|
|
depth: 0,
|
|
})
|
|
|
|
expect(assignedRead.totalDocs).toBeGreaterThan(0)
|
|
expect(assignedRead.docs[0]).not.toHaveProperty('tokenHash')
|
|
expect(outsiderRead.totalDocs).toBe(0)
|
|
const rawTokenLookup = await payload.find({
|
|
collection: 'campaignInvite',
|
|
where: { tokenHash: { equals: created.token } },
|
|
depth: 0,
|
|
})
|
|
expect(rawTokenLookup.totalDocs).toBe(0)
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'denies invite creation to leader accounts with the coordination-only message',
|
|
async () => {
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const leaderAccount = await campaignFixtures().createCampaignUser('leader')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
|
|
await expect(
|
|
createInviteThroughAction(leaderAccount, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
}),
|
|
).rejects.toThrow('Somente a coordenação pode criar convites.')
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'revokes the prior active invite and rejects expired or revoked tokens',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
|
|
const first = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
const second = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
|
|
await expect(
|
|
actions.redeemCampaignInviteAutofill({
|
|
token: first.token,
|
|
name: 'Maria Revogada',
|
|
phone: campaignFixtures().phone(),
|
|
consentAccepted: true,
|
|
}),
|
|
).rejects.toThrow('Convite inválido ou expirado')
|
|
|
|
const secondInvite = await payload.find({
|
|
collection: 'campaignInvite',
|
|
where: { tokenHash: { equals: second.tokenHash } },
|
|
depth: 0,
|
|
limit: 1,
|
|
})
|
|
await payload.update({
|
|
collection: 'campaignInvite',
|
|
id: secondInvite.docs[0]!.id,
|
|
data: { expiresAt: new Date(Date.now() - 1000).toISOString() },
|
|
})
|
|
await expect(
|
|
actions.redeemCampaignInviteAutofill({
|
|
token: second.token,
|
|
name: 'Maria Expirada',
|
|
phone: campaignFixtures().phone(),
|
|
consentAccepted: true,
|
|
}),
|
|
).rejects.toThrow('Convite inválido ou expirado')
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'waits on the exact invite-creation key before revoking or creating an invite',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
const first = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
authState.user = coordinator
|
|
const key = `invite-creation:${leadership.id}:autopreenchimento`
|
|
|
|
await expect(
|
|
withBlockedInviteAction({
|
|
key,
|
|
start: () =>
|
|
actions.createCampaignInvite({
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
}),
|
|
whileWaiting: async () => {
|
|
const invites = await payload.find({
|
|
collection: 'campaignInvite',
|
|
where: { leadership: { equals: leadership.id } },
|
|
depth: 0,
|
|
pagination: false,
|
|
})
|
|
expect(invites.docs).toHaveLength(1)
|
|
expect(invites.docs[0]).toMatchObject({
|
|
tokenHash: first.tokenHash,
|
|
revokedAt: null,
|
|
usedAt: null,
|
|
})
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
inviteUrl: expect.stringContaining('/campanha/convite/'),
|
|
whatsappUrl: expect.stringContaining('https://wa.me/'),
|
|
})
|
|
|
|
const invites = await payload.find({
|
|
collection: 'campaignInvite',
|
|
where: { leadership: { equals: leadership.id } },
|
|
depth: 0,
|
|
pagination: false,
|
|
})
|
|
expect(invites.docs).toHaveLength(2)
|
|
expect(invites.docs.filter((invite) => invite.revokedAt === null)).toHaveLength(1)
|
|
expect(
|
|
invites.docs.find((invite) => invite.tokenHash === first.tokenHash)?.revokedAt,
|
|
).not.toBe(null)
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'waits on the exact contact-ficha key before login redemption mutates data',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const fixture = await createLinkedLoginInvite(coordinator)
|
|
const key = `contact-ficha:${fixture.contact.id}`
|
|
const originalAccountName = fixture.account.name
|
|
const originalContactName = fixture.contact.name
|
|
|
|
await expect(
|
|
withBlockedInviteAction({
|
|
key,
|
|
start: () =>
|
|
actions.redeemCampaignInviteLogin({
|
|
token: fixture.invite.token,
|
|
name: campaignFixtures().value('Nome após contato'),
|
|
phone: fixture.contact.phone,
|
|
password: campaignFixtures().value('senha-nova'),
|
|
consentAccepted: true,
|
|
}),
|
|
whileWaiting: async () => {
|
|
await expectLoginInviteUnchanged({
|
|
accountID: fixture.account.id,
|
|
accountName: originalAccountName,
|
|
contactID: fixture.contact.id,
|
|
contactName: originalContactName,
|
|
inviteHash: fixture.invite.tokenHash,
|
|
leadershipID: fixture.leadership.id,
|
|
})
|
|
},
|
|
}),
|
|
).resolves.toEqual({ ok: true })
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'waits on the exact account-username key before login redemption mutates data',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const fixture = await createLinkedLoginInvite(coordinator)
|
|
const key = `account-username:${fixture.contact.phone}`
|
|
|
|
await expect(
|
|
withBlockedInviteAction({
|
|
key,
|
|
start: () =>
|
|
actions.redeemCampaignInviteLogin({
|
|
token: fixture.invite.token,
|
|
name: campaignFixtures().value('Nome após username'),
|
|
phone: fixture.contact.phone,
|
|
password: campaignFixtures().value('senha-nova'),
|
|
consentAccepted: true,
|
|
}),
|
|
whileWaiting: async () => {
|
|
await expectLoginInviteUnchanged({
|
|
accountID: fixture.account.id,
|
|
accountName: fixture.account.name,
|
|
contactID: fixture.contact.id,
|
|
contactName: fixture.contact.name,
|
|
inviteHash: fixture.invite.tokenHash,
|
|
leadershipID: fixture.leadership.id,
|
|
})
|
|
},
|
|
}),
|
|
).resolves.toEqual({ ok: true })
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'orders all login redemption keys before waiting on the exact contact-ficha key',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const fixture = await createLinkedLoginInvite(coordinator)
|
|
const usernameKey = `account-username:${fixture.contact.phone}`
|
|
const contactPhoneKey = `contact-phone:${fixture.contact.phone}`
|
|
const userKey = `invite-redemption-user:${fixture.account.id}`
|
|
const fichaKey = `contact-ficha:${fixture.contact.id}`
|
|
|
|
await expect(
|
|
withBlockedInviteAction({
|
|
key: fichaKey,
|
|
start: () =>
|
|
actions.redeemCampaignInviteLogin({
|
|
token: fixture.invite.token,
|
|
name: campaignFixtures().value('Nome após usuário'),
|
|
phone: fixture.contact.phone,
|
|
password: campaignFixtures().value('senha-nova'),
|
|
consentAccepted: true,
|
|
}),
|
|
whileWaiting: async (waiterPID) => {
|
|
await Promise.all([
|
|
expectExactAdvisoryLockGranted(usernameKey, waiterPID),
|
|
expectExactAdvisoryLockGranted(contactPhoneKey, waiterPID),
|
|
expectExactAdvisoryLockGranted(userKey, waiterPID),
|
|
])
|
|
await expectLoginInviteUnchanged({
|
|
accountID: fixture.account.id,
|
|
accountName: fixture.account.name,
|
|
contactID: fixture.contact.id,
|
|
contactName: fixture.contact.name,
|
|
inviteHash: fixture.invite.tokenHash,
|
|
leadershipID: fixture.leadership.id,
|
|
})
|
|
},
|
|
}),
|
|
).resolves.toEqual({ ok: true })
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'waits on the exact contact-ficha key before autofill redemption mutates data',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { contact, leadership } = await createInviteLeadershipGraph(
|
|
coordinator,
|
|
municipality.id,
|
|
)
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
const key = `contact-ficha:${contact.id}`
|
|
const originalContactName = contact.name
|
|
|
|
await expect(
|
|
withBlockedInviteAction({
|
|
key,
|
|
start: () =>
|
|
actions.redeemCampaignInviteAutofill({
|
|
token: invite.token,
|
|
name: campaignFixtures().value('Nome após contato'),
|
|
phone: contact.phone,
|
|
consentAccepted: true,
|
|
}),
|
|
whileWaiting: async () => {
|
|
const unchanged = await payload.findByID({
|
|
collection: 'contact',
|
|
id: contact.id,
|
|
depth: 0,
|
|
})
|
|
expect(unchanged.name).toBe(originalContactName)
|
|
},
|
|
}),
|
|
).resolves.toEqual({ ok: true })
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'consumes an autofill invite once under concurrent redemption and writes only the whitelist',
|
|
async (consent) => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { contact, leadership } = await createInviteLeadershipGraph(
|
|
coordinator,
|
|
municipality.id,
|
|
)
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
const submission = {
|
|
token: invite.token,
|
|
name: 'Maria Confirmada',
|
|
phone: campaignFixtures().phone(),
|
|
email: 'confirmada@example.com',
|
|
gender: 'feminino' as const,
|
|
consentAccepted: true as const,
|
|
supportStatus: 'negativo',
|
|
notes: 'tentativa de sobrescrita',
|
|
municipalities: [999],
|
|
user: 999,
|
|
}
|
|
|
|
const results = await Promise.allSettled([
|
|
actions.redeemCampaignInviteAutofill(submission),
|
|
actions.redeemCampaignInviteAutofill(submission),
|
|
])
|
|
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
|
|
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
|
|
|
|
const updatedContact = await payload.findByID({
|
|
collection: 'contact',
|
|
id: contact.id,
|
|
depth: 0,
|
|
})
|
|
const updatedLeadership = await payload.findByID({
|
|
collection: 'leadership',
|
|
id: leadership.id,
|
|
depth: 0,
|
|
})
|
|
expect(updatedContact).toMatchObject({
|
|
name: 'Maria Confirmada',
|
|
phones: expect.arrayContaining([expect.objectContaining({ value: submission.phone })]),
|
|
email: 'confirmada@example.com',
|
|
gender: 'feminino',
|
|
})
|
|
expect(updatedLeadership).toMatchObject({
|
|
supportStatus: 'engajado',
|
|
notes: 'Nota interna protegida',
|
|
municipalities: [municipality.id],
|
|
consent: consent.id,
|
|
})
|
|
expect(updatedLeadership.user).toBeNull()
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'waits on the exact contact-phone namespace before an invite changes a phone',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { contact, leadership } = await createInviteLeadershipGraph(
|
|
coordinator,
|
|
municipality.id,
|
|
)
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
const targetPhone = campaignFixtures().phone()
|
|
const holderID = await payload.db.beginTransaction()
|
|
if (holderID === null) throw new Error('Expected a PostgreSQL transaction.')
|
|
const holderReq = { transactionID: holderID }
|
|
await acquireTextAdvisoryLocks(payload, holderReq, [`contact-phone:${targetPhone}`])
|
|
|
|
const originalBegin = payload.db.beginTransaction.bind(payload.db)
|
|
let resolveWaiterPID!: (pid: number) => void
|
|
const waiterPID = new Promise<number>((resolve) => {
|
|
resolveWaiterPID = resolve
|
|
})
|
|
const beginSpy = vi.spyOn(payload.db, 'beginTransaction').mockImplementation(async () => {
|
|
const transactionID = await originalBegin()
|
|
if (transactionID === null) throw new Error('Expected the invite transaction to start.')
|
|
resolveWaiterPID(await getTestTransactionBackendPID(payload, transactionID))
|
|
return transactionID
|
|
})
|
|
const pending = actions.redeemCampaignInviteAutofill({
|
|
token: invite.token,
|
|
name: contact.name,
|
|
phone: targetPhone,
|
|
consentAccepted: true,
|
|
})
|
|
|
|
try {
|
|
const expectedPID = await waiterPID
|
|
const waiting = await waitForAdvisoryLockWaiter(payload, {
|
|
key: `contact-phone:${targetPhone}`,
|
|
mode: 'ExclusiveLock',
|
|
waiterPID: expectedPID,
|
|
})
|
|
expect(waiting).toMatchObject({
|
|
classID: waiting.expectedClassID,
|
|
granted: false,
|
|
objectID: waiting.expectedObjectID,
|
|
objectSubID: 1,
|
|
pid: expectedPID,
|
|
})
|
|
await payload.db.rollbackTransaction(holderID)
|
|
await expect(pending).resolves.toEqual({ ok: true })
|
|
} catch (error) {
|
|
await payload.db.rollbackTransaction(holderID).catch(() => undefined)
|
|
throw error
|
|
} finally {
|
|
beginSpy.mockRestore()
|
|
}
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'shares a phone with another ficha on autofill and keeps the invite consumed (C111)',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const target = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
const owner = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: target.leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
|
|
// C111 — the ficha the invite anchors is known; the typed phone may
|
|
// legitimately match another ficha's, and the write shares it.
|
|
await expect(
|
|
actions.redeemCampaignInviteAutofill({
|
|
token: invite.token,
|
|
name: target.contact.name,
|
|
phone: owner.contact.phone,
|
|
consentAccepted: true,
|
|
}),
|
|
).resolves.toEqual({ ok: true })
|
|
|
|
await expect(
|
|
payload.findByID({ collection: 'contact', id: target.contact.id, depth: 0 }),
|
|
).resolves.toMatchObject({
|
|
phones: expect.arrayContaining([expect.objectContaining({ value: owner.contact.phone })]),
|
|
})
|
|
await expect(
|
|
payload.findByID({ collection: 'contact', id: owner.contact.id, depth: 0 }),
|
|
).resolves.toMatchObject({
|
|
phones: expect.arrayContaining([expect.objectContaining({ value: owner.contact.phone })]),
|
|
})
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'lets two concurrent autofills share one phone and consumes both invites (C111)',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const firstMunicipality = await campaignFixtures().getMunicipality()
|
|
const secondMunicipality = await campaignFixtures().getMunicipality()
|
|
const first = await createInviteLeadershipGraph(coordinator, firstMunicipality.id)
|
|
const second = await createInviteLeadershipGraph(coordinator, secondMunicipality.id)
|
|
const firstInvite = await createInviteThroughAction(coordinator, {
|
|
leadership: first.leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
const secondInvite = await createInviteThroughAction(coordinator, {
|
|
leadership: second.leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
const targetPhone = campaignFixtures().phone()
|
|
const submissions = [
|
|
{
|
|
token: firstInvite.token,
|
|
name: first.contact.name,
|
|
phone: targetPhone,
|
|
consentAccepted: true as const,
|
|
},
|
|
{
|
|
token: secondInvite.token,
|
|
name: second.contact.name,
|
|
phone: targetPhone,
|
|
consentAccepted: true as const,
|
|
},
|
|
]
|
|
|
|
const results = await Promise.allSettled(
|
|
submissions.map((submission) => actions.redeemCampaignInviteAutofill(submission)),
|
|
)
|
|
// C111 — the phone is a contact channel: both invites may share it.
|
|
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(2)
|
|
|
|
const contacts = await Promise.all(
|
|
[first.contact.id, second.contact.id].map((id) =>
|
|
payload.findByID({ collection: 'contact', id, depth: 0 }),
|
|
),
|
|
)
|
|
expect(
|
|
contacts.every((contact) =>
|
|
(contact.phones ?? []).some((entry) => entry.value === targetPhone),
|
|
),
|
|
).toBe(true)
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'completes opposite phone swaps without deadlock and shares the numbers (C111)',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const first = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
const second = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
const firstInvite = await createInviteThroughAction(coordinator, {
|
|
leadership: first.leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
const secondInvite = await createInviteThroughAction(coordinator, {
|
|
leadership: second.leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
|
|
const results = await Promise.allSettled([
|
|
actions.redeemCampaignInviteAutofill({
|
|
token: firstInvite.token,
|
|
name: first.contact.name,
|
|
phone: second.contact.phone,
|
|
consentAccepted: true,
|
|
}),
|
|
actions.redeemCampaignInviteAutofill({
|
|
token: secondInvite.token,
|
|
name: second.contact.name,
|
|
phone: first.contact.phone,
|
|
consentAccepted: true,
|
|
}),
|
|
])
|
|
// Both write the other's number (shared channel) — the ordered locks
|
|
// keep the swap deadlock-free instead of serializing exclusivity.
|
|
expect(results.every(({ status }) => status === 'fulfilled')).toBe(true)
|
|
const [firstContact, secondContact] = await Promise.all([
|
|
payload.findByID({ collection: 'contact', id: first.contact.id, depth: 0 }),
|
|
payload.findByID({ collection: 'contact', id: second.contact.id, depth: 0 }),
|
|
])
|
|
expect(firstContact.phones?.map((entry) => entry.value)).toEqual(
|
|
expect.arrayContaining([second.contact.phone]),
|
|
)
|
|
expect(secondContact.phones?.map((entry) => entry.value)).toEqual(
|
|
expect.arrayContaining([first.contact.phone]),
|
|
)
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'persists explicit optional-field clears and leaves absent fields unchanged',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const cleared = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
await payload.update({
|
|
collection: 'contact',
|
|
id: cleared.contact.id,
|
|
data: { email: 'antes@example.com', gender: 'feminino' },
|
|
})
|
|
const clearInvite = await createInviteThroughAction(coordinator, {
|
|
leadership: cleared.leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
|
|
await actions.redeemCampaignInviteAutofill({
|
|
token: clearInvite.token,
|
|
name: cleared.contact.name,
|
|
phone: cleared.contact.phone,
|
|
email: '',
|
|
gender: '',
|
|
consentAccepted: true,
|
|
})
|
|
|
|
await expect(
|
|
payload.findByID({ collection: 'contact', id: cleared.contact.id, depth: 0 }),
|
|
).resolves.toMatchObject({ email: null, gender: null })
|
|
|
|
const unchanged = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
await payload.update({
|
|
collection: 'contact',
|
|
id: unchanged.contact.id,
|
|
data: { email: 'mantido@example.com', gender: 'masculino' },
|
|
})
|
|
const unchangedInvite = await createInviteThroughAction(coordinator, {
|
|
leadership: unchanged.leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
|
|
await actions.redeemCampaignInviteAutofill({
|
|
token: unchangedInvite.token,
|
|
name: unchanged.contact.name,
|
|
phone: unchanged.contact.phone,
|
|
consentAccepted: true,
|
|
})
|
|
|
|
await expect(
|
|
payload.findByID({ collection: 'contact', id: unchanged.contact.id, depth: 0 }),
|
|
).resolves.toMatchObject({ email: 'mantido@example.com', gender: 'masculino' })
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'allows login only for engaged leadership and reuses accounts for recovery without reducing role',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const engaged = await createInviteLeadershipGraph(coordinator, municipality.id, 'engajado')
|
|
const inactive = await createInviteLeadershipGraph(coordinator, municipality.id, 'a_abordar')
|
|
|
|
await expect(
|
|
createInviteThroughAction(coordinator, {
|
|
leadership: inactive.leadership.id,
|
|
kind: 'login',
|
|
}),
|
|
).rejects.toThrow('engajada')
|
|
|
|
const existingCoordinator = await payload.create({
|
|
collection: 'campaignUser',
|
|
data: {
|
|
name: 'Conta existente',
|
|
username: engaged.contact.phone,
|
|
password: 'senha-antiga',
|
|
role: 'coordinator',
|
|
},
|
|
})
|
|
await payload.update({
|
|
collection: 'leadership',
|
|
id: engaged.leadership.id,
|
|
data: { user: existingCoordinator.id },
|
|
depth: 0,
|
|
})
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: engaged.leadership.id,
|
|
kind: 'login',
|
|
})
|
|
const recoveredPassword = ' senha-nova-segura '
|
|
await actions.redeemCampaignInviteLogin({
|
|
token: invite.token,
|
|
name: 'Conta recuperada',
|
|
phone: engaged.contact.phone,
|
|
password: recoveredPassword,
|
|
consentAccepted: true,
|
|
})
|
|
|
|
const reused = await payload.findByID({
|
|
collection: 'campaignUser',
|
|
id: existingCoordinator.id,
|
|
depth: 0,
|
|
})
|
|
const linked = await payload.findByID({
|
|
collection: 'leadership',
|
|
id: engaged.leadership.id,
|
|
depth: 0,
|
|
})
|
|
const inactivePersisted = await payload.findByID({
|
|
collection: 'leadership',
|
|
id: inactive.leadership.id,
|
|
depth: 0,
|
|
})
|
|
expect(reused.role).toBe('coordinator')
|
|
expect(reused.phone).toBe(engaged.contact.phone)
|
|
expect(linked.user).toBe(existingCoordinator.id)
|
|
expect(inactivePersisted.user).toBeNull()
|
|
await expect(
|
|
payload.login({
|
|
collection: 'campaignUser',
|
|
data: { username: engaged.contact.phone, password: recoveredPassword },
|
|
}),
|
|
).resolves.toHaveProperty('token')
|
|
await expect(
|
|
payload.login({
|
|
collection: 'campaignUser',
|
|
data: { username: engaged.contact.phone, password: recoveredPassword.trim() },
|
|
}),
|
|
).rejects.toThrow()
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'requires missing consent for login and rolls back consumption on rejection',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { contact, leadership } = await createInviteLeadershipGraph(
|
|
coordinator,
|
|
municipality.id,
|
|
)
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'login',
|
|
})
|
|
const submission = {
|
|
token: invite.token,
|
|
name: contact.name,
|
|
phone: contact.phone,
|
|
password: 'senha-segura-nova',
|
|
}
|
|
|
|
await expect(actions.redeemCampaignInviteLogin(submission)).rejects.toThrow(
|
|
'aceitar o consentimento',
|
|
)
|
|
await expect(
|
|
actions.redeemCampaignInviteLogin({
|
|
...submission,
|
|
consentAccepted: true,
|
|
}),
|
|
).resolves.toEqual({ ok: true })
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'requires new acceptance before replacing an older consent during recovery',
|
|
async (configuredConsent) => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const oldConsent = await campaignFixtures().createConsent({
|
|
key: null,
|
|
text: {
|
|
root: {
|
|
type: 'root',
|
|
children: [
|
|
{
|
|
type: 'paragraph',
|
|
children: [{ type: 'text', text: 'Consentimento anterior', version: 1 }],
|
|
direction: null,
|
|
format: '',
|
|
indent: 0,
|
|
version: 1,
|
|
},
|
|
],
|
|
direction: null,
|
|
format: '',
|
|
indent: 0,
|
|
version: 1,
|
|
},
|
|
},
|
|
})
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { contact, leadership } = await createInviteLeadershipGraph(
|
|
coordinator,
|
|
municipality.id,
|
|
)
|
|
const account = await payload.create({
|
|
collection: 'campaignUser',
|
|
data: {
|
|
name: contact.name,
|
|
username: contact.phone,
|
|
password: 'senha-anterior-segura',
|
|
role: 'leader',
|
|
},
|
|
})
|
|
await payload.update({
|
|
collection: 'leadership',
|
|
id: leadership.id,
|
|
data: { consent: oldConsent.id, user: account.id },
|
|
})
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'login',
|
|
})
|
|
const submission = {
|
|
token: invite.token,
|
|
name: contact.name,
|
|
phone: contact.phone,
|
|
password: 'senha-nova-segura',
|
|
}
|
|
|
|
await expect(actions.redeemCampaignInviteLogin(submission)).rejects.toThrow(
|
|
'aceitar o consentimento',
|
|
)
|
|
await expect(
|
|
payload.findByID({ collection: 'leadership', id: leadership.id, depth: 0 }),
|
|
).resolves.toMatchObject({ consent: oldConsent.id, user: account.id })
|
|
await expect(
|
|
payload.login({
|
|
collection: 'campaignUser',
|
|
data: { username: contact.phone, password: 'senha-anterior-segura' },
|
|
}),
|
|
).resolves.toHaveProperty('token')
|
|
|
|
await expect(
|
|
actions.redeemCampaignInviteLogin({ ...submission, consentAccepted: true }),
|
|
).resolves.toEqual({ ok: true })
|
|
await expect(
|
|
payload.findByID({ collection: 'leadership', id: leadership.id, depth: 0 }),
|
|
).resolves.toMatchObject({ consent: configuredConsent.id, user: account.id })
|
|
},
|
|
)
|
|
|
|
itWithMutableConsent(
|
|
'requires reacceptance when the configured consent text changes in the same document',
|
|
async (consent) => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const { getCampaignInvitePageData } = await import('@/utilities/campaignInvitePageData')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { contact, leadership } = await createInviteLeadershipGraph(
|
|
coordinator,
|
|
municipality.id,
|
|
)
|
|
const firstInvite = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
|
|
await actions.redeemCampaignInviteAutofill({
|
|
token: firstInvite.token,
|
|
name: contact.name,
|
|
phone: contact.phone,
|
|
consentAccepted: true,
|
|
})
|
|
const firstAcceptance = await payload.findByID({
|
|
collection: 'leadership',
|
|
id: leadership.id,
|
|
depth: 0,
|
|
})
|
|
expect(firstAcceptance.consent).toBe(consent.id)
|
|
expect(firstAcceptance.consentContentHash).toMatch(/^[a-f0-9]{64}$/)
|
|
expect(firstAcceptance.consentedAt).toBeTruthy()
|
|
|
|
await payload.update({
|
|
collection: 'consent',
|
|
id: consent.id,
|
|
data: {
|
|
text: {
|
|
root: {
|
|
type: 'root',
|
|
children: [
|
|
{
|
|
type: 'paragraph',
|
|
children: [
|
|
{ type: 'text', text: campaignFixtures().value('Texto alterado'), version: 1 },
|
|
],
|
|
direction: null,
|
|
format: '',
|
|
indent: 0,
|
|
version: 1,
|
|
},
|
|
],
|
|
direction: null,
|
|
format: '',
|
|
indent: 0,
|
|
version: 1,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
const secondInvite = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
await expect(getCampaignInvitePageData(payload, secondInvite.token)).resolves.toMatchObject({
|
|
status: 'valid',
|
|
requiresConsent: true,
|
|
})
|
|
await expect(
|
|
actions.redeemCampaignInviteAutofill({
|
|
token: secondInvite.token,
|
|
name: contact.name,
|
|
phone: contact.phone,
|
|
}),
|
|
).rejects.toThrow('aceitar o consentimento')
|
|
await expect(
|
|
actions.redeemCampaignInviteAutofill({
|
|
token: secondInvite.token,
|
|
name: contact.name,
|
|
phone: contact.phone,
|
|
consentAccepted: true,
|
|
}),
|
|
).resolves.toEqual({ ok: true })
|
|
|
|
const secondAcceptance = await payload.findByID({
|
|
collection: 'leadership',
|
|
id: leadership.id,
|
|
depth: 0,
|
|
})
|
|
expect(secondAcceptance.consent).toBe(consent.id)
|
|
expect(secondAcceptance.consentContentHash).not.toBe(firstAcceptance.consentContentHash)
|
|
expect(secondAcceptance.consentedAt).not.toBe(firstAcceptance.consentedAt)
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'rejects takeover of an unlinked elevated account with the submitted phone',
|
|
async () => {
|
|
const records = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { contact, leadership } = await createInviteLeadershipGraph(
|
|
coordinator,
|
|
municipality.id,
|
|
)
|
|
const victim = await payload.create({
|
|
collection: 'campaignUser',
|
|
data: {
|
|
name: 'Coordenação vítima',
|
|
username: contact.phone,
|
|
password: 'senha-original-segura',
|
|
role: 'coordinator',
|
|
},
|
|
})
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'login',
|
|
})
|
|
|
|
await expect(
|
|
records.redeemCampaignInviteLogin({
|
|
token: invite.token,
|
|
name: contact.name,
|
|
phone: contact.phone,
|
|
password: 'senha-tomada-segura',
|
|
consentAccepted: true,
|
|
}),
|
|
).rejects.toThrow('Convite inválido ou expirado')
|
|
const persistedLeadership = await payload.findByID({
|
|
collection: 'leadership',
|
|
id: leadership.id,
|
|
depth: 0,
|
|
})
|
|
expect(persistedLeadership.user).toBeNull()
|
|
await expect(
|
|
payload.login({
|
|
collection: 'campaignUser',
|
|
data: { username: contact.phone, password: 'senha-original-segura' },
|
|
}),
|
|
).resolves.toMatchObject({ user: { id: victim.id, role: 'coordinator' } })
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent('rejects lateral takeover of another leadership account', async () => {
|
|
const records = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const targetMunicipality = await campaignFixtures().getMunicipality()
|
|
const otherMunicipality = await campaignFixtures().getMunicipality()
|
|
const target = await createInviteLeadershipGraph(coordinator, targetMunicipality.id)
|
|
const other = await createInviteLeadershipGraph(coordinator, otherMunicipality.id)
|
|
const lateralAccount = await payload.create({
|
|
collection: 'campaignUser',
|
|
data: {
|
|
name: 'Outra liderança',
|
|
username: target.contact.phone,
|
|
password: 'senha-lateral-original',
|
|
role: 'leader',
|
|
},
|
|
})
|
|
await payload.update({
|
|
collection: 'leadership',
|
|
id: other.leadership.id,
|
|
data: { user: lateralAccount.id },
|
|
})
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: target.leadership.id,
|
|
kind: 'login',
|
|
})
|
|
|
|
await expect(
|
|
records.redeemCampaignInviteLogin({
|
|
token: invite.token,
|
|
name: target.contact.name,
|
|
phone: target.contact.phone,
|
|
password: 'senha-lateral-tomada',
|
|
consentAccepted: true,
|
|
}),
|
|
).rejects.toThrow('Convite inválido ou expirado')
|
|
const targetPersisted = await payload.findByID({
|
|
collection: 'leadership',
|
|
id: target.leadership.id,
|
|
depth: 0,
|
|
})
|
|
expect(targetPersisted.user).toBeNull()
|
|
})
|
|
|
|
itWithMutableConsent(
|
|
'requires the configured consent key even when the leadership already has consent',
|
|
async (consent) => {
|
|
const records = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { contact, leadership } = await createInviteLeadershipGraph(
|
|
coordinator,
|
|
municipality.id,
|
|
)
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
await payload.update({
|
|
collection: 'leadership',
|
|
id: leadership.id,
|
|
data: { consent: consent.id },
|
|
})
|
|
await payload.update({
|
|
collection: 'consent',
|
|
id: consent.id,
|
|
data: { key: `consent-vacated-${consent.id}` },
|
|
})
|
|
|
|
await expect(
|
|
records.redeemCampaignInviteAutofill({
|
|
token: invite.token,
|
|
name: 'Nome não persistido',
|
|
phone: contact.phone,
|
|
consentAccepted: true,
|
|
}),
|
|
).rejects.toThrow('Consentimento ainda não configurado')
|
|
const unchanged = await payload.findByID({
|
|
collection: 'contact',
|
|
id: contact.id,
|
|
depth: 0,
|
|
})
|
|
expect(unchanged.name).toBe(contact.name)
|
|
await payload.update({
|
|
collection: 'consent',
|
|
id: consent.id,
|
|
data: { key: 'lideranca-autopreenchimento' },
|
|
})
|
|
await expect(
|
|
records.redeemCampaignInviteAutofill({
|
|
token: invite.token,
|
|
name: 'Nome após restauração',
|
|
phone: contact.phone,
|
|
consentAccepted: true,
|
|
}),
|
|
).resolves.toEqual({ ok: true })
|
|
},
|
|
)
|
|
|
|
itWithMutableConsent(
|
|
'rolls back linked-account recovery when the configured consent key is missing',
|
|
async (consent) => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const { contact, leadership } = await createInviteLeadershipGraph(
|
|
coordinator,
|
|
municipality.id,
|
|
)
|
|
const account = await payload.create({
|
|
collection: 'campaignUser',
|
|
data: {
|
|
name: contact.name,
|
|
username: contact.phone,
|
|
password: 'senha-original-consentimento',
|
|
role: 'leader',
|
|
},
|
|
})
|
|
await payload.update({
|
|
collection: 'leadership',
|
|
id: leadership.id,
|
|
data: { consent: consent.id, user: account.id },
|
|
})
|
|
const invite = await createInviteThroughAction(coordinator, {
|
|
leadership: leadership.id,
|
|
kind: 'login',
|
|
})
|
|
await payload.update({
|
|
collection: 'consent',
|
|
id: consent.id,
|
|
data: { key: `consent-vacated-${consent.id}` },
|
|
})
|
|
const submission = {
|
|
token: invite.token,
|
|
name: contact.name,
|
|
phone: contact.phone,
|
|
password: 'senha-nova-consentimento',
|
|
}
|
|
|
|
await expect(actions.redeemCampaignInviteLogin(submission)).rejects.toThrow(
|
|
'Consentimento ainda não configurado',
|
|
)
|
|
await expect(
|
|
payload.login({
|
|
collection: 'campaignUser',
|
|
data: { username: contact.phone, password: 'senha-original-consentimento' },
|
|
}),
|
|
).resolves.toHaveProperty('token')
|
|
await payload.update({
|
|
collection: 'consent',
|
|
id: consent.id,
|
|
data: { key: 'lideranca-autopreenchimento' },
|
|
})
|
|
await expect(
|
|
actions.redeemCampaignInviteLogin({ ...submission, consentAccepted: true }),
|
|
).resolves.toEqual({ ok: true })
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'serializes concurrent new-account creation for the same username and creates leader accounts',
|
|
async () => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
const firstMunicipality = await campaignFixtures().getMunicipality()
|
|
const secondMunicipality = await campaignFixtures().getMunicipality()
|
|
const first = await createInviteLeadershipGraph(coordinator, firstMunicipality.id)
|
|
const second = await createInviteLeadershipGraph(coordinator, secondMunicipality.id)
|
|
const firstInvite = await createInviteThroughAction(coordinator, {
|
|
leadership: first.leadership.id,
|
|
kind: 'login',
|
|
})
|
|
const secondInvite = await createInviteThroughAction(coordinator, {
|
|
leadership: second.leadership.id,
|
|
kind: 'login',
|
|
})
|
|
const sharedPhone = campaignFixtures().phone()
|
|
const results = await Promise.allSettled([
|
|
actions.redeemCampaignInviteLogin({
|
|
token: firstInvite.token,
|
|
name: first.contact.name,
|
|
phone: sharedPhone,
|
|
password: 'senha-corrida-segura',
|
|
consentAccepted: true,
|
|
}),
|
|
actions.redeemCampaignInviteLogin({
|
|
token: secondInvite.token,
|
|
name: second.contact.name,
|
|
phone: sharedPhone,
|
|
password: 'senha-corrida-segura',
|
|
consentAccepted: true,
|
|
}),
|
|
])
|
|
|
|
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
|
|
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
|
|
const accounts = await payload.find({
|
|
collection: 'campaignUser',
|
|
where: { username: { equals: sharedPhone } },
|
|
depth: 0,
|
|
})
|
|
expect(accounts.totalDocs).toBe(1)
|
|
expect(accounts.docs[0]!.role).toBe('leader')
|
|
},
|
|
)
|
|
|
|
itWithInviteConsent(
|
|
'returns only minimal serializable DTOs from public server actions',
|
|
async (consent) => {
|
|
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
authState.user = coordinator
|
|
const municipality = await campaignFixtures().getMunicipality()
|
|
const autofillTarget = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
const created = await actions.createCampaignInvite({
|
|
leadership: autofillTarget.leadership.id,
|
|
kind: 'autopreenchimento',
|
|
})
|
|
expect(Object.keys(created).sort()).toEqual(['inviteUrl', 'whatsappUrl'])
|
|
expect(created.inviteUrl).toMatch(/^http:\/\/localhost:3000\/campanha\/convite\/[^/]+$/)
|
|
expect(JSON.stringify(created)).not.toContain('tokenHash')
|
|
const autofillToken = new URL(created.inviteUrl).pathname.split('/').pop()!
|
|
await expect(
|
|
actions.redeemCampaignInviteAutofill({
|
|
token: autofillToken,
|
|
name: autofillTarget.contact.name,
|
|
phone: autofillTarget.contact.phone,
|
|
consentAccepted: true,
|
|
}),
|
|
).resolves.toEqual({ ok: true })
|
|
|
|
const loginTarget = await createInviteLeadershipGraph(coordinator, municipality.id)
|
|
await payload.update({
|
|
collection: 'leadership',
|
|
id: loginTarget.leadership.id,
|
|
data: { consent: consent.id },
|
|
})
|
|
const loginCreated = await actions.createCampaignInvite({
|
|
leadership: loginTarget.leadership.id,
|
|
kind: 'login',
|
|
})
|
|
const loginToken = new URL(loginCreated.inviteUrl).pathname.split('/').pop()!
|
|
await expect(
|
|
actions.redeemCampaignInviteLogin({
|
|
token: loginToken,
|
|
name: loginTarget.contact.name,
|
|
phone: loginTarget.contact.phone,
|
|
password: 'senha-publica-segura',
|
|
consentAccepted: true,
|
|
}),
|
|
).resolves.toEqual({ ok: true })
|
|
await expect(
|
|
payload.findByID({
|
|
collection: 'leadership',
|
|
id: loginTarget.leadership.id,
|
|
depth: 0,
|
|
}),
|
|
).resolves.toMatchObject({ consent: consent.id })
|
|
expect(cookieState.set).toHaveBeenCalledWith(expect.any(String), expect.any(Object))
|
|
expect(JSON.stringify(loginCreated)).not.toMatch(
|
|
/tokenHash|supportStatus|notes|consentNote|sessions|salt|hash/,
|
|
)
|
|
},
|
|
)
|
|
})
|