From 304eb34b6491dbc11cde24d13faa85e2d95bc7da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 25 Jul 2026 16:02:57 -0300 Subject: [PATCH] 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) --- docs/TECH-DEBT.md | 2 +- src/app/(frontend)/actions/submitWhatsapp.ts | 15 ++- src/lib/campaignConsentKeys.ts | 7 ++ ...70000_whatsapp_subscription_consent_key.ts | 38 +++++++ src/migrations/index.ts | 6 + tests/helpers/campaignFixtures.ts | 1 + tests/int/submitWhatsapp.int.spec.ts | 107 ++++++++++++++++++ 7 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/migrations/20260725_170000_whatsapp_subscription_consent_key.ts create mode 100644 tests/int/submitWhatsapp.int.spec.ts diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index a99afe9f..2e0fb091 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.md @@ -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 | | diff --git a/src/app/(frontend)/actions/submitWhatsapp.ts b/src/app/(frontend)/actions/submitWhatsapp.ts index 75cb1b30..807d457e 100644 --- a/src/app/(frontend)/actions/submitWhatsapp.ts +++ b/src/app/(frontend)/actions/submitWhatsapp.ts @@ -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 }, diff --git a/src/lib/campaignConsentKeys.ts b/src/lib/campaignConsentKeys.ts index 8b4c8d56..bbd42b56 100644 --- a/src/lib/campaignConsentKeys.ts +++ b/src/lib/campaignConsentKeys.ts @@ -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' diff --git a/src/migrations/20260725_170000_whatsapp_subscription_consent_key.ts b/src/migrations/20260725_170000_whatsapp_subscription_consent_key.ts new file mode 100644 index 00000000..05360e3e --- /dev/null +++ b/src/migrations/20260725_170000_whatsapp_subscription_consent_key.ts @@ -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 { + 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 { + await db.execute(sql` + UPDATE "consent" + SET "key" = NULL, "updated_at" = now() + WHERE "id" = 2 AND "key" = 'whatsapp-inscricao'; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 00d567e1..d9c1119f 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -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', + }, ] diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 01bfdd88..6bc3834d 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -130,6 +130,7 @@ const leasedConsentKeys = new Set([ 'lideranca-autopreenchimento', 'apoiador-cadastro', 'apoiador-intencao-voto', + 'whatsapp-inscricao', ]) const defaultConsentText = (text: string): Consent['text'] => ({ diff --git a/tests/int/submitWhatsapp.int.spec.ts b/tests/int/submitWhatsapp.int.spec.ts new file mode 100644 index 00000000..ecab370a --- /dev/null +++ b/tests/int/submitWhatsapp.int.spec.ts @@ -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, + }) + }) +})