/** * Tests for the Documenso webhook receiver route at * `src/app/api/webhooks/documenso/route.ts`. * * The receiver was previously only covered indirectly: the unit test * `webhook-event-map.test.ts` validates the static event-name map, and * `documents-expired-webhook.test.ts` calls handlers directly. Neither * exercised the route's auth, dedup, dispatch loop, or 200-on-failure * envelope. This file fills that gap. * * Refs: docs/audit-comprehensive-2026-05-05.md HIGH ยง19 (auditor-J Issue 2). */ import { describe, it, expect } from 'vitest'; import { eq } from 'drizzle-orm'; import { NextRequest } from 'next/server'; import { POST as documensoWebhook } from '@/app/api/webhooks/documenso/route'; import { db } from '@/lib/db'; import { documents, documentEvents } from '@/lib/db/schema/documents'; import { interests } from '@/lib/db/schema/interests'; import { systemSettings } from '@/lib/db/schema/system'; import { encrypt } from '@/lib/utils/encryption'; import { makeClient, makePort } from '../helpers/factories'; /** * Seed a per-port webhook secret on the freshly-created test port and * return both the port + the plaintext secret. The receiver iterates * per-port secrets BEFORE the env fallback, so without this seed * `listDocumensoWebhookSecrets()` resolves the matched port to whichever * other port happens to share the env value (e.g. the dev `port-amador` * row that ships with the .env example). Each test gets a unique * secret so cross-test pollution can't happen either. */ async function seedWebhookSecret(portId: string): Promise { const secret = `test-webhook-${portId.slice(0, 8)}-${Date.now()}`.padEnd(48, 'x').slice(0, 48); const envelope = JSON.parse(encrypt(secret)); await db.insert(systemSettings).values({ portId, key: 'documenso_webhook_secret', value: envelope, }); return secret; } function buildRequest(body: Record, secret: string): NextRequest { return new NextRequest('http://localhost:3000/api/webhooks/documenso', { method: 'POST', headers: { 'content-type': 'application/json', 'x-documenso-secret': secret, }, body: JSON.stringify(body), }); } describe('Documenso webhook route', () => { it('returns 200 with ok:false when the secret header is missing or wrong', async () => { const req = buildRequest( { event: 'DOCUMENT_SIGNED', payload: { id: 'abc', recipients: [] } }, 'wrong-secret', ); const res = await documensoWebhook(req); expect(res.status).toBe(200); const body = await res.json(); expect(body).toMatchObject({ ok: false }); }); it('with a valid secret + DOCUMENT_SIGNED writes a documentEvents row', async () => { const port = await makePort(); const secret = await seedWebhookSecret(port.id); const client = await makeClient({ portId: port.id }); const documensoId = `docu-test-${Date.now()}`; const [doc] = await db .insert(documents) .values({ portId: port.id, clientId: client.id, documentType: 'eoi', title: 'Webhook test EOI', status: 'sent', documensoId, createdBy: 'seed', }) .returning(); const req = buildRequest( { event: 'DOCUMENT_SIGNED', payload: { id: documensoId, recipients: [{ email: 'signer@test.invalid', signingStatus: 'SIGNED' }], }, }, secret, ); const res = await documensoWebhook(req); expect(res.status).toBe(200); const events = await db .select() .from(documentEvents) .where(eq(documentEvents.documentId, doc!.id)); expect(events.length).toBeGreaterThanOrEqual(1); }); it('DOCUMENT_COMPLETED for a reservation_agreement stamps reservationDocStatus on the linked interest', async () => { const port = await makePort(); const secret = await seedWebhookSecret(port.id); const client = await makeClient({ portId: port.id }); const [interest] = await db .insert(interests) .values({ portId: port.id, clientId: client.id, pipelineStage: 'reservation', }) .returning(); const documensoId = `docu-resv-${Date.now()}`; await db.insert(documents).values({ portId: port.id, clientId: client.id, interestId: interest!.id, documentType: 'reservation_agreement', title: 'Reservation webhook test', status: 'sent', documensoId, createdBy: 'seed', }); const req = buildRequest( { event: 'DOCUMENT_COMPLETED', payload: { id: documensoId, recipients: [] }, }, secret, ); const res = await documensoWebhook(req); expect(res.status).toBe(200); const [updated] = await db.select().from(interests).where(eq(interests.id, interest!.id)); expect(updated?.reservationDocStatus).toBe('signed'); expect(updated?.dateReservationSigned).not.toBeNull(); }); it('DOCUMENT_COMPLETED for a contract stamps contractDocStatus on the linked interest', async () => { const port = await makePort(); const secret = await seedWebhookSecret(port.id); const client = await makeClient({ portId: port.id }); const [interest] = await db .insert(interests) .values({ portId: port.id, clientId: client.id, pipelineStage: 'contract', }) .returning(); const documensoId = `docu-contract-${Date.now()}`; await db.insert(documents).values({ portId: port.id, clientId: client.id, interestId: interest!.id, documentType: 'contract', title: 'Contract webhook test', status: 'sent', documensoId, createdBy: 'seed', }); const req = buildRequest( { event: 'DOCUMENT_COMPLETED', payload: { id: documensoId, recipients: [] }, }, secret, ); const res = await documensoWebhook(req); expect(res.status).toBe(200); const [updated] = await db.select().from(interests).where(eq(interests.id, interest!.id)); expect(updated?.contractDocStatus).toBe('signed'); expect(updated?.dateContractSigned).not.toBeNull(); }); it('replays of the same body are no-ops (signatureHash dedup)', async () => { const port = await makePort(); const secret = await seedWebhookSecret(port.id); const client = await makeClient({ portId: port.id }); const documensoId = `docu-dedup-${Date.now()}`; const [doc] = await db .insert(documents) .values({ portId: port.id, clientId: client.id, documentType: 'eoi', title: 'Dedup test EOI', status: 'sent', documensoId, createdBy: 'seed', }) .returning(); const body = { event: 'DOCUMENT_OPENED', payload: { id: documensoId, recipients: [{ email: 'opener@test.invalid', readStatus: 'OPENED' }], }, }; await documensoWebhook(buildRequest(body, secret)); await documensoWebhook(buildRequest(body, secret)); const events = await db .select() .from(documentEvents) .where(eq(documentEvents.documentId, doc!.id)); // The route's `handleDocumentOpened` writes an event with type // `'viewed'`. One row from the first call; the second should have // been refused by the signatureHash dedup guard. const viewedEvents = events.filter((e) => e.eventType === 'viewed'); expect(viewedEvents.length).toBe(1); }); });