Round 4 of external review, on the merged commit. Both findings are consequences of the round-3 streaming change, which is exactly why the round was worth running. An AxiosError carries the request it failed on: `config.data` is the ENTIRE serialised message, base64 attachments included, and `config.headers` holds the signature. emailProcessor logs the error object and winston serialises it, so a DNS blip or a refused connection wrote password-reset links, guest recovery codes and multi-megabyte invoices into combined.log — verified against axios rather than assumed. Every rejection is now caught and replaced with a message-and-code-only error, so nothing downstream can serialise the request back out of it. readBounded had no deadline. axios' `timeout` covers the response HEADERS, and with responseType 'stream' it has already resolved by the time the body is read — so a receiver that answered 2xx and never closed its body left the await hanging, the queue row stayed pending, and the next processor pass sent the same message again. An unclosed stream was duplicate email. There is now a 10s wall clock that destroys the stream, with the timer unref'd so a hung read cannot hold the process open at exit. 23 transport tests (2 new, both failing without these fixes), 63 across the email suites, eslint clean. Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
25a7e64951
commit
0d41fe5bf1
@@ -48,6 +48,7 @@ beforeEach(() => {
|
||||
process.env.EMAIL_WEBHOOK_SECRET = SECRET;
|
||||
transport.__testing.setAllowPrivateUrls(false);
|
||||
transport.__testing.resetSecretWarning();
|
||||
transport.__testing.setReadTimeout(10000);
|
||||
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok' });
|
||||
axios.post.mockResolvedValue({ status: 200, data: streamOf('') });
|
||||
});
|
||||
@@ -257,6 +258,45 @@ describe('delivery result', () => {
|
||||
expect(result.messageId).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('never lets the request body escape inside a network error', async () => {
|
||||
// An AxiosError carries config.data — the whole serialised message, base64
|
||||
// attachments included — and config.headers holds the signature. Callers
|
||||
// log the error object and winston serialises it, so propagating the raw
|
||||
// error writes password-reset links and invoices into combined.log.
|
||||
const axiosError = Object.assign(new Error('connect ECONNREFUSED'), {
|
||||
code: 'ECONNREFUSED',
|
||||
config: {
|
||||
data: JSON.stringify({ html: 'RESET-LINK-SECRET' }),
|
||||
headers: { 'X-PicPeak-Signature': 'the-signature' },
|
||||
},
|
||||
});
|
||||
axios.post.mockRejectedValue(axiosError);
|
||||
|
||||
const caught = await transport.send(MAIL).catch((e) => e);
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect(caught.config).toBeUndefined();
|
||||
const serialised = JSON.stringify({ msg: caught.message, ...caught });
|
||||
expect(serialised).not.toContain('RESET-LINK-SECRET');
|
||||
expect(serialised).not.toContain('the-signature');
|
||||
// The useful part still survives for diagnosis.
|
||||
expect(caught.message).toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('gives up on a response stream that never closes, instead of hanging', async () => {
|
||||
// axios' timeout covers the headers; with responseType 'stream' it has
|
||||
// already resolved. Without a deadline this await hangs, the queue row
|
||||
// stays pending, and the next pass sends the same email again.
|
||||
const { PassThrough } = require('stream');
|
||||
const neverEnds = new PassThrough(); // written to by nobody, never ended
|
||||
axios.post.mockResolvedValue({ status: 200, data: neverEnds });
|
||||
transport.__testing.setReadTimeout(25);
|
||||
|
||||
const result = await transport.send(MAIL);
|
||||
expect(result.messageId).toEqual(expect.any(String));
|
||||
// The stream is torn down rather than left dangling.
|
||||
expect(neverEnds.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers a messageId the receiver reports', async () => {
|
||||
axios.post.mockResolvedValue({ status: 200, data: streamOf(JSON.stringify({ messageId: 'from-n8n-123' })) });
|
||||
expect((await transport.send(MAIL)).messageId).toBe('from-n8n-123');
|
||||
|
||||
Reference in New Issue
Block a user