fix(documenso): follow v1 /download JSON {downloadUrl} to fetch the real signed PDF
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
This commit is contained in:
@@ -1060,8 +1060,52 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrayBuffer = await res.arrayBuffer();
|
// Documenso 2.13's v1-compat `/download` returns JSON `{ downloadUrl }`
|
||||||
return Buffer.from(arrayBuffer);
|
// (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.
|
/** Convenience health-check used by the admin "Test connection" button.
|
||||||
|
|||||||
79
tests/unit/services/documenso-download-signed-pdf.test.ts
Normal file
79
tests/unit/services/documenso-download-signed-pdf.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user