Files
pn-new-crm/tests/unit/comms-safety.test.ts
Matt fc994cd88b
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m53s
fix(eoi): silence Documenso's own lifecycle emails on createDocument
The local-fill EOI pathway creates fresh Documenso envelopes via
createDocument, which (unlike the template pathway that inherits
template 8's all-false emailSettings) used Documenso's defaults — every
email event defaults to true on both the v1 and v2.13 APIs. So Documenso
fired its OWN unbranded "Waiting for others to complete signing." and
"Signing Complete!" emails (signed PDF attached, reply-to sales@),
bypassing EMAIL_REDIRECT_TO and duplicating the CRM's branded sends.

Force emailSettings to all-false (DOCUMENSO_SILENT_EMAIL_SETTINGS) on
every createDocument call (v1 JSON + v2 multipart). The CRM stays the
sole sender of signing comms. Verified against the live v2.13 OpenAPI +
template 8's stored meta.

Also stop the EMAIL_REDIRECT_TO gate from appending "(was: <email>)" to
the recipient NAME: a "Name" field auto-fills from it into the signed
PDF, so the annotation overlapped the signature. Redirect the email
only; the original is still logged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:20:55 +02:00

318 lines
13 KiB
TypeScript

/**
* EMAIL_REDIRECT_TO safety net - comprehensive verification.
*
* Goal: a single env flip (`EMAIL_REDIRECT_TO=<address>`) MUST pause every
* outbound communication channel. This test file exercises each channel
* end-to-end with the env set, asserting the message is rerouted (or
* short-circuited) before it leaves the process.
*
* Lock these tests in: any new outbound channel added later should ALSO
* gain a check here. If a future PR breaks the redirect, this fails loud.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const REDIRECT_TARGET = 'redirect@example.test';
// -------------------------------------------------------------------------
// 1. Documenso recipient redirect (createDocument + generateDocumentFromTemplate)
// -------------------------------------------------------------------------
describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
const originalDocumensoUrl = process.env.DOCUMENSO_API_URL;
const originalDocumensoKey = process.env.DOCUMENSO_API_KEY;
const originalDocumensoVersion = process.env.DOCUMENSO_API_VERSION;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET;
process.env.DOCUMENSO_API_URL = 'https://documenso.example.test';
process.env.DOCUMENSO_API_KEY = 'test-key';
// Pin v1 — prod's API version + these assertions read the JSON request
// body. Without this the local .env's DOCUMENSO_API_VERSION leaks in and
// the v2 multipart/FormData path makes JSON.parse(body) throw.
process.env.DOCUMENSO_API_VERSION = 'v1';
fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
id: 'doc-1',
status: 'PENDING',
recipients: [],
}),
text: async () => '',
}));
// @ts-expect-error global fetch shim for the test
globalThis.fetch = fetchMock;
});
afterEach(() => {
if (originalRedirect === undefined) delete process.env.EMAIL_REDIRECT_TO;
else process.env.EMAIL_REDIRECT_TO = originalRedirect;
if (originalDocumensoUrl === undefined) delete process.env.DOCUMENSO_API_URL;
else process.env.DOCUMENSO_API_URL = originalDocumensoUrl;
if (originalDocumensoKey === undefined) delete process.env.DOCUMENSO_API_KEY;
else process.env.DOCUMENSO_API_KEY = originalDocumensoKey;
if (originalDocumensoVersion === undefined) delete process.env.DOCUMENSO_API_VERSION;
else process.env.DOCUMENSO_API_VERSION = originalDocumensoVersion;
vi.resetModules();
});
it('createDocument - every recipient.email rewritten to redirect target', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
{ name: 'Alice Smith', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
{ name: 'Bob Smith', email: 'bob@realclient.com', role: 'VIEWER', signingOrder: 2 },
]);
expect(fetchMock).toHaveBeenCalledOnce();
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
expect(callBody.recipients).toHaveLength(2);
const namesByOrder = Object.fromEntries(
callBody.recipients.map((r: any) => [r.signingOrder, r.name]),
);
for (const r of callBody.recipients) {
expect(r.email).toBe(REDIRECT_TARGET);
}
// Name must stay CLEAN — it renders into the signed PDF's Name field, so
// the "(was: …)" redirect annotation must NOT leak into it (it overlapped
// the signature). Email-only redirect; original email lives in the logs.
expect(namesByOrder[1]).toBe('Alice Smith');
expect(namesByOrder[2]).toBe('Bob Smith');
for (const r of callBody.recipients) {
expect(r.name).not.toContain('(was:');
}
});
it('createDocument - suppresses Documenso own emails (emailSettings all false)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
{ name: 'Alice Smith', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
]);
expect(fetchMock).toHaveBeenCalledOnce();
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
// The CRM is the SOLE sender of signing comms. Documenso must never fire
// its own "Waiting for others" / "Signing Complete!" lifecycle emails, so
// every per-document email event is disabled at creation time.
expect(callBody.meta).toBeDefined();
expect(callBody.meta.emailSettings).toBeDefined();
const es = callBody.meta.emailSettings;
for (const key of [
'recipientSigningRequest',
'recipientSigned',
'recipientRemoved',
'documentPending',
'documentCompleted',
'documentDeleted',
'ownerDocumentCreated',
'ownerDocumentCompleted',
'ownerRecipientExpired',
]) {
expect(es[key]).toBe(false);
}
});
it('createDocument (v2) - emailSettings all false in the multipart payload', async () => {
process.env.DOCUMENSO_API_VERSION = 'v2';
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
{ name: 'Alice Smith', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
]);
// v2 envelope/create is multipart/form-data; the JSON lives in `payload`.
const form = fetchMock.mock.calls[0]![1].body as FormData;
const payload = JSON.parse(form.get('payload') as string) as any;
expect(payload.meta.emailSettings).toBeDefined();
for (const key of [
'recipientSigningRequest',
'recipientSigned',
'documentPending',
'documentCompleted',
'ownerDocumentCompleted',
]) {
expect(payload.meta.emailSettings[key]).toBe(false);
}
});
it('generateDocumentFromTemplate - formValues *Email keys rewritten', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.generateDocumentFromTemplate(42, {
formValues: {
'client.fullName': 'Alice Smith',
'client.primaryEmail': 'alice@realclient.com',
'developer.email': 'dev@realclient.com',
},
});
expect(fetchMock).toHaveBeenCalledOnce();
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
expect(callBody.formValues['client.primaryEmail']).toBe(REDIRECT_TARGET);
expect(callBody.formValues['developer.email']).toBe(REDIRECT_TARGET);
// Non-email field untouched
expect(callBody.formValues['client.fullName']).toBe('Alice Smith');
});
it('generateDocumentFromTemplate - recipients array rewritten (v2.x shape)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.generateDocumentFromTemplate(42, {
recipients: [
{ name: 'Alice', email: 'alice@realclient.com' },
{ name: 'Bob', email: 'bob@realclient.com' },
],
});
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
for (const r of callBody.recipients) {
expect(r.email).toBe(REDIRECT_TARGET);
// Name stays clean — no "(was: …)" annotation (renders into the PDF).
expect(r.name).not.toContain('(was:');
}
});
it('sendDocument - short-circuited when redirect is set (no /send call)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.sendDocument('doc-1');
// sendDocument falls through to getDocument when redirect is set, so we
// expect the GET fetch but NOT the /send POST.
const calls = fetchMock.mock.calls;
const sendCall = calls.find((c) => String(c[0]).includes('/send') && c[1]?.method === 'POST');
expect(sendCall).toBeUndefined();
});
it('sendReminder - short-circuited when redirect is set (no /remind call)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.sendReminder('doc-1', 'signer-1');
expect(fetchMock).not.toHaveBeenCalled();
});
it('createDocument - recipients NOT redirected when EMAIL_REDIRECT_TO unset', async () => {
delete process.env.EMAIL_REDIRECT_TO;
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
{ name: 'Alice', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
]);
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
expect(callBody.recipients[0].email).toBe('alice@realclient.com');
});
});
// -------------------------------------------------------------------------
// 2. sendEmail redirect (covers the centralized path used by 5+ services)
// -------------------------------------------------------------------------
describe('sendEmail redirect - EMAIL_REDIRECT_TO', () => {
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
afterEach(() => {
vi.doUnmock('nodemailer');
vi.resetModules();
if (originalRedirect === undefined) delete process.env.EMAIL_REDIRECT_TO;
else process.env.EMAIL_REDIRECT_TO = originalRedirect;
});
/**
* Each test does its own reset → mock → import dance so the nodemailer
* mock is the one observed by the freshly-imported `@/lib/email` module.
* Returns the sendMail spy so the test can assert on it.
*/
async function setupWith(redirect: string | null) {
if (redirect) process.env.EMAIL_REDIRECT_TO = redirect;
else delete process.env.EMAIL_REDIRECT_TO;
vi.resetModules();
const sendMailMock = vi.fn(async () => ({ messageId: '<msg@test>' }));
vi.doMock('nodemailer', () => ({
default: {
createTransport: vi.fn(() => ({ sendMail: sendMailMock })),
},
}));
const mod = await import('@/lib/email');
return { sendMailMock, mod };
}
// The mock is typed as `vi.fn(async () => …)` which gives `calls: unknown[]`
// - so the indexer reads come back as possibly-undefined. The test arms
// the spy and asserts toHaveBeenCalledOnce above, then this helper picks
// the first call with a runtime non-null check that satisfies tsc.
function firstSendMailArgs(spy: ReturnType<typeof vi.fn>): {
to: string;
subject: string;
} {
const calls = spy.mock.calls;
if (calls.length === 0) throw new Error('expected sendMail to be called');
const args = calls[0]?.[0];
if (!args) throw new Error('expected first call to have args');
return args as { to: string; subject: string };
}
it('rewrites to + prefixes subject when redirect set', async () => {
const { sendMailMock, mod } = await setupWith(REDIRECT_TARGET);
await mod.sendEmail('alice@realclient.com', 'Welcome', '<p>Hi Alice</p>');
expect(sendMailMock).toHaveBeenCalledOnce();
const args = firstSendMailArgs(sendMailMock);
expect(args.to).toBe(REDIRECT_TARGET);
expect(args.subject).toMatch(/^\[redirected from alice@realclient\.com\] Welcome$/);
});
it('handles array of recipients - joins original list into the subject prefix', async () => {
const { sendMailMock, mod } = await setupWith(REDIRECT_TARGET);
await mod.sendEmail(['alice@realclient.com', 'bob@realclient.com'], 'Update', '<p>x</p>');
const args = firstSendMailArgs(sendMailMock);
expect(args.to).toBe(REDIRECT_TARGET);
expect(args.subject).toMatch(
/^\[redirected from alice@realclient\.com, bob@realclient\.com\] Update$/,
);
});
it('passes through unchanged when redirect unset', async () => {
const { sendMailMock, mod } = await setupWith(null);
await mod.sendEmail('alice@realclient.com', 'Welcome', '<p>Hi</p>');
const args = firstSendMailArgs(sendMailMock);
expect(args.to).toBe('alice@realclient.com');
expect(args.subject).toBe('Welcome');
});
});
// -------------------------------------------------------------------------
// 3. Webhook short-circuit (covers the per-port outbound webhook delivery)
// -------------------------------------------------------------------------
describe('Webhook short-circuit - EMAIL_REDIRECT_TO', () => {
// The actual webhook worker pulls from BullMQ + the DB. To keep this a
// pure unit test, we extract the "should I dispatch?" predicate and
// assert against env.EMAIL_REDIRECT_TO directly. The full integration
// path is already covered by tests/integration/webhook-delivery.test.ts.
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
afterEach(() => {
if (originalRedirect === undefined) delete process.env.EMAIL_REDIRECT_TO;
else process.env.EMAIL_REDIRECT_TO = originalRedirect;
});
it('the worker reads process.env.EMAIL_REDIRECT_TO at dispatch time', () => {
// Sanity: the worker uses process.env directly (not a cached env import)
// so flipping the env at runtime takes effect on the next job.
process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET;
expect(process.env.EMAIL_REDIRECT_TO).toBe(REDIRECT_TARGET);
delete process.env.EMAIL_REDIRECT_TO;
expect(process.env.EMAIL_REDIRECT_TO).toBeUndefined();
});
});