imapflow
Version:
IMAP Client for Node
459 lines (396 loc) • 15.3 kB
JavaScript
/*eslint no-unused-expressions: 0, prefer-arrow-callback: 0 */
;
const { ImapStream } = require('../lib/handler/imap-stream');
/**
* Helper that wires up the standard readable/next consumer pattern used across tests.
*
* The reader calls onCommand synchronously for each command object, then calls cmd.next()
* to allow the stream to continue. test.done() is called on the 'end' event.
*
* The readable handler uses a pendingRead flag so that if a 'readable' event fires
* while the reader is active, a follow-up read is triggered after the reader finishes.
* This prevents the 'end' event from being missed when the stream ends between reader
* iterations.
*
* @param {Object} test - nodeunit test object
* @param {Function} onCommand - synchronous function(cmd) called for each parsed command
* @param {Function} writer - async function(stream) that writes data to the stream
* @param {number} [expectedCount] - if set, assert that exactly this many commands were emitted
*/
function runStreamTest(test, onCommand, writer, expectedCount) {
const stream = new ImapStream({ cid: 'test' });
let commandCount = 0;
let reading = false;
let pendingRead = false;
const reader = async () => {
let cmd;
while ((cmd = stream.read()) !== null) {
commandCount++;
onCommand(cmd);
cmd.next();
}
};
const drainStream = () => {
if (reading) {
pendingRead = true;
return;
}
reading = true;
reader()
.catch(err => test.ifError(err))
.finally(() => {
reading = false;
if (pendingRead) {
pendingRead = false;
drainStream();
}
});
};
stream.on('readable', drainStream);
stream.on('error', err => {
test.ifError(err);
});
stream.on('end', () => {
if (expectedCount !== undefined) {
test.equal(commandCount, expectedCount, `expected ${expectedCount} command(s), got ${commandCount}`);
}
test.done();
});
writer(stream).catch(err => test.ifError(err));
}
module.exports['Literal split across chunks'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A APPEND {5}\r\n', 'payload should include literal marker line');
test.equal(cmd.literals.length, 1, 'should have one literal');
test.ok(Buffer.isBuffer(cmd.literals[0]), 'literal should be a Buffer');
test.equal(cmd.literals[0].toString(), '12345', 'literal content should be 12345');
},
async stream => {
stream.write(Buffer.from('A APPEND {5}\r\n'));
stream.end(Buffer.from('12345\r\n'));
},
1
);
};
module.exports['Literal with zero size'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A APPEND {0}\r\n', 'payload should include zero-size literal marker');
test.equal(cmd.literals.length, 1, 'should have one literal');
test.ok(Buffer.isBuffer(cmd.literals[0]), 'literal should be a Buffer');
test.equal(cmd.literals[0].length, 0, 'literal should be empty (length 0)');
},
async stream => {
stream.end(Buffer.from('A APPEND {0}\r\n\r\n'));
},
1
);
};
module.exports['Multiple commands in single chunk'] = test => {
const expected = ['A CMD1', 'B CMD2'];
let index = 0;
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), expected[index], `command ${index} payload`);
index++;
},
async stream => {
stream.end(Buffer.from('A CMD1\r\nB CMD2\r\n'));
},
2
);
};
module.exports['LiteralTooLarge error'] = test => {
const stream = new ImapStream({ cid: 'test' });
stream.on('error', err => {
test.equal(err.code, 'LiteralTooLarge', 'error code should be LiteralTooLarge');
stream.destroy();
test.done();
});
stream.write(Buffer.from('A APPEND {1073741825}\r\n'));
};
module.exports['LiteralTooLarge error honors configured maxLiteralSize'] = test => {
const cap = 1024; // 1KB cap
const stream = new ImapStream({ cid: 'test', maxLiteralSize: cap });
stream.on('error', err => {
test.equal(err.code, 'LiteralTooLarge', 'error code should be LiteralTooLarge');
test.equal(err.maxSize, cap, 'maxSize should reflect the configured cap');
test.equal(err.literalSize, 2048, 'literalSize should be the offending value');
stream.destroy();
test.done();
});
stream.write(Buffer.from('A APPEND {2048}\r\n'));
};
module.exports['maxLiteralSize: 0 is honored (not swallowed into the default)'] = test => {
// Regression: `this.options.maxLiteralSize || MAX_LITERAL_SIZE` turned an explicit 0 into
// the 1GB default. An explicit 0 must mean "reject any non-empty literal".
test.expect(2);
const stream = new ImapStream({ cid: 'test', maxLiteralSize: 0 });
test.equal(stream.maxLiteralSize, 0, 'an explicit 0 cap is preserved, not replaced by the default');
stream.on('error', err => {
test.equal(err.code, 'LiteralTooLarge', 'a 1-byte literal exceeds the 0 cap');
stream.destroy();
test.done();
});
stream.write(Buffer.from('A APPEND {1}\r\n'));
};
module.exports['Literal within configured maxLiteralSize parses cleanly'] = test => {
// Require both literal assertions to actually run: 'end' fires even if the parser never
// emits the command, so without expect() a dropped-literal regression would pass green.
test.expect(2);
const stream = new ImapStream({ cid: 'test', maxLiteralSize: 1024 });
const literal = Buffer.alloc(512, 0x61); // 512 * 'a'
stream.on('readable', () => {
let cmd;
while ((cmd = stream.read()) !== null) {
test.equal(cmd.literals.length, 1, 'should have one literal');
test.equal(cmd.literals[0].length, 512, 'literal length should be 512');
cmd.next();
}
});
stream.on('error', err => test.ifError(err));
stream.on('end', () => test.done());
stream.write(Buffer.from('A APPEND {512}\r\n'));
stream.write(literal);
stream.end(Buffer.from('\r\n'));
};
module.exports['Incomplete line continued in next chunk'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A CAPABILITY', 'payload should be A CAPABILITY');
test.equal(cmd.literals.length, 0, 'should have no literals');
},
async stream => {
stream.write(Buffer.from('A CA'));
stream.end(Buffer.from('PABILITY\r\n'));
},
1
);
};
module.exports['Empty chunk then valid command'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A CMD', 'payload should be A CMD');
test.equal(cmd.literals.length, 0, 'should have no literals');
},
async stream => {
stream.write(Buffer.alloc(0));
stream.end(Buffer.from('A CMD\r\n'));
},
1
);
};
module.exports['String input converted to Buffer'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A CMD', 'payload should be A CMD');
test.ok(Buffer.isBuffer(cmd.payload), 'payload should be a Buffer');
test.equal(cmd.literals.length, 0, 'should have no literals');
},
async stream => {
stream.end('A CMD\r\n');
},
1
);
};
module.exports['LF-only line terminator'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A CMD', 'payload should be A CMD without CR or LF');
test.equal(cmd.literals.length, 0, 'should have no literals');
},
async stream => {
stream.end(Buffer.from('A CMD\n'));
},
1
);
};
module.exports['Many chunks trigger event loop yield'] = test => {
runStreamTest(
test,
cmd => {
// Just verify each command is valid
test.ok(cmd.payload.toString().startsWith('A'), 'command should start with tag');
},
async stream => {
for (let i = 0; i < 15; i++) {
stream.write(Buffer.from(`A CMD${i}\r\n`));
}
stream.end();
},
15
);
};
module.exports['Destroy with queued items does not hang'] = test => {
const stream = new ImapStream({ cid: 'test' });
let errorEmitted = false;
stream.on('error', () => {
errorEmitted = true;
});
// Write multiple chunks rapidly then destroy
stream.write(Buffer.from('A CMD1\r\n'));
stream.write(Buffer.from('B CMD2\r\n'));
stream.destroy();
// Errors from destroy are emitted synchronously or on next tick
setImmediate(() => {
test.ok(!errorEmitted, 'should not emit error on destroy');
test.done();
});
};
module.exports['logRaw option triggers trace logging'] = test => {
let traceCalled = false;
let traceData = null;
const stream = new ImapStream({
cid: 'test',
logRaw: true
});
// Override the log object to capture trace calls
stream.log = {
trace: data => {
traceCalled = true;
traceData = data;
},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {}
};
// Also need to handle readable events
stream.on('readable', () => {
let cmd;
while ((cmd = stream.read()) !== null) {
cmd.next();
}
});
stream.on('end', () => {
test.ok(traceCalled, 'trace should have been called');
test.ok(traceData, 'trace data should exist');
test.equal(traceData.src, 's', 'source should be s');
test.ok(traceData.data, 'should have base64 data');
test.done();
});
stream.end(Buffer.from('A CMD\r\n'));
};
module.exports['Adjacent literals with marker at line start'] = test => {
// After the first literal's data (12345) is consumed, parsing resumes at the very start of
// a line that is itself a literal marker ({3}). The marker begins at byte 0 of the resumed
// line, which the backward scan must still recognize. Previously the loop bound skipped
// index 0, so the second literal was silently dropped.
runStreamTest(
test,
cmd => {
test.equal(cmd.literals.length, 2, 'both adjacent literals must be extracted');
test.equal(cmd.literals[0].toString(), '12345', 'first literal content');
test.equal(cmd.literals[1].toString(), 'ABC', 'second literal content');
},
async stream => {
stream.end(Buffer.from('A LOGIN {5}\r\n12345{3}\r\nABC\r\n'));
},
1
);
};
module.exports['Line length cap rejects oversized line'] = test => {
// A server that never sends a line terminator must not grow the line buffer without bound.
const stream = new ImapStream({ cid: 'test', maxLineLength: 16 });
let errored = false;
stream.on('error', err => {
errored = true;
test.equal(err.code, 'LineTooLarge', 'error code should be LineTooLarge');
test.equal(err.maxSize, 16, 'error should report the configured cap');
stream.destroy();
test.done();
});
stream.on('end', () => {
if (!errored) {
test.ok(false, 'expected a LineTooLarge error');
test.done();
}
});
// 24 bytes, no LF, written across chunks -> exceeds the 16 byte cap.
stream.write(Buffer.from('AAAAAAAA'));
stream.write(Buffer.from('BBBBBBBB'));
stream.write(Buffer.from('CCCCCCCC'));
};
module.exports['Line length cap allows line within limit'] = test => {
// A normal line under the configured cap must still parse cleanly.
const stream = new ImapStream({ cid: 'test', maxLineLength: 32 });
let payloads = [];
stream.on('readable', () => {
let cmd;
while ((cmd = stream.read()) !== null) {
payloads.push(cmd.payload.toString());
cmd.next();
}
});
stream.on('error', err => test.ifError(err));
stream.on('end', () => {
test.deepEqual(payloads, ['A NOOP'], 'line under the cap should parse');
test.done();
});
stream.end(Buffer.from('A NOOP\r\n'));
};
// ---------------------------------------------------------------------------
// checkLiteralMarker direct edge cases + _transform string handling + _destroy
// draining (lines that the normal streaming flow does not exercise).
// ---------------------------------------------------------------------------
module.exports['ImapStream: checkLiteralMarker returns false for empty line'] = test => {
const stream = new ImapStream({ cid: 't' });
test.equal(stream.checkLiteralMarker(Buffer.alloc(0)), false);
test.equal(stream.checkLiteralMarker(null), false);
test.done();
};
module.exports['ImapStream: checkLiteralMarker returns false when no trailing LF'] = test => {
const stream = new ImapStream({ cid: 't' });
test.equal(stream.checkLiteralMarker(Buffer.from('A1 OK no newline')), false);
test.done();
};
module.exports['ImapStream: checkLiteralMarker returns false for non-numeric marker'] = test => {
const stream = new ImapStream({ cid: 't' });
// '{' present but contains a non-digit, and an empty {} marker
test.equal(stream.checkLiteralMarker(Buffer.from('A1 CMD {x}\r\n')), false);
test.equal(stream.checkLiteralMarker(Buffer.from('A1 CMD {}\r\n')), false);
test.done();
};
module.exports['ImapStream: checkLiteralMarker activates literal state for valid marker'] = test => {
const stream = new ImapStream({ cid: 't' });
test.equal(stream.checkLiteralMarker(Buffer.from('A1 CMD {5}\r\n')), true);
test.equal(stream.literalWaiting, 5);
test.done();
};
module.exports['ImapStream: _transform converts string chunks to Buffer'] = test => {
const stream = new ImapStream({ cid: 't' });
let commands = [];
stream.on('readable', () => {
let cmd;
while ((cmd = stream.read()) !== null) {
commands.push(cmd.payload.toString());
cmd.next();
}
});
stream.on('end', () => {
test.ok(commands.some(c => /A1 OK/.test(c)));
test.done();
});
// write a string (not a Buffer) to exercise the string->Buffer branch
stream.write('A1 OK done\r\n');
stream.end();
};
module.exports['ImapStream: _destroy drains pending input queue callbacks'] = test => {
const stream = new ImapStream({ cid: 't' });
let nextCalled = false;
// Stage a pending queue item with a next() callback, then destroy.
stream.inputQueue.push({ chunk: Buffer.from('x'), next: () => (nextCalled = true) });
stream.destroy();
setImmediate(() => {
test.ok(nextCalled, 'pending next() invoked during destroy');
test.done();
});
};