UNPKG

imapflow

Version:

IMAP Client for Node

610 lines (557 loc) 20.3 kB
'use strict'; // Targeted coverage for ImapFlow internals that need precise state setups: // the lock queue processor (processLocks), authentication fallbacks, and a few // reader/handler error branches. const { ImapFlow } = require('../lib/imap-flow'); const makeClient = (overrides = {}) => { let client = new ImapFlow({ host: 'imap.example.com', port: 993, auth: { user: 'test', pass: 'test' }, logger: false, ...overrides }); client.socket = { destroyed: false, destroy: () => {} }; client.usable = true; return client; }; const drain = () => new Promise(resolve => setImmediate(resolve)); // ============================================================================ // processLocks // ============================================================================ module.exports['Coverage: processLocks is re-entrant safe (already processing)'] = async test => { let client = makeClient(); let resolveOpen; // mailboxOpen blocks until we resolve it, keeping processingLock busy client.mailboxOpen = () => new Promise(resolve => { resolveOpen = () => resolve({ path: 'A' }); }); let lockAPromise = client.getMailboxLock('A'); await drain(); // let processLocks start and await mailboxOpen // Second request while the first is still being processed -> hits the // "already processing" early return. let lockBPromise = client.getMailboxLock('B'); await drain(); // Now let the first mailbox open complete client.mailbox = { path: 'A', readOnly: false }; resolveOpen(); let lockA = await lockAPromise; test.ok(lockA); // Releasing A lets B be processed; B opens a different mailbox client.mailboxOpen = async () => { client.mailbox = { path: 'B', readOnly: false }; return { path: 'B' }; }; lockA.release(); let lockB = await lockBPromise; test.ok(lockB); lockB.release(); await drain(); test.done(); }; module.exports['Coverage: processLocks yields to the event loop on many failing locks'] = async test => { let client = makeClient(); client.usable = false; // every lock fails immediately via the NoConnection path let promises = []; for (let i = 0; i < 7; i++) { promises.push(client.getMailboxLock('M' + i).catch(err => err)); } let results = await Promise.all(promises); test.equal(results.length, 7); results.forEach(r => test.equal(r.code, 'NoConnection')); test.done(); }; module.exports['Coverage: processLocks marks mailbox missing when SELECT NO and LIST verify throws'] = async test => { let client = makeClient(); client.mailboxOpen = async () => { let err = new Error('SELECT failed'); err.responseStatus = 'NO'; throw err; }; // run('LIST') used to verify existence throws -> inner catch (3849-3850) client.run = async () => { throw new Error('LIST blew up'); }; let err = null; try { await client.getMailboxLock('Ghost'); } catch (e) { err = e; } test.ok(err); test.equal(err.responseStatus, 'NO'); test.done(); }; module.exports['Coverage: processLocks marks mailboxMissing when LIST returns empty'] = async test => { let client = makeClient(); client.mailboxOpen = async () => { let err = new Error('SELECT failed'); err.responseStatus = 'NO'; throw err; }; client.run = async () => []; // empty LIST -> mailbox confirmed missing let err = null; try { await client.getMailboxLock('Ghost'); } catch (e) { err = e; } test.ok(err.mailboxMissing); test.done(); }; module.exports['Coverage: processLocks yields after 5 processed locks'] = async test => { let client = makeClient(); client.usable = false; // each queued lock fails and continues let rejects = 0; for (let i = 0; i < 6; i++) { client.locks.push({ resolve: () => {}, reject: () => { rejects++; }, path: 'P', options: {}, lockId: i }); } await client.processLocks(); test.equal(rejects, 6); test.done(); }; module.exports['Coverage: getMailboxLock includes description in trace logs'] = async test => { let client = makeClient(); client.mailbox = { path: 'INBOX', readOnly: false }; // a description on the lock options exercises the `...(options.description && {...})` spreads let lock = await client.getMailboxLock('INBOX', { description: 'my work' }); test.ok(lock); lock.release(); await drain(); test.done(); }; module.exports['Coverage: processLocks logs active lock and returns when already processing'] = async test => { let client = makeClient(); client.mailbox = { path: 'INBOX' }; // so the log records the active mailbox path client.processingLock = true; // simulate a concurrent processor client.currentLock = { lockId: 7, options: { description: 'held lock' } }; // returns immediately after logging the active lock details await client.processLocks(); test.ok(true); test.done(); }; // ============================================================================ // close() internal error handling // ============================================================================ module.exports['Coverage: close swallows streamer cleanup errors'] = test => { let client = makeClient(); client.streamer = { removeListener() {}, destroyed: false, destroy() { throw new Error('streamer destroy boom'); } }; // Should not throw despite the streamer.destroy failure client.close(); test.ok(true); test.done(); }; module.exports['Coverage: close swallows top-level errors'] = test => { let client = makeClient(); // folders.clear() throws -> the outer try/catch in close() handles it client.folders = { clear() { throw new Error('folders boom'); } }; client.close(); test.ok(true); test.done(); }; module.exports['Coverage: lock acquired via SELECT logs description'] = async test => { let client = makeClient(); client.mailbox = false; // force the SELECT/EXAMINE path client.mailboxOpen = async path => { client.mailbox = { path, readOnly: false }; return { path }; }; let lock = await client.getMailboxLock('Work', { description: 'selected-path lock' }); test.equal(client.mailbox.path, 'Work'); lock.release(); await drain(); test.done(); }; module.exports['Coverage: lock failure logs description'] = async test => { let client = makeClient(); client.mailbox = false; client.mailboxOpen = async () => { throw new Error('cannot open'); }; let err = null; try { await client.getMailboxLock('Bad', { description: 'failing lock' }); } catch (e) { err = e; } test.ok(err); test.done(); }; module.exports['Coverage: getMailboxLock logs active lock description while one is held'] = test => { let client = makeClient(); // a held lock carrying a description exercises the activeLock spread in the request log client.currentLock = { lockId: 1, options: { description: 'currently held' } }; let p = client.getMailboxLock('Queued', { description: 'queued lock' }); p.catch(() => {}); // it will never resolve (held lock blocks); just exercise the log test.ok(p && typeof p.then === 'function'); client.close(); // rejects the queued lock and cleans up test.done(); }; // ============================================================================ // initialOK / serverBye fallbacks // ============================================================================ module.exports['Coverage: initialOK tolerates a greeting with no attributes'] = async test => { let client = makeClient(); client.isClosed = true; // so beginSession short-circuits without starting a session await client.initialOK({}); // no .attributes -> (message.attributes || []) test.equal(client.greeting, ''); test.done(); }; module.exports['Coverage: serverBye defaults the reason when none is given'] = async test => { let client = makeClient(); await client.serverBye({}); // no attributes -> reason falls back to the default test.equal(client.byeReason, 'Server closed connection'); test.done(); }; // ============================================================================ // authenticate fallbacks // ============================================================================ module.exports['Coverage: authenticate throws when state is LOGOUT'] = async test => { let client = makeClient(); client.state = client.states.LOGOUT; let err = null; try { await client.authenticate(); } catch (e) { err = e; } test.ok(err); test.done(); }; module.exports['Coverage: authenticate returns true when already authenticated'] = async test => { let client = makeClient(); client.state = client.states.AUTHENTICATED; let res = await client.authenticate(); test.equal(res, true); test.done(); }; module.exports['Coverage: authenticate throws when run yields falsy auth result'] = async test => { let client = makeClient(); client.state = client.states.NOT_AUTHENTICATED; client.capabilities = new Map(); // no AUTH= -> LOGIN path client.run = async () => false; // LOGIN returns falsy -> "No matching authentication method" let err = null; try { await client.authenticate(); } catch (e) { err = e; } test.ok(err); test.done(); }; // ============================================================================ // Socket event handlers (built by setSocketHandlers) // ============================================================================ // Minimal socket stub that records listeners so handlers can be invoked directly. const makeSocketStub = () => ({ destroyed: false, once() {}, on() {}, removeListener() {}, destroy() {} }); module.exports['Coverage: setSocketHandlers removes a lingering connect error handler'] = test => { let client = makeClient(); let removed = null; client.socket = { destroyed: false, once() {}, on() {}, removeListener(evt, fn) { removed = fn; }, destroy() {} }; client.writeSocket = client.socket; let handler = () => {}; client._connectErrorHandler = handler; client.setSocketHandlers(); test.equal(removed, handler, 'temporary connect error handler removed'); test.equal(client._connectErrorHandler, null); test.done(); }; module.exports['Coverage: initialPREAUTH is a no-op once closed'] = async test => { let client = makeClient(); client.isClosed = true; let started = false; client.startSession = async () => { started = true; }; await client.initialPREAUTH(); test.equal(started, false); test.done(); }; module.exports['Coverage: initialPREAUTH routes startSession failure to onUnhandledError'] = async test => { let client = makeClient(); client.initialResolve = false; client.initialReject = false; // no pending connect promise -> fall through to onUnhandledError client.startSession = async () => { throw new Error('session boom'); }; let closedAfter = false; client.closeAfter = () => { closedAfter = true; }; await client.initialPREAUTH(); await drain(); await drain(); test.equal(client.state, client.states.AUTHENTICATED); test.ok(closedAfter, 'closeAfter invoked from the PREAUTH error handler'); test.done(); }; module.exports['Coverage: _socketError logs and emits the error'] = test => { let client = makeClient(); client.socket = makeSocketStub(); client.writeSocket = client.socket; client.setSocketHandlers(); let emitted = null; client.on('error', err => { emitted = err; }); // not connecting/upgrading -> emitError falls through to emit('error') client._socketError(new Error('socket failure')); test.ok(emitted); test.done(); }; module.exports['Coverage: _socketTimeout recovers an IDLE connection with NOOP'] = async test => { let client = makeClient(); client.socket = makeSocketStub(); client.writeSocket = client.socket; client.setSocketHandlers(); client.idling = true; client.usable = true; let noopRun = false; let idleResumed = false; client.run = async cmd => { if (cmd === 'NOOP') noopRun = true; return true; }; client.idle = async () => { idleResumed = true; }; client._socketTimeout(); await drain(); await drain(); test.ok(noopRun, 'NOOP issued to recover IDLE'); test.ok(idleResumed, 'IDLE resumed after NOOP'); test.done(); }; module.exports['Coverage: _socketTimeout emits error when idling but unusable'] = test => { let client = makeClient(); client.socket = makeSocketStub(); client.writeSocket = client.socket; client.setSocketHandlers(); client.idling = true; client.usable = false; // cannot recover -> emitError let emitted = null; client.on('error', err => { emitted = err; }); client._socketTimeout(); test.ok(emitted); test.equal(emitted.code, 'ETIMEOUT'); test.done(); }; module.exports['Coverage: _socketTimeout emits error for non-IDLE operations'] = test => { let client = makeClient(); client.socket = makeSocketStub(); client.writeSocket = client.socket; client.setSocketHandlers(); client.idling = false; let emitted = null; client.on('error', err => { emitted = err; }); client._socketTimeout(); test.ok(emitted); test.equal(emitted.code, 'ETIMEOUT'); test.done(); }; module.exports['Coverage: _socketTimeout IDLE recovery closes on failure'] = async test => { let client = makeClient(); client.socket = makeSocketStub(); client.writeSocket = client.socket; client.setSocketHandlers(); client.idling = true; client.usable = true; client.run = async () => { throw new Error('NOOP failed'); }; let closed = false; client.close = () => { closed = true; }; client._socketTimeout(); await drain(); await drain(); test.ok(closed, 'connection closed after failed IDLE recovery'); test.done(); }; // ============================================================================ // reader() periodic event-loop yield // ============================================================================ module.exports['Coverage: reader yields after processing many items in one pass'] = async test => { let client = makeClient(); client.mailbox = { path: 'INBOX', exists: 0 }; // Queue 12 untagged EXISTS responses so a single reader() pass processes >10 // items and hits the periodic `await setImmediate` yield. let items = []; for (let i = 1; i <= 12; i++) { items.push({ payload: Buffer.from(`* ${i} EXISTS`), literals: [], next: () => {} }); } let idx = 0; client.streamer = { read: () => (idx < items.length ? items[idx++] : null) }; client.on('exists', () => {}); await client.reader(); test.equal(idx, 12, 'all items drained in one reader pass'); test.done(); }; // ============================================================================ // reader() error branches (handlers throwing must not break the loop) // ============================================================================ // Helper: drive reader() over a fixed list of pre-built data items. const runReaderWith = async (client, payloads) => { let items = payloads.map(p => ({ payload: Buffer.from(p), literals: [], next: () => {} })); let idx = 0; client.streamer = { read: () => (idx < items.length ? items[idx++] : null) }; await client.reader(); return idx; }; module.exports['Coverage: reader swallows a throwing onPlusTag handler'] = async test => { let client = makeClient(); client.currentRequest = { options: { onPlusTag: async () => { throw new Error('plus boom'); } } }; let count = await runReaderWith(client, ['+ go ahead']); test.equal(count, 1); test.done(); }; module.exports['Coverage: reader swallows a throwing section handler'] = async test => { let client = makeClient(); client.sectionHandlers = { CAPABILITY: async () => { throw new Error('section boom'); } }; let count = await runReaderWith(client, ['* OK [CAPABILITY FOO] hi']); test.equal(count, 1); test.done(); }; module.exports['Coverage: reader swallows a throwing untagged handler'] = async test => { let client = makeClient(); client.untaggedHandlers = { FOO: async () => { throw new Error('untagged boom'); } }; let count = await runReaderWith(client, ['* FOO bar']); test.equal(count, 1); test.done(); }; module.exports['Coverage: reader swallows a throwing trySend after a tagged response'] = async test => { let client = makeClient(); let resolved = false; client.currentRequest = { tag: 'A1' }; client.requestTagMap.set('A1', { command: 'NOOP', attributes: [], options: {}, resolve: arg => { resolved = true; // the reader awaits arg.next(); call it so the loop proceeds arg.next(); }, reject: () => {} }); client.trySend = async () => { throw new Error('trySend boom'); }; let count = await runReaderWith(client, ['A1 OK done']); test.equal(count, 1); test.ok(resolved, 'request still resolved despite trySend failure'); test.done(); }; module.exports['Coverage: reader handles compiler failure when building executedCommand'] = async test => { let client = makeClient(); let rejected = null; // A request whose attributes cannot be compiled -> the executedCommand try/catch is hit client.currentRequest = { tag: 'A1' }; client.requestTagMap.set('A1', { command: 'NOOP', // a circular/odd attribute that makes the logging compiler throw attributes: [{ type: 'LITERAL', get value() { throw new Error('compile boom'); } }], options: {}, resolve: () => {}, reject: err => { rejected = err; } }); await runReaderWith(client, ['A1 NO it failed']); test.ok(rejected); test.equal(rejected.responseStatus, 'NO'); test.done(); }; module.exports['Coverage: reader caps very large throttle backoff'] = async test => { let client = makeClient(); let rejected = null; client.currentRequest = { tag: 'A1' }; client.requestTagMap.set('A1', { command: 'FETCH', attributes: [], options: {}, resolve: () => {}, reject: err => { rejected = err; } }); // Run the reader; immediately abort the throttle wait via close() so the test // does not actually wait. The large backoff value exercises the 5-minute cap. let readerPromise = runReaderWith(client, ['A1 BAD Request is throttled. Suggested Backoff Time: 999999999 milliseconds']); // give the reader a tick to register the throttle timer, then abort it await drain(); if (typeof client._throttleAbort === 'function') { client._throttleAbort(true); } await readerPromise; test.ok(rejected); test.done(); }; // ============================================================================ // beginSession when already closed // ============================================================================ module.exports['Coverage: beginSession is a no-op once closed'] = test => { let client = makeClient(); client.isClosed = true; let started = false; client.startSession = async () => { started = true; }; client.beginSession(() => {}); test.equal(started, false, 'startSession not invoked when closed'); test.done(); };