Pass 2 D3: consent do WhatsApp resolvido por chave estavel (fim do consent: 2)
- submitWhatsapp resolve Consent.key = 'whatsapp-inscricao' via requireConsentByKey e FALHA FECHADO com mensagem clara enquanto o documento nao existir - migration 20260725_170000 (data-only, idempotente, guardada): tagueia o documento vivo id 2 (key vazia/'') com a chave estavel; no-op se ja houver documento com a chave ou se id 2 tiver outra chave; NAO inventa texto juridico (politica Onda 0) - chave em lib/campaignConsentKeys + lease key nas fixtures; int spec pina fail-closed + caminho feliz (contact + subscription -> consent.id) - ATENCAO DEPLOY: revisar o SQL antes do push (aplica em producao no build da Vercel)
This commit is contained in:
parent
851f4bee3c
commit
304eb34b64
7 changed files with 174 additions and 2 deletions
|
|
@ -12,7 +12,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
|
|||
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 |
|
||||
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
|
||||
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
|
||||
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | open | AGENTS Known Gaps #2 |
|
||||
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
|
||||
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
|
||||
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
|
||||
| E2E campaign journeys latency-flake on loaded local machines (5s expect / 30s test budgets vs cold dev compiles; suite green with CI retries) | `tests/e2e/campaignMunicipalities.e2e.spec.ts` | test-infra | low | S | P3 | open — consider per-step budgets or a warmed dist dir | |
|
||||
|
|
|
|||
|
|
@ -2,12 +2,25 @@
|
|||
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { WHATSAPP_SUBSCRIPTION_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { WhatsAppFormInput, whatsAppFormSchema } from '@/lib/schemas/whatsapp-form'
|
||||
import { requireConsentByKey } from '@/utilities/campaignConsent'
|
||||
|
||||
export const submitWhatsapp = async (input: WhatsAppFormInput) => {
|
||||
const { comment, ...contactInput } = whatsAppFormSchema.parse(input)
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// Fail-closed consent resolution by stable key (Pass 2 D3) — the flow
|
||||
// refuses to record a subscription while the keyed Consent document is
|
||||
// missing, same policy as the campaign flows.
|
||||
const consent = await requireConsentByKey(
|
||||
payload,
|
||||
WHATSAPP_SUBSCRIPTION_CONSENT_KEY,
|
||||
undefined,
|
||||
'Consentimento da inscrição no WhatsApp ainda não configurado.',
|
||||
)
|
||||
|
||||
const transactionID = await payload.db.beginTransaction()
|
||||
|
||||
if (!transactionID) {
|
||||
|
|
@ -25,7 +38,7 @@ export const submitWhatsapp = async (input: WhatsAppFormInput) => {
|
|||
collection: 'subscription',
|
||||
data: {
|
||||
contact: contact.id,
|
||||
consent: 2,
|
||||
consent: consent.id,
|
||||
comment,
|
||||
},
|
||||
req: { transactionID },
|
||||
|
|
|
|||
|
|
@ -8,3 +8,10 @@ export const CAMPAIGN_INVITE_CONSENT_KEY = 'lideranca-autopreenchimento'
|
|||
|
||||
export const SUPPORTER_REGISTRATION_CONSENT_KEY = 'apoiador-cadastro'
|
||||
export const SUPPORTER_VOTE_INTENTION_CONSENT_KEY = 'apoiador-intencao-voto'
|
||||
|
||||
/**
|
||||
* Public-site WhatsApp subscription consent. Historically the flow hardcoded
|
||||
* `consent: 2`; migration `20260725_170000_whatsapp_subscription_consent_key`
|
||||
* tags that live document with this stable key (Pass 2 D3).
|
||||
*/
|
||||
export const WHATSAPP_SUBSCRIPTION_CONSENT_KEY = 'whatsapp-inscricao'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* Pass 2 D3 — retire the last hardcoded consent document id.
|
||||
*
|
||||
* The public WhatsApp subscription flow (`submitWhatsapp.ts`) used to write
|
||||
* `consent: 2`. This tags that SAME live document with the stable key
|
||||
* `whatsapp-inscricao`, so the code can resolve it fail-closed by key like
|
||||
* every campaign consent. Idempotent and guarded:
|
||||
*
|
||||
* - no-op when a document already carries the key (target state);
|
||||
* - no-op when id 2 does not exist or already carries a different key —
|
||||
* the flow then fails closed until an admin creates the keyed document
|
||||
* (deliberately NOT inventing consent text here: the wording is
|
||||
* counsel-owned, same policy as the Onda 0 keys).
|
||||
*
|
||||
* The live document's `key` is the EMPTY STRING (Payload text default), so
|
||||
* the guard treats '' and NULL alike.
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
UPDATE "consent"
|
||||
SET "key" = 'whatsapp-inscricao', "updated_at" = now()
|
||||
WHERE "id" = 2
|
||||
AND ("key" IS NULL OR "key" = '')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "consent" WHERE "key" = 'whatsapp-inscricao'
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
UPDATE "consent"
|
||||
SET "key" = NULL, "updated_at" = now()
|
||||
WHERE "id" = 2 AND "key" = 'whatsapp-inscricao';
|
||||
`)
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import * as migration_20260724_175500_contact_phone_optional from './20260724_17
|
|||
import * as migration_20260724_180000_add_campaign_foundation_records from './20260724_180000_add_campaign_foundation_records'
|
||||
import * as migration_20260724_180000_add_campaign_goals_global from './20260724_180000_add_campaign_goals_global'
|
||||
import * as migration_20260725_022155_add_municipality_budget_notes from './20260725_022155_add_municipality_budget_notes'
|
||||
import * as migration_20260725_170000_whatsapp_subscription_consent_key from './20260725_170000_whatsapp_subscription_consent_key'
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -180,4 +181,9 @@ export const migrations = [
|
|||
down: migration_20260725_022155_add_municipality_budget_notes.down,
|
||||
name: '20260725_022155_add_municipality_budget_notes',
|
||||
},
|
||||
{
|
||||
up: migration_20260725_170000_whatsapp_subscription_consent_key.up,
|
||||
down: migration_20260725_170000_whatsapp_subscription_consent_key.down,
|
||||
name: '20260725_170000_whatsapp_subscription_consent_key',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ const leasedConsentKeys = new Set([
|
|||
'lideranca-autopreenchimento',
|
||||
'apoiador-cadastro',
|
||||
'apoiador-intencao-voto',
|
||||
'whatsapp-inscricao',
|
||||
])
|
||||
|
||||
const defaultConsentText = (text: string): Consent['text'] => ({
|
||||
|
|
|
|||
107
tests/int/submitWhatsapp.int.spec.ts
Normal file
107
tests/int/submitWhatsapp.int.spec.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// @vitest-environment node
|
||||
|
||||
/**
|
||||
* Pass 2 D3: the public WhatsApp subscription flow resolves its Consent by the
|
||||
* stable key `whatsapp-inscricao` and FAILS CLOSED while the keyed document is
|
||||
* missing — the last hardcoded consent id (`consent: 2`) is gone.
|
||||
*/
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { submitWhatsapp } from '@/app/(frontend)/actions/submitWhatsapp'
|
||||
import { WHATSAPP_SUBSCRIPTION_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const deleteWhatsappConsentRows = async () => {
|
||||
await payload.delete({
|
||||
collection: 'consent',
|
||||
where: { key: { equals: WHATSAPP_SUBSCRIPTION_CONSENT_KEY } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
describe('submitWhatsapp (consent by stable key)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
// Only this spec file touches the whatsapp key, so removing it here cannot
|
||||
// rob a parallel spec (unlike the shared campaign keys, which are leased).
|
||||
it('fails closed while the keyed Consent document is missing', async () => {
|
||||
await deleteWhatsappConsentRows()
|
||||
|
||||
await expect(
|
||||
submitWhatsapp({
|
||||
name: 'Contato Whatsapp',
|
||||
email: `${campaignFixtures().value('whats')}@example.com`,
|
||||
phone: campaignFixtures().phone(),
|
||||
state: 'BA',
|
||||
city: 'Salvador',
|
||||
}),
|
||||
).rejects.toThrow('Consentimento da inscrição no WhatsApp ainda não configurado.')
|
||||
})
|
||||
|
||||
it('records contact + subscription linked to the keyed consent', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const consent = await fixtures.createConsent({ key: WHATSAPP_SUBSCRIPTION_CONSENT_KEY })
|
||||
const phone = fixtures.phone()
|
||||
|
||||
const result = await submitWhatsapp({
|
||||
name: 'Contato Whatsapp',
|
||||
email: `${fixtures.value('whats')}@example.com`,
|
||||
phone,
|
||||
state: 'BA',
|
||||
city: 'Salvador',
|
||||
comment: 'Quero receber as novidades.',
|
||||
})
|
||||
expect(result).toEqual({ ok: true })
|
||||
|
||||
const contacts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: phone } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const contact = contacts.docs[0]
|
||||
expect(contact).toBeDefined()
|
||||
|
||||
const subscriptions = await payload.find({
|
||||
collection: 'subscription',
|
||||
where: { contact: { equals: contact!.id } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const subscription = subscriptions.docs[0]
|
||||
expect(subscription).toBeDefined()
|
||||
expect(
|
||||
typeof subscription!.consent === 'object' && subscription!.consent !== null
|
||||
? subscription!.consent.id
|
||||
: subscription!.consent,
|
||||
).toBe(consent.id)
|
||||
|
||||
// The action's rows are not fixture-owned — clean them here so the
|
||||
// fixture's contact cleanup does not trip on the subscription FK.
|
||||
await payload.delete({
|
||||
collection: 'subscription',
|
||||
id: subscription!.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue