UNPKG

imapflow

Version:

IMAP Client for Node

467 lines (394 loc) 16.2 kB
'use strict'; /** * Tests for unhandled rejection prevention. * * When close() runs, it rejects pending promises synchronously. exec() and * getMailboxLock() attach .catch(noop) before returning, so the rejection is * observed immediately and does not trigger Node.js unhandledRejection. * These tests verify that no unhandled rejections escape while the caller * still receives the expected error. * * Key fix: exec() and getMailboxLock() are non-async, returning the promise * directly (with .catch(noop)), so the caller gets the same promise object * that has the noop handler. This prevents the async-wrapper double-promise * issue where .catch(noop) on an inner promise doesn't protect the outer * async wrapper promise. */ const net = require('net'); const { ImapFlow } = require('../lib/imap-flow'); // Create a mock IMAP server with optional custom behavior. // options.extraCapabilities - additional capabilities (e.g., 'IDLE') // options.onCommand(socket, tag, command) - custom handler; return true if handled function createMockServer(options) { const extraCaps = options && options.extraCapabilities ? ' ' + options.extraCapabilities : ''; const onCommand = options && options.onCommand; const server = net.createServer(socket => { socket.write('* OK Mock IMAP Server ready\r\n'); socket.on('data', data => { const lines = data .toString() .split('\r\n') .filter(l => l.trim()); for (const line of lines) { const parts = line.split(' '); const tag = parts[0]; const command = parts[1] ? parts[1].toUpperCase() : ''; if (onCommand && onCommand(socket, tag, command, line)) { continue; } if (command === 'CAPABILITY') { socket.write(`* CAPABILITY IMAP4rev1 AUTH=PLAIN${extraCaps}\r\n`); socket.write(`${tag} OK CAPABILITY completed\r\n`); } else if (command === 'LOGIN') { socket.write(`${tag} OK LOGIN completed\r\n`); } else if (command === 'LOGOUT') { socket.write('* BYE Server logging out\r\n'); socket.write(`${tag} OK LOGOUT completed\r\n`); socket.end(); } else if (command === 'NAMESPACE') { socket.write('* NAMESPACE (("" "/")) NIL NIL\r\n'); socket.write(`${tag} OK NAMESPACE completed\r\n`); } else if (command === 'COMPRESS') { socket.write(`${tag} NO COMPRESS not supported\r\n`); } else if (command === 'ENABLE') { socket.write(`${tag} OK ENABLE completed\r\n`); } else if (command === 'ID') { socket.write('* ID NIL\r\n'); socket.write(`${tag} OK ID completed\r\n`); } else if (command === 'SELECT' || command === 'EXAMINE') { socket.write('* 1 EXISTS\r\n'); socket.write('* 1 RECENT\r\n'); socket.write('* OK [UIDVALIDITY 1] UIDs valid\r\n'); socket.write('* OK [UIDNEXT 2] Predicted next UID\r\n'); socket.write(`${tag} OK SELECT completed\r\n`); } else if (command === 'NOOP') { socket.write(`${tag} OK NOOP completed\r\n`); } else if (tag && command) { socket.write(`${tag} OK Command completed\r\n`); } } }); socket.on('error', () => {}); }); return server; } // Helper: install an unhandledRejection detector function installRejectionDetector(test) { let unhandled = false; let unhandledReason = null; const handler = reason => { unhandled = true; unhandledReason = reason; }; process.on('unhandledRejection', handler); return { check() { process.removeListener('unhandledRejection', handler); test.equal(unhandled, false, 'no unhandledRejection should fire' + (unhandledReason ? ': ' + unhandledReason.message : '')); } }; } exports['Unhandled Rejection Prevention'] = { 'exec() + close() race should not cause unhandled rejection'(test) { test.expect(2); const client = new ImapFlow({ host: '127.0.0.1', port: 1, secure: false, logger: false }); // Set up state so exec() doesn't reject synchronously client.state = client.states.AUTHENTICATED; client.socket = new net.Socket(); const detector = installRejectionDetector(test); // Create a pending request, then immediately close. // close() rejects the request synchronously. The .catch(noop) on the // exec() promise ensures the rejection is observed immediately. let promise = client.exec('NOOP'); client.close(); // Wait for setImmediate and microtask rejections to settle setTimeout(() => { detector.check(); promise.catch(err => { test.equal(err.code, 'NoConnection', 'caller should receive NoConnection error'); test.done(); }); }, 100); }, 'exec() on LOGOUT state should not cause unhandled rejection'(test) { test.expect(2); const client = new ImapFlow({ host: '127.0.0.1', port: 1, secure: false, logger: false }); client.state = client.states.LOGOUT; const detector = installRejectionDetector(test); // exec() detects LOGOUT and returns a rejected promise with .catch(noop) let promise = client.exec('NOOP'); setTimeout(() => { detector.check(); promise.catch(err => { test.equal(err.code, 'NoConnection', 'should reject with NoConnection'); test.done(); }); }, 100); }, 'exec() with destroyed socket should not cause unhandled rejection'(test) { test.expect(2); const client = new ImapFlow({ host: '127.0.0.1', port: 1, secure: false, logger: false }); client.state = client.states.AUTHENTICATED; let sock = new net.Socket(); sock.destroy(); client.socket = sock; const detector = installRejectionDetector(test); let promise = client.exec('NOOP'); setTimeout(() => { detector.check(); promise.catch(err => { test.equal(err.code, 'EConnectionClosed', 'should reject with EConnectionClosed'); test.done(); }); }, 100); }, 'multiple pending exec() + close() should not cause unhandled rejections'(test) { test.expect(7); const client = new ImapFlow({ host: '127.0.0.1', port: 1, secure: false, logger: false }); client.state = client.states.AUTHENTICATED; client.socket = new net.Socket(); const detector = installRejectionDetector(test); let p1 = client.exec('NOOP'); let p2 = client.exec('NOOP'); let p3 = client.exec('NOOP'); client.close(); setTimeout(() => { detector.check(); let settled = 0; const checkDone = () => { if (++settled === 3) { test.done(); } }; p1.catch(err => { test.ok(err, 'p1 should reject'); test.equal(err.code, 'NoConnection', 'p1 error code'); checkDone(); }); p2.catch(err => { test.ok(err, 'p2 should reject'); test.equal(err.code, 'NoConnection', 'p2 error code'); checkDone(); }); p3.catch(err => { test.ok(err, 'p3 should reject'); test.equal(err.code, 'NoConnection', 'p3 error code'); checkDone(); }); }, 100); }, 'getMailboxLock() + close() race should not cause unhandled rejection'(test) { test.expect(2); const server = createMockServer(); server.listen(0, '127.0.0.1', async () => { const port = server.address().port; const client = new ImapFlow({ host: '127.0.0.1', port, secure: false, logger: false, auth: { user: 'test', pass: 'test' } }); const detector = installRejectionDetector(test); try { await client.connect(); // Request a lock, then immediately close before it resolves. // close() rejects pending locks synchronously. let lockPromise = client.getMailboxLock('INBOX'); client.close(); try { await lockPromise; test.ok(false, 'lockPromise should have rejected'); } catch (err) { // Wait for any deferred unhandled rejection await new Promise(r => setTimeout(r, 100)); detector.check(); test.equal(err.code, 'NoConnection', 'caller should receive NoConnection error'); } } catch (err) { detector.check(); test.ok(false, 'unexpected error: ' + err.message); } finally { server.close(() => test.done()); } }); }, 'connect() + close() during greeting timeout should not cause unhandled rejection'(test) { test.expect(2); // Server that accepts TCP but never sends a greeting. // The client's greetingTimeout will fire. const server = net.createServer(socket => { // Intentionally send nothing - let client timeout socket.on('error', () => {}); }); server.listen(0, '127.0.0.1', () => { const port = server.address().port; const client = new ImapFlow({ host: '127.0.0.1', port, secure: false, logger: false, greetingTimeout: 200, auth: { user: 'test', pass: 'test' } }); const detector = installRejectionDetector(test); // Close shortly after the TCP connection is established but // before the greeting is received const origSetSocket = client.setSocketHandlers; let closeTriggered = false; client.setSocketHandlers = function () { origSetSocket.call(this); if (!closeTriggered) { closeTriggered = true; // Close on the next tick to simulate race setImmediate(() => client.close()); } }; client.connect().then( () => { detector.check(); test.ok(false, 'connect() should have rejected'); server.close(() => test.done()); }, err => { setTimeout(() => { detector.check(); test.ok(err && err.code, 'connect() should reject with an error code'); server.close(() => test.done()); }, 100); } ); }); }, 'BYE during IDLE should not cause unhandled rejection (Death 1)'(test) { test.expect(2); const server = createMockServer({ extraCapabilities: 'IDLE', onCommand(socket, tag, command) { if (command === 'IDLE') { socket.write('+ idling\r\n'); // After a short delay, send BYE and close (simulates token expiry) setTimeout(() => { try { socket.write('* BYE Session invalidated - AccessTokenExpired\r\n'); socket.end(); } catch { // socket may already be closed } }, 50); return true; } } }); server.listen(0, '127.0.0.1', async () => { const port = server.address().port; const client = new ImapFlow({ host: '127.0.0.1', port, secure: false, logger: false, auth: { user: 'test', pass: 'test' } }); const detector = installRejectionDetector(test); try { await client.connect(); await client.mailboxOpen('INBOX'); // Start IDLE and wait for the BYE-triggered close await new Promise((resolve, reject) => { client.idle().catch(() => { // Expected: IDLE rejects when BYE arrives }); client.on('close', () => { // Wait for any deferred unhandled rejections to surface setTimeout(resolve, 100); }); // Safety timeout setTimeout(() => reject(new Error('Timeout waiting for close')), 5000); }); detector.check(); test.ok(true, 'No unhandled rejection during IDLE + BYE'); } catch (err) { detector.check(); test.ok(false, 'Unexpected error: ' + err.message); } finally { server.close(() => test.done()); } }); }, 'BAD response to FETCH should not cause unhandled rejection (Death 2)'(test) { test.expect(3); const server = createMockServer({ onCommand(socket, tag, command) { if (command === 'UID' || command === 'FETCH') { // Simulate "Server Unavailable" error for any FETCH variant socket.write(`${tag} BAD Server Unavailable. 15\r\n`); return true; } } }); server.listen(0, '127.0.0.1', async () => { const port = server.address().port; const client = new ImapFlow({ host: '127.0.0.1', port, secure: false, logger: false, auth: { user: 'test', pass: 'test' } }); const detector = installRejectionDetector(test); try { await client.connect(); await client.mailboxOpen('INBOX'); // Attempt a FETCH that will get BAD response try { await client.fetchOne('*', { uid: true }, { uid: true }); test.ok(false, 'fetchOne should have rejected'); } catch (err) { test.equal(err.message, 'Command failed', 'Should get Command failed error'); test.equal(err.responseStatus, 'BAD', 'Response status should be BAD'); } // Wait for any deferred unhandled rejections await new Promise(r => setTimeout(r, 100)); detector.check(); client.close(); } catch (err) { detector.check(); test.ok(false, 'Unexpected error: ' + err.message); } finally { server.close(() => test.done()); } }); } };