/** * EMAIL_REDIRECT_TO safety net — comprehensive verification. * * Goal: a single env flip (`EMAIL_REDIRECT_TO=
`) 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; let fetchMock: ReturnType; beforeEach(() => { process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET; process.env.DOCUMENSO_API_URL = 'https://documenso.example.test'; process.env.DOCUMENSO_API_KEY = 'test-key'; 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; 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); expect(callBody.recipients).toHaveLength(2); for (const r of callBody.recipients) { expect(r.email).toBe(REDIRECT_TARGET); // Original email preserved in the name for traceability expect(r.name).toMatch(/\(was: .+@realclient\.com\)/); } }); 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); 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); for (const r of callBody.recipients) { expect(r.email).toBe(REDIRECT_TARGET); expect(r.name).toMatch(/\(was: .+@realclient\.com\)/); } }); 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); 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: '' })); 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): { 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', '

Hi Alice

'); 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', '

x

'); 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', '

Hi

'); 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(); }); });