imapflow
Version:
IMAP Client for Node
459 lines (365 loc) • 15.6 kB
JavaScript
'use strict';
// Tests for reliability/stability improvements: lock identity, acquireTimeout,
// maxLockHoldTime diagnostic, capability-clear-on-STARTTLS, and related
// cleanup paths.
const { ImapFlow } = require('../lib/imap-flow');
const iconv = require('iconv-lite');
// Helper: client with stubbed socket/usable state so the fast path of
// getMailboxLock() can grant locks synchronously (no network I/O).
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;
client.mailbox = { path: 'INBOX', readOnly: false };
return client;
};
// Helper: wait for any queued setImmediate callbacks (processLocks reschedules
// itself via setImmediate after a release).
const drain = () => new Promise(resolve => setImmediate(resolve));
// ============================================================================
// release() identity check
// ============================================================================
module.exports['Reliability: stale release() does not clear replacement lock'] = async test => {
let client = makeClient();
// Grant L1 via fast path
let lockA = await client.getMailboxLock('INBOX');
let staleRelease = lockA.release;
// Release L1 normally
staleRelease();
await drain();
test.equal(client.currentLock, false, 'L1 released');
// Grant L2
let lockB = await client.getMailboxLock('INBOX');
test.ok(client.currentLock, 'L2 active');
let currentBeforeStale = client.currentLock;
// Stale call — must NOT clear L2's hold
staleRelease();
test.equal(client.currentLock, currentBeforeStale, 'L2 still active after stale release');
lockB.release();
await drain();
test.done();
};
module.exports['Reliability: double release() is idempotent'] = async test => {
let client = makeClient();
let lock = await client.getMailboxLock('INBOX');
// First release clears
lock.release();
await drain();
test.equal(client.currentLock, false);
// Second release is a no-op — must not throw, must not affect state
test.doesNotThrow(() => lock.release());
test.equal(client.currentLock, false);
test.done();
};
// ============================================================================
// acquireTimeout
// ============================================================================
module.exports['Reliability: acquireTimeout rejects with LockTimeout code'] = async test => {
let client = makeClient();
// Hold the first lock so the next one queues
let lockA = await client.getMailboxLock('INBOX');
let start = Date.now();
try {
await client.getMailboxLock('INBOX', { acquireTimeout: 30 });
test.ok(false, 'Should have timed out');
} catch (err) {
let elapsed = Date.now() - start;
test.equal(err.code, 'LockTimeout');
test.ok(err.message.includes('Timed out'));
test.ok(typeof err.lockId === 'number');
test.ok(elapsed >= 25, `expected to wait ~30ms, got ${elapsed}`);
}
// Original lock must still be held
test.ok(client.currentLock, 'L1 still held after L2 timeout');
lockA.release();
await drain();
test.done();
};
module.exports['Reliability: acquireTimeout cleared when lock is granted'] = async test => {
let client = makeClient();
// Fast path grants immediately; timer never fires
let lock = await client.getMailboxLock('INBOX', { acquireTimeout: 50 });
test.ok(lock);
test.ok(client.currentLock, 'L1 granted');
// Wait past the timeout — no rejection should occur post-grant, no dangling timer
await new Promise(r => setTimeout(r, 80));
test.ok(client.currentLock, 'Still held after timer would have fired');
lock.release();
await drain();
test.done();
};
module.exports['Reliability: acquireTimeout cleared on close()'] = async test => {
let client = makeClient();
// Hold L1 so L2 queues
let lockA = await client.getMailboxLock('INBOX');
let rejectedCode = null;
client.getMailboxLock('INBOX', { acquireTimeout: 10_000 }).catch(err => {
rejectedCode = err.code;
});
// Immediately close — pending lock should reject with NoConnection (not LockTimeout),
// and its acquireTimer should be cleared so it never fires afterward.
client.close();
await drain();
await new Promise(r => setTimeout(r, 30));
test.equal(rejectedCode, 'NoConnection', 'Pending lock rejects with NoConnection on close');
// Keep reference so linter doesn't complain
test.ok(lockA);
test.done();
};
// ============================================================================
// maxLockHoldTime diagnostic
// ============================================================================
module.exports['Reliability: maxLockHoldTime warning fires when lock held past threshold'] = async test => {
let warnings = [];
let client = makeClient();
client.log.warn = obj => warnings.push(obj);
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 30 });
await new Promise(r => setTimeout(r, 60));
let hit = warnings.find(w => w && w.msg === 'Mailbox lock held for a long time');
test.ok(hit, 'Warning log must fire');
test.ok(typeof hit.heldFor === 'number' && hit.heldFor >= 25);
lock.release();
await drain();
test.done();
};
module.exports['Reliability: maxLockHoldTime=0 disables the warning'] = async test => {
let warnings = [];
let client = makeClient();
client.log.warn = obj => warnings.push(obj);
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 0 });
await new Promise(r => setTimeout(r, 30));
test.ok(!warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'), 'No warn when disabled');
lock.release();
await drain();
test.done();
};
module.exports['Reliability: maxLockHoldTime=false disables the warning'] = async test => {
let warnings = [];
let client = makeClient();
client.log.warn = obj => warnings.push(obj);
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: false });
await new Promise(r => setTimeout(r, 30));
test.ok(!warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'));
lock.release();
await drain();
test.done();
};
module.exports['Reliability: per-call maxLockHoldTime overrides constructor option'] = async test => {
let warnings = [];
// Constructor sets a long threshold; per-call sets a short one
let client = makeClient({ maxLockHoldTime: 10_000 });
client.log.warn = obj => warnings.push(obj);
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 20 });
await new Promise(r => setTimeout(r, 50));
test.ok(warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'), 'Per-call override must take effect');
lock.release();
await drain();
test.done();
};
module.exports['Reliability: held-lock timer cleared on release (does not fire after)'] = async test => {
let warnings = [];
let client = makeClient();
client.log.warn = obj => warnings.push(obj);
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 50 });
lock.release();
await drain();
// Wait longer than the threshold — no warning should appear because release cleared it
await new Promise(r => setTimeout(r, 80));
test.ok(!warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'));
test.done();
};
module.exports['Reliability: held-lock timer cleared on close()'] = async test => {
let warnings = [];
let client = makeClient();
client.log.warn = obj => warnings.push(obj);
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 30 });
test.ok(lock);
client.close();
await new Promise(r => setTimeout(r, 60));
test.ok(!warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'));
test.done();
};
// ============================================================================
// STARTTLS capability reset
// ============================================================================
module.exports['Reliability: STARTTLS code path clears capabilities before re-fetch'] = async test => {
// Verify the clear() calls happen by driving the same branch directly.
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' },
logger: false
});
client.capabilities.set('LOGINDISABLED', true);
client.capabilities.set('STARTTLS', true);
client.authCapabilities.set('AUTH=PLAIN', false);
// Stub the run() that would re-fetch CAPABILITY post-TLS
let ran = [];
client.run = async command => {
ran.push(command);
return true;
};
client.expectCapabilityUpdate = true;
// Execute the same statements that the STARTTLS-success branch runs
// (guards the behavior contract: caches are cleared before re-fetch)
if (client.expectCapabilityUpdate) {
client.capabilities.clear();
client.authCapabilities.clear();
await client.run('CAPABILITY');
}
test.equal(client.capabilities.size, 0, 'capabilities map cleared');
test.equal(client.authCapabilities.size, 0, 'authCapabilities map cleared');
test.deepEqual(ran, ['CAPABILITY']);
test.done();
};
// ============================================================================
// Handler try/catch (smoke tests via direct invocation)
// ============================================================================
module.exports['Reliability: sectionHandler throw is caught (no handler -> no effect)'] = async test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' },
logger: false
});
// Install a section handler that throws
client.getSectionHandler = () => async () => {
throw new Error('handler boom');
};
// The production path awaits the handler inside a try/catch in reader().
// Replicate that contract: the wrapping semantics here should not reject.
let handler = client.getSectionHandler('TEST');
let caught = false;
try {
// Mimic reader() — it does: try { await handler(...) } catch (err) { log.warn(...) }
try {
await handler([]);
} catch (err) {
caught = true;
test.ok(err.message.includes('boom'));
}
} catch (unexpected) {
test.ok(false, 'Outer catch should not observe: ' + unexpected.message);
}
test.ok(caught, 'Thrown error is captured by inner try/catch');
test.done();
};
module.exports['Reliability: onPlusTag throw is caught (no handler -> no effect)'] = async test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' },
logger: false
});
test.ok(client);
// Simulate a currentRequest with a throwing onPlusTag
let onPlusTag = async () => {
throw new Error('plus tag boom');
};
// The reader() path wraps this call in try/catch; replicate the contract.
let caught = false;
try {
await onPlusTag({});
} catch (err) {
caught = true;
test.ok(err.message.includes('boom'));
}
test.ok(caught);
test.done();
};
// ============================================================================
// Charset decoder defensive listener
// ============================================================================
module.exports['Reliability: decoder emit(error) does not crash when user has not attached listener'] = test => {
// Pattern: after a getDecoder() + defensive .on('error') + decoder.emit('error', err),
// the process does not throw (because at least one listener was registered).
let decoder = iconv.decodeStream('latin1');
// Attach the same kind of safety listener the production code installs
let warned = 0;
decoder.on('error', () => {
warned++;
});
// Simulate forwarding a source error into the decoder
test.doesNotThrow(() => {
decoder.emit('error', new Error('source stream failed'));
});
test.equal(warned, 1);
test.done();
};
// ============================================================================
// Throttle back-off timer is tracked and abortable on close()
// ============================================================================
// Feed a single throttling BAD response into reader() once, then null.
const stubThrottleResponse = (client, backoffMs) => {
let request = { tag: 'A001', command: 'FETCH', resolve: () => {}, reject: () => {} };
client.requestTagMap = new Map([['A001', request]]);
let done = false;
client.streamer.read = () => {
if (done) {
return null;
}
done = true;
return {
payload: Buffer.from(`A001 BAD Request is throttled. Suggested Backoff Time: ${backoffMs} milliseconds`),
literals: [],
next: () => {}
};
};
return request;
};
module.exports['Reliability: throttle back-off aborts promptly on close()'] = async test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' },
logger: false
});
client.socket = { destroyed: false, destroy: () => {} };
client.writeSocket = client.socket;
let rejected = null;
let request = stubThrottleResponse(client, 300000); // 5 min back-off
request.reject = err => {
rejected = err;
};
let start = Date.now();
let readerDone = client.reader().catch(() => {});
// Let reader() reach the (tracked) back-off wait.
await new Promise(r => setTimeout(r, 50));
test.ok(client._throttleTimer, 'back-off timer is tracked while waiting');
client.close();
await new Promise(r => setImmediate(r));
test.ok(rejected, 'request rejected promptly after close()');
test.equal(rejected.code, 'NoConnection', 'rejected with connection error, not ETHROTTLE');
test.equal(client._throttleTimer, null, 'throttle timer cleared on close()');
test.ok(Date.now() - start < 5000, 'settled well under the 5-minute cap');
await readerDone;
test.done();
};
module.exports['Reliability: throttle back-off still rejects ETHROTTLE on normal expiry'] = async test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' },
logger: false
});
let rejected = null;
let request = stubThrottleResponse(client, 50); // 50ms back-off
request.reject = err => {
rejected = err;
};
let readerDone = client.reader().catch(() => {});
await new Promise(r => setTimeout(r, 250));
test.ok(rejected, 'request rejected after the back-off elapses');
test.equal(rejected.code, 'ETHROTTLE', 'normal expiry still rejects ETHROTTLE');
test.equal(rejected.throttleReset, 50, 'throttleReset preserved');
test.equal(client._throttleTimer, null, 'throttle timer cleared after normal expiry');
await readerDone;
client.close();
test.done();
};