From af05bb18dcbc75c473da421fb7eb31e8d01efade Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 24 Jun 2026 23:55:16 +0200 Subject: [PATCH] fix(documenso): follow v1 /download JSON {downloadUrl} to fetch the real signed PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documenso 2.13's v1-compat `GET /api/v1/documents/{id}/download` returns JSON `{ downloadUrl }` (a presigned S3 URL), not raw PDF bytes. `downloadSignedPdf` was doing `res.arrayBuffer()` directly, so it stored the ~500-byte JSON as the "signed PDF" — every signer got a corrupt attachment ("Adobe could not open … damaged") and the corrupt file was filed against the deal in the CRM. Fix: magic-byte detection — if the v1 /download body isn't a `%PDF-`, parse the JSON and follow `downloadUrl` to fetch the actual file; validate the result is a real PDF before returning. Backward-compatible with older v1 servers that return the PDF directly. This also protects the CRM deposit + email fan-out, since both consume downloadSignedPdf's buffer — a non-PDF now throws instead of depositing. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy --- src/lib/services/documenso-client.ts | 48 ++++++++++- .../documenso-download-signed-pdf.test.ts | 79 +++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 tests/unit/services/documenso-download-signed-pdf.test.ts diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index fc624b06..634eb9a0 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -1060,8 +1060,52 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise }); } - const arrayBuffer = await res.arrayBuffer(); - return Buffer.from(arrayBuffer); + // Documenso 2.13's v1-compat `/download` returns JSON `{ downloadUrl }` + // (a presigned S3 URL), NOT the raw PDF. Older v1 returned the PDF bytes + // directly. Detect by magic bytes: a real PDF starts with `%PDF-`. When it + // doesn't, parse the JSON and follow `downloadUrl` to fetch the actual file. + // Saving the JSON body as the "signed PDF" produced a ~500-byte corrupt file + // that got emailed to every signer + filed in the CRM (audit 2026-06-24). + const firstBuf = Buffer.from(await res.arrayBuffer()); + if (firstBuf.subarray(0, 5).toString('latin1') === '%PDF-') { + return firstBuf; + } + + let downloadUrl: string | undefined; + try { + downloadUrl = (JSON.parse(firstBuf.toString('utf8')) as { downloadUrl?: string }).downloadUrl; + } catch { + /* body was neither a PDF nor JSON */ + } + if (!downloadUrl) { + throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { + internalMessage: `${path} returned a ${firstBuf.length}-byte non-PDF body with no downloadUrl`, + }); + } + + let pdfRes: Response; + try { + pdfRes = await fetchWithTimeout(downloadUrl, {}); + } catch (err) { + if (err instanceof FetchTimeoutError) { + throw new CodedError('DOCUMENSO_TIMEOUT', { + internalMessage: `signed-PDF presigned download timed out after ${err.timeoutMs}ms`, + }); + } + throw err; + } + if (!pdfRes.ok) { + throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { + internalMessage: `signed-PDF presigned URL → ${pdfRes.status}`, + }); + } + const pdfBuf = Buffer.from(await pdfRes.arrayBuffer()); + if (pdfBuf.subarray(0, 5).toString('latin1') !== '%PDF-') { + throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { + internalMessage: `signed-PDF presigned URL returned a ${pdfBuf.length}-byte non-PDF`, + }); + } + return pdfBuf; } /** Convenience health-check used by the admin "Test connection" button. diff --git a/tests/unit/services/documenso-download-signed-pdf.test.ts b/tests/unit/services/documenso-download-signed-pdf.test.ts new file mode 100644 index 00000000..57fd33df --- /dev/null +++ b/tests/unit/services/documenso-download-signed-pdf.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Documenso 2.13's v1-compat `GET /api/v1/documents/{id}/download` returns +// JSON `{ downloadUrl }` (a presigned S3 URL), NOT raw PDF bytes. The CRM was +// saving that ~500-byte JSON as the "signed PDF" → corrupt file emailed to every +// signer + filed in the CRM. These tests pin the two-step follow behaviour. + +vi.mock('@/lib/fetch-with-timeout', () => ({ + fetchWithTimeout: vi.fn(), + FetchTimeoutError: class FetchTimeoutError extends Error { + timeoutMs = 0; + }, +})); +vi.mock('@/lib/services/port-config', () => ({ + getPortDocumensoConfig: vi.fn().mockResolvedValue({ + apiUrl: 'https://sig.example.com', + apiKey: 'k', + apiVersion: 'v1', + }), +})); +vi.mock('@/lib/logger', () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +import { downloadSignedPdf } from '@/lib/services/documenso-client'; +import { fetchWithTimeout } from '@/lib/fetch-with-timeout'; + +const mockFetch = vi.mocked(fetchWithTimeout); + +function jsonRes(obj: unknown) { + const bytes = Buffer.from(JSON.stringify(obj)); + return { + ok: true, + status: 200, + arrayBuffer: async () => bytes, + text: async () => JSON.stringify(obj), + headers: { get: () => 'application/json' }, + } as unknown as Response; +} +function pdfRes(text: string) { + const bytes = Buffer.from(text); + return { + ok: true, + status: 200, + arrayBuffer: async () => bytes, + headers: { get: () => 'application/pdf' }, + } as unknown as Response; +} + +describe('downloadSignedPdf (v1) — Documenso 2.13 JSON downloadUrl', () => { + beforeEach(() => mockFetch.mockReset()); + + it('follows the JSON { downloadUrl } and returns the real signed PDF bytes', async () => { + mockFetch + .mockResolvedValueOnce(jsonRes({ downloadUrl: 'https://s3.example/signed.pdf?sig=1' })) + .mockResolvedValueOnce(pdfRes('%PDF-1.7\nreal signed content')); + + const buf = await downloadSignedPdf('117', 'port-1'); + + expect(buf.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + expect(buf.toString()).toContain('real signed content'); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[1]![0]).toBe('https://s3.example/signed.pdf?sig=1'); + }); + + it('returns raw PDF directly when the endpoint already serves PDF bytes (older v1)', async () => { + mockFetch.mockResolvedValueOnce(pdfRes('%PDF-1.7 direct bytes')); + + const buf = await downloadSignedPdf('118', 'port-1'); + + expect(buf.toString()).toContain('direct bytes'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('throws when the body is neither a PDF nor a downloadUrl JSON', async () => { + mockFetch.mockResolvedValueOnce(jsonRes({ nope: true })); + await expect(downloadSignedPdf('119', 'port-1')).rejects.toThrow(); + }); +});