imapflow
Version:
IMAP Client for Node
451 lines (410 loc) • 14.6 kB
JavaScript
;
// Targeted unit tests for low-level ImapFlow internals that are awkward to reach
// through a full session: emitError routing, write() guards, run() dispatch
// guards, the synthetic logger, the streamer error handler, autoidle scheduling
// and the untaggedFetch flag/modseq 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
});
return client;
};
// ============================================================================
// emitError
// ============================================================================
module.exports['Internals: emitError ignores falsy error'] = test => {
let client = makeClient();
let emitted = false;
client.on('error', () => {
emitted = true;
});
client.emitError(null);
test.equal(emitted, false);
test.done();
};
module.exports['Internals: emitError routes to upgrade rejector while upgrading'] = test => {
let client = makeClient();
client.socket = { destroyed: true, destroy: () => {} };
client.upgrading = true;
let rejected = null;
client._upgradeReject = err => {
rejected = err;
};
let err = new Error('tls boom');
client.emitError(err);
test.equal(rejected, err);
test.equal(client.upgrading, false);
test.done();
};
module.exports['Internals: emitError rejects pending connect promise'] = test => {
let client = makeClient();
client.socket = { destroyed: true, destroy: () => {} };
let rejected = null;
client.initialReject = err => {
rejected = err;
};
client.initialResolve = () => {};
let err = new Error('connect boom');
client.emitError(err);
test.equal(rejected, err);
test.equal(client.initialReject, false);
test.done();
};
module.exports['Internals: emitError emits error event as fallback'] = test => {
let client = makeClient();
client.socket = { destroyed: true, destroy: () => {} };
let emitted = null;
client.on('error', err => {
emitted = err;
});
let err = new Error('plain boom');
client.emitError(err);
test.equal(emitted, err);
test.equal(err._connId, client.id);
test.done();
};
// ============================================================================
// streamer error handler
// ============================================================================
module.exports['Internals: streamer error handler silently closes on transient codes'] = test => {
let client = makeClient();
let closeAfterCalled = false;
client.closeAfter = () => {
closeAfterCalled = true;
};
let emitted = false;
client.on('error', () => {
emitted = true;
});
client._streamerErrorHandler({ code: 'ECONNRESET', message: 'reset' });
test.ok(closeAfterCalled);
test.equal(emitted, false);
test.done();
};
module.exports['Internals: streamer error handler emits on other codes'] = test => {
let client = makeClient();
client.socket = { destroyed: true, destroy: () => {} };
let emitted = null;
client.on('error', err => {
emitted = err;
});
client._streamerErrorHandler({ code: 'EOTHER', message: 'weird' });
test.ok(emitted);
test.done();
};
// ============================================================================
// write()
// ============================================================================
module.exports['Internals: write throws when socket destroyed'] = test => {
let client = makeClient();
client.socket = { destroyed: true };
test.throws(() => client.write('A NOOP'), /Socket is already closed/);
test.done();
};
module.exports['Internals: write throws after logout'] = test => {
let client = makeClient();
client.socket = { destroyed: false };
client.state = client.states.LOGOUT;
test.throws(() => client.write('A NOOP'), /Can not send data after logged out/);
test.done();
};
module.exports['Internals: write closes when writeSocket destroyed'] = test => {
let client = makeClient();
client.socket = { destroyed: false };
client.writeSocket = { destroyed: true };
client.state = client.states.AUTHENTICATED;
let closed = false;
client.close = () => {
closed = true;
};
let res = client.write('A NOOP');
test.equal(res, undefined);
test.ok(closed);
test.done();
};
module.exports['Internals: write returns false for non-string non-buffer'] = test => {
let client = makeClient();
let written = [];
client.socket = { destroyed: false };
client.writeSocket = { destroyed: false, write: c => written.push(c) };
client.state = client.states.AUTHENTICATED;
client.commandParts = [];
let res = client.write({ not: 'a buffer' });
test.equal(res, false);
test.equal(written.length, 0);
test.done();
};
module.exports['Internals: write logs raw data when logRaw enabled'] = test => {
let logs = [];
let client = makeClient({ logRaw: true });
client.log = {
trace: o => logs.push(o),
debug: () => {},
warn: () => {},
error: () => {},
info: () => {}
};
let written = [];
client.socket = { destroyed: false };
client.writeSocket = { destroyed: false, write: c => written.push(c) };
client.state = client.states.AUTHENTICATED;
client.commandParts = [];
client.write('A NOOP');
test.ok(logs.some(l => l.src === 'c' && l.msg === 'write to socket'));
test.equal(written.length, 1);
test.done();
};
module.exports['Internals: write appends CRLF only on final part'] = test => {
let client = makeClient();
let written = [];
client.socket = { destroyed: false };
client.writeSocket = { destroyed: false, write: c => written.push(c) };
client.state = client.states.AUTHENTICATED;
// Pending command parts => no CRLF appended to this chunk
client.commandParts = ['more'];
client.write(Buffer.from('literal'));
test.equal(written[0].toString(), 'literal');
test.done();
};
// ============================================================================
// run()
// ============================================================================
module.exports['Internals: run returns false for unknown command'] = async test => {
let client = makeClient();
client.socket = { destroyed: false };
let res = await client.run('NOT_A_COMMAND');
test.equal(res, false);
test.done();
};
module.exports['Internals: run throws NoConnection without socket'] = async test => {
let client = makeClient();
client.socket = null;
let err = null;
try {
await client.run('NOOP');
} catch (e) {
err = e;
}
test.ok(err);
test.equal(err.code, 'NoConnection');
test.done();
};
module.exports['Internals: run invokes preCheck and command handler'] = async test => {
let client = makeClient();
client.socket = { destroyed: false };
let preCheckCalled = false;
client.preCheck = async () => {
preCheckCalled = true;
};
let handlerArgs = null;
client.commands = new Map([
[
'TESTCMD',
async (conn, a, b) => {
handlerArgs = [a, b];
return 'handled';
}
]
]);
let res = await client.run('TESTCMD', 1, 2);
test.equal(res, 'handled');
test.ok(preCheckCalled);
test.deepEqual(handlerArgs, [1, 2]);
test.done();
};
// ============================================================================
// autoidle
// ============================================================================
module.exports['Internals: autoidle does nothing when not selected'] = test => {
let client = makeClient();
client.state = client.states.AUTHENTICATED;
client.autoidle();
test.equal(client.idleStartTimer, undefined);
test.done();
};
module.exports['Internals: autoidle schedules idle when selected'] = test => {
let client = makeClient();
client.state = client.states.SELECTED;
let realSetTimeout = global.setTimeout;
let idleCalled = false;
client.idle = async () => {
idleCalled = true;
};
// Intercept the 15s idle timer and fire it synchronously
global.setTimeout = (fn, ms) => {
if (ms === 15 * 1000) {
fn();
return { unref() {} };
}
return realSetTimeout(fn, ms);
};
try {
client.autoidle();
} finally {
global.setTimeout = realSetTimeout;
}
test.ok(idleCalled);
test.done();
};
// ============================================================================
// getLogger
// ============================================================================
module.exports['Internals: getLogger uses provided logger object'] = test => {
let entries = [];
let custom = {
trace: o => entries.push(['trace', o]),
debug: o => entries.push(['debug', o]),
info: o => entries.push(['info', o]),
warn: o => entries.push(['warn', o]),
error: o => entries.push(['error', o]),
fatal: o => entries.push(['fatal', o])
};
let client = makeClient({ logger: custom });
client.log.info({ msg: 'hello' });
test.ok(entries.some(e => e[0] === 'info'));
test.done();
};
module.exports['Internals: getLogger falls back to console for missing fatal/error level'] = test => {
// Logger object missing the 'error' method -> falls through to console.log
let partial = {
trace() {},
debug() {},
info() {},
warn() {}
// no error, no fatal
};
let client = makeClient({ logger: partial });
let origConsoleLog = console.log;
let logged = [];
console.log = (...args) => logged.push(args);
try {
client.log.error({ msg: 'boom' });
} finally {
console.log = origConsoleLog;
}
test.ok(logged.length >= 1);
test.done();
};
module.exports['Internals: getLogger emits log events when emitLogs set'] = test => {
let client = makeClient({ logger: false });
client.emitLogs = true;
let events = [];
client.on('log', entry => events.push(entry));
let err = new Error('with stack');
err.code = 'XCODE';
client.log.warn({ msg: 'warned', err });
test.equal(events.length, 1);
test.equal(events[0].level, 'warn');
test.equal(events[0].err.code, 'XCODE');
test.ok(events[0].err.stack);
test.done();
};
module.exports['Internals: logger:false suppresses output but still allows log calls'] = test => {
let client = makeClient({ logger: false });
// Should not throw
client.log.debug({ msg: 'nothing happens' });
test.ok(true);
test.done();
};
// ============================================================================
// Misc branch coverage
// ============================================================================
module.exports['Internals: secure connection defaults to port 993'] = test => {
let client = new ImapFlow({ secure: true });
test.equal(client.port, 993);
test.done();
};
module.exports['Internals: getUntaggedHandler ignores non-string type token'] = test => {
let client = makeClient();
client.untaggedHandlers = { '5': () => 'numeric-keyword-handler' };
// numeric prefix but attributes[0].value is not a string -> keyword stays '5'
let handler = client.getUntaggedHandler('5', [{ type: 'ATOM', value: 12345 }]);
test.equal(handler(), 'numeric-keyword-handler');
test.done();
};
module.exports['Internals: untaggedVanished filters non-string tag entries'] = async test => {
let client = makeClient();
client.mailbox = { path: 'INBOX' };
let events = [];
client.on('expunge', e => events.push(e));
await client.untaggedVanished({
attributes: [[{ value: 12345 }], { value: '7' }] // non-string tag value filtered out
});
test.equal(events.length, 1);
test.equal(events[0].earlier, false);
test.done();
};
module.exports['Internals: setFlagColor returns false when STORE yields falsy'] = async test => {
let client = makeClient();
client.mailbox = { path: 'INBOX', exists: 5 };
client.socket = { destroyed: false };
client.run = async () => false; // both add and remove STORE return falsy
let res = await client.setFlagColor('1', 'red');
test.equal(res, false);
test.done();
};
module.exports['Internals: fetchOne with falsy seq coerces to empty string'] = async test => {
let client = makeClient();
client.mailbox = { path: 'INBOX', exists: 5 };
let captured = null;
client.run = async (cmd, seq) => {
captured = seq;
return { list: [] };
};
let res = await client.fetchOne(0, {});
test.equal(captured, ''); // (0 || '').toString()
test.equal(res, false);
test.done();
};
module.exports['Internals: unbind falls back to socket when writeSocket missing'] = test => {
let client = makeClient();
let raw = {
unpipe() {},
on() {},
once() {},
removeListener() {}
};
client.socket = raw;
client.writeSocket = null;
client.streamer = { /* unused here */ };
let result = client.unbind();
test.equal(result.writeSocket, raw);
test.done();
};
// ============================================================================
// untaggedFetch flag/modseq branches
// ============================================================================
module.exports['Internals: untaggedFetch includes modseq and flagColor'] = async test => {
let client = makeClient();
client.mailbox = { path: 'INBOX', exists: 5 };
let evt = null;
client.on('flags', e => {
evt = e;
});
let untagged = {
command: '2',
attributes: [
{ type: 'ATOM', value: 'FETCH' },
[
{ type: 'ATOM', value: 'UID' },
{ type: 'ATOM', value: '20' },
{ type: 'ATOM', value: 'MODSEQ' },
[{ type: 'ATOM', value: '4242' }],
{ type: 'ATOM', value: 'FLAGS' },
[{ type: 'ATOM', value: '\\Flagged' }]
]
]
};
await client.untaggedFetch(untagged);
test.ok(evt);
test.equal(evt.uid, 20);
test.ok(evt.modseq);
// \\Flagged with no MailFlagBit keywords maps to the red color
test.equal(evt.flagColor, 'red');
test.done();
};