imapflow
Version:
IMAP Client for Node
949 lines (787 loc) • 26.4 kB
JavaScript
;
const { ImapFlow } = require('../lib/imap-flow');
const { EventEmitter } = require('events');
// Edge Cases Tests
module.exports['Connection Edge: Socket error during connection'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Simulate socket error
let errorEmitted = false;
client.on('error', err => {
errorEmitted = true;
test.ok(err);
test.equal(err._connId, client.id);
});
// Trigger error through emitError
let testError = new Error('Socket connection failed');
testError.code = 'ECONNREFUSED';
client.emitError(testError);
test.ok(errorEmitted, 'Error event should be emitted');
test.done();
};
module.exports['Connection Edge: Write after socket destroyed'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Simulate destroyed socket
client.socket = { destroyed: true };
test.throws(
() => {
client.write('TEST');
},
/Socket is already closed/,
'Should throw when writing to destroyed socket'
);
test.done();
};
module.exports['Connection Edge: Write after logout'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Create a mock socket
client.socket = { destroyed: false };
client.state = client.states.LOGOUT;
test.throws(
() => {
client.write('TEST');
},
/Can not send data after logged out/,
'Should throw when writing after logout'
);
test.done();
};
module.exports['Connection Edge: Stats reset functionality'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Set some initial values
client.writeBytesCounter = 100;
client.streamer.readBytesCounter = 200;
let stats = client.stats(false);
test.equal(stats.sent, 100);
test.equal(stats.received, 200);
// Reset stats
stats = client.stats(true);
test.equal(stats.sent, 100, 'Should return old value before reset');
test.equal(stats.received, 200, 'Should return old value before reset');
// Check if reset worked
test.equal(client.writeBytesCounter, 0, 'Write counter should be reset');
test.equal(client.streamer.readBytesCounter, 0, 'Read counter should be reset');
test.done();
};
module.exports['Connection Edge: Multiple error handlers'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
let errorCount = 0;
// Add multiple error handlers
client.on('error', () => errorCount++);
client.on('error', () => errorCount++);
client.on('error', () => errorCount++);
// Emit an error
client.emitError(new Error('Test error'));
test.equal(errorCount, 3, 'All error handlers should be called');
test.done();
};
module.exports['Connection Edge: Connection with valid empty auth'] = test => {
// ImapFlow actually allows creating client without throwing
// The error would occur during connection
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test' }
});
test.ok(client, 'Client should be created even with partial auth');
test.equal(client.host, 'imap.example.com');
test.done();
};
module.exports['Connection Edge: Connection with default values'] = test => {
// ImapFlow sets default values for missing options
let client = new ImapFlow({
host: 'imap.example.com',
auth: { user: 'test', pass: 'test' }
});
test.ok(client, 'Client should be created with defaults');
test.equal(client.port, 110, 'Should use default port');
test.equal(client.secureConnection, false, 'Should default to non-secure');
test.done();
};
module.exports['Connection Edge: Port number handling'] = test => {
// ImapFlow sets default port based on secure flag
let client1 = new ImapFlow({
host: 'imap.example.com',
secure: false,
auth: { user: 'test', pass: 'test' }
});
test.equal(client1.port, 110, 'Default non-secure port');
let client2 = new ImapFlow({
host: 'imap.example.com',
port: 65535,
auth: { user: 'test', pass: 'test' }
});
test.equal(client2.port, 65535, 'Custom port accepted');
test.done();
};
module.exports['Connection Edge: STARTTLS misconfiguration'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 143,
secure: true,
doSTARTTLS: true,
auth: { user: 'test', pass: 'test' }
});
// Test that upgradeToSTARTTLS throws on misconfiguration
client.upgradeToSTARTTLS().catch(err => {
test.ok(err);
test.ok(err.message.includes('Misconfiguration'), 'Should detect STARTTLS misconfiguration');
test.done();
});
};
module.exports['Connection Edge: Socket timeout handling'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Create a mock socket
client.socket = new EventEmitter();
client.socket.destroyed = false;
client.socket.destroy = () => {};
client.writeSocket = client.socket;
client.writeSocket.destroy = () => {};
let errorEmitted = false;
client.on('error', err => {
errorEmitted = true;
test.equal(err.code, 'ETIMEOUT');
});
// Set up socket handlers
client.setSocketHandlers();
// Simulate timeout on non-idle connection
client.idling = false;
client.socket.emit('timeout');
test.ok(errorEmitted, 'Timeout error should be emitted');
test.done();
};
module.exports['Connection Edge: Socket timeout during IDLE'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Create mock socket and methods
client.socket = new EventEmitter();
client.socket.destroyed = false;
client.writeSocket = client.socket;
client.usable = true;
client.idling = true;
// Mock run and idle methods
let noopCalled = false;
let idleCalled = false;
client.run = async command => {
if (command === 'NOOP') {
noopCalled = true;
return Promise.resolve();
}
};
client.idle = async () => {
idleCalled = true;
return Promise.resolve();
};
// Set up socket handlers
client.setSocketHandlers();
// Simulate timeout during IDLE
client.socket.emit('timeout');
// Give async operations time to complete
setTimeout(() => {
test.ok(noopCalled, 'NOOP should be called to recover from IDLE timeout');
test.ok(idleCalled, 'Should return to IDLE after NOOP');
test.done();
}, 100);
};
module.exports['Connection Edge: Clear socket handlers'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Create a mock socket
client.socket = new EventEmitter();
client.writeSocket = client.socket;
// Set handlers
client.setSocketHandlers();
// Verify handlers are set
test.equal(client.socket.listenerCount('error'), 1);
test.equal(client.socket.listenerCount('close'), 1);
test.equal(client.socket.listenerCount('end'), 1);
test.equal(client.socket.listenerCount('timeout'), 1);
// Clear handlers
client.clearSocketHandlers();
// Verify handlers are removed
test.equal(client.socket.listenerCount('error'), 0);
test.equal(client.socket.listenerCount('close'), 0);
test.equal(client.socket.listenerCount('end'), 0);
test.equal(client.socket.listenerCount('timeout'), 0);
test.done();
};
module.exports['Connection Edge: Write with null socket'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Set socket to null
client.socket = null;
test.throws(
() => {
client.write('TEST');
},
/Socket is already closed/,
'Should throw when socket is null'
);
test.done();
};
module.exports['Connection Edge: Compression error handling'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Create mock socket and streams
client.socket = new EventEmitter();
client.socket.pipe = () => client.socket;
client.socket.unpipe = () => {};
client.streamer = new EventEmitter();
// Mock run method to simulate successful COMPRESS negotiation
client.run = async command => {
if (command === 'COMPRESS') {
return true;
}
};
let errorEmitted = false;
client.streamer.on('error', err => {
errorEmitted = true;
test.ok(err);
});
// Call compress
client.compress().then(() => {
// Simulate compression error
if (client._inflate) {
client._inflate.emit('error', new Error('Compression failed'));
}
test.ok(errorEmitted, 'Compression error should be propagated');
test.done();
});
};
module.exports['Connection Edge: Authentication state after logout'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Set initial authenticated state
client.state = client.states.AUTHENTICATED;
client.authenticated = true;
// Simulate logout
client.state = client.states.LOGOUT;
// Try to authenticate
client.authenticate().catch(err => {
test.ok(err);
test.equal(err.message, 'Already logged out');
test.done();
});
};
module.exports['Connection Edge: Throttling detection'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Create mock request
let errorReceived = null;
let request = {
tag: 'A001',
command: 'FETCH',
resolve: () => {},
reject: err => {
errorReceived = err;
}
};
client.requestTagMap = new Map();
client.requestTagMap.set('A001', request);
// Simulate throttling response
let parsed = {
tag: 'A001',
command: 'BAD',
attributes: [
{
type: 'TEXT',
value: 'Request is throttled. Suggested Backoff Time: 5000 milliseconds'
}
]
};
// Mock streamer with proper async iterator
client.streamer = new EventEmitter();
client.streamer.read = () => null;
client.streamer.iterate = async function* () {
yield {
next: () => {},
parsed
};
};
// Process reader
client
.reader()
.then(() => {
if (errorReceived) {
test.ok(errorReceived, 'Should receive throttle error');
test.equal(errorReceived.code, 'ETHROTTLE');
test.equal(errorReceived.throttleReset, 5000);
}
test.done();
})
.catch(() => {
// Reader might fail in test environment
test.done();
});
};
module.exports['Connection Edge: Binary data in write'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Mock socket
let writtenData = null;
client.socket = { destroyed: false };
client.writeSocket = {
destroyed: false,
write: data => {
writtenData = data;
}
};
// Test writing Buffer
let testBuffer = Buffer.from('TEST');
client.write(testBuffer);
test.ok(Buffer.isBuffer(writtenData));
test.ok(writtenData.includes(testBuffer));
test.ok(writtenData.includes(Buffer.from('\r\n')));
// Test writing string
client.commandParts = [];
client.write('STRING_TEST');
test.ok(Buffer.isBuffer(writtenData));
test.ok(writtenData.includes(Buffer.from('STRING_TEST\r\n')));
test.done();
};
module.exports['Connection Edge: Invalid write data type'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Mock socket
client.socket = { destroyed: false };
client.writeSocket = { destroyed: false, write: () => {} };
// Test writing invalid data type
let result = client.write(12345);
test.equal(result, false, 'Should return false for invalid data type');
result = client.write({ test: 'object' });
test.equal(result, false, 'Should return false for object');
test.done();
};
module.exports['Connection Edge: Concurrent connections'] = test => {
let clients = [];
// Create multiple clients
for (let i = 0; i < 5; i++) {
let client = new ImapFlow({
host: `imap${i}.example.com`,
port: 993,
auth: { user: `user${i}`, pass: `pass${i}` }
});
clients.push(client);
}
// Verify each client has unique ID
let ids = clients.map(c => c.id);
let uniqueIds = [...new Set(ids)];
test.equal(uniqueIds.length, clients.length, 'All client IDs should be unique');
// Verify each client has independent state
clients[0].state = clients[0].states.AUTHENTICATED;
clients[1].state = clients[1].states.SELECTED;
test.equal(clients[0].state, clients[0].states.AUTHENTICATED);
test.equal(clients[1].state, clients[1].states.SELECTED);
test.equal(clients[2].state, clients[2].states.NOT_AUTHENTICATED);
test.done();
};
module.exports['Connection Edge: Destroyed write socket'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Mock sockets
client.socket = { destroyed: false };
client.writeSocket = { destroyed: true };
// Mock close method
let closeCalled = false;
client.close = () => {
closeCalled = true;
};
// Attempt to write
client.write('TEST');
test.ok(closeCalled, 'Should call close when write socket is destroyed');
test.done();
};
module.exports['Connection Edge: Command after logout'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Set logout state
client.state = client.states.LOGOUT;
// Create a request
let errorReceived = null;
let request = {
tag: 'A001',
reject: err => {
errorReceived = err;
}
};
client.requestTagMap = new Map();
client.requestTagMap.set('A001', request);
// Try to send command
client.send({ tag: 'A001', command: 'NOOP' }).then(() => {
test.ok(errorReceived, 'Should reject command after logout');
test.equal(errorReceived.code, 'NoConnection');
test.done();
});
};
module.exports['Connection Edge: Race condition in mailbox lock'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Initialize locks array
client.locks = [];
// Simulate concurrent lock requests
let lockPromises = [];
for (let i = 0; i < 3; i++) {
lockPromises.push(
new Promise(resolve => {
client.locks.push({
path: 'INBOX',
resolve,
promise: new Promise(() => {})
});
})
);
}
test.equal(client.locks.length, 3, 'Should queue multiple lock requests');
// Process locks sequentially
client.locks.forEach((lock, index) => {
lock.resolve({ path: 'INBOX', index });
});
Promise.all(lockPromises).then(results => {
test.equal(results.length, 3, 'All lock requests should be resolved');
test.done();
});
};
module.exports['Connection Edge: Capability update after STARTTLS'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 143,
auth: { user: 'test', pass: 'test' }
});
// Mock capabilities
client.capabilities = new Map();
client.capabilities.set('STARTTLS', true);
// Mock run method
client.run = async command => {
if (command === 'STARTTLS') {
client.expectCapabilityUpdate = true;
return true;
}
if (command === 'CAPABILITY') {
return true;
}
};
// Mock socket upgrade
client.socket = new EventEmitter();
client.socket.unpipe = () => {};
client.streamer = new EventEmitter();
// Override the upgradeToSTARTTLS to test capability update
client
.upgradeToSTARTTLS()
.then(result => {
test.ok(result, 'Should successfully upgrade to TLS');
test.ok(client.expectCapabilityUpdate, 'Should expect capability update');
test.done();
})
.catch(() => {
// Expected for this mock setup
test.done();
});
};
module.exports['Connection Edge: Event handlers attached before piping'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Track the order of operations during the connection flow
let pipeCalledBeforeHandlers = false;
let eventHandlersAttached = false;
let pipeWasCalled = false;
// Override setEventHandlers to track when it's called
let originalSetEventHandlers = client.setEventHandlers.bind(client);
client.setEventHandlers = function () {
eventHandlersAttached = true;
return originalSetEventHandlers();
};
// Create a mock socket with pipe tracking
let mockSocket = new EventEmitter();
mockSocket.setKeepAlive = () => {};
mockSocket.setTimeout = () => {};
mockSocket.remotePort = 993;
mockSocket.remoteAddress = '127.0.0.1';
mockSocket.localAddress = '127.0.0.1';
mockSocket.localPort = 12345;
mockSocket.destroyed = false;
mockSocket.pipe = function (dest) {
pipeWasCalled = true;
if (!eventHandlersAttached) {
pipeCalledBeforeHandlers = true;
}
// Mock pipe behavior - just return the destination
return dest;
};
// Assign mock socket
client.socket = mockSocket;
client.writeSocket = mockSocket;
// Simulate the onConnect flow that happens in the actual code
client.setSocketHandlers();
client.setEventHandlers();
client.socket.pipe(client.streamer);
test.ok(eventHandlersAttached, 'Event handlers should be attached');
test.ok(pipeWasCalled, 'Socket pipe should be called');
test.ok(!pipeCalledBeforeHandlers, 'Event handlers should be attached before piping socket to streamer');
test.done();
};
module.exports['Connection Edge: Pending locks rejected on close'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Initialize locks array with pending locks
let rejectedErrors = [];
client.locks = [
{
path: 'INBOX',
lockId: 'lock1',
resolve: () => {},
reject: err => rejectedErrors.push(err)
},
{
path: 'Sent',
lockId: 'lock2',
resolve: () => {},
reject: err => rejectedErrors.push(err)
}
];
// Mock socket to allow close() to run
client.socket = { destroyed: false, destroy: () => {} };
client.usable = true;
// Call close
client.close();
// Rejections happen via setImmediate, so check after next tick
setImmediate(() => {
test.equal(rejectedErrors.length, 2, 'All pending locks should be rejected');
test.equal(rejectedErrors[0].code, 'NoConnection', 'Error should have NoConnection code');
test.equal(rejectedErrors[1].code, 'NoConnection', 'Error should have NoConnection code');
test.equal(client.locks.length, 0, 'Locks array should be cleared');
test.done();
});
};
module.exports['Connection Edge: Lock rejection includes byeReason'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Set byeReason before close
client.byeReason = 'Server shutting down';
let rejectedError = null;
client.locks = [
{
path: 'INBOX',
lockId: 'lock1',
resolve: () => {},
reject: err => {
rejectedError = err;
}
}
];
client.socket = { destroyed: false, destroy: () => {} };
client.usable = true;
client.close();
setImmediate(() => {
test.ok(rejectedError, 'Lock should be rejected');
test.equal(rejectedError.code, 'NoConnection');
test.equal(rejectedError.reason, 'Server shutting down', 'byeReason should be included');
test.done();
});
};
module.exports['Connection Edge: currentLock cleared on close'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Simulate an active lock
client.currentLock = {
path: 'INBOX',
lockId: 'active-lock',
release: () => {}
};
client.locks = [];
client.socket = { destroyed: false, destroy: () => {} };
client.usable = true;
client.close();
test.equal(client.currentLock, false, 'currentLock should be cleared');
test.done();
};
module.exports['Connection Edge: Lock rejection handles missing reject function'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
// Create lock with missing reject function (edge case)
let validRejected = false;
client.locks = [
{
path: 'INBOX',
lockId: 'lock1',
resolve: () => {}
// No reject function
},
{
path: 'Sent',
lockId: 'lock2',
resolve: () => {},
reject: () => {
validRejected = true;
}
}
];
client.socket = { destroyed: false, destroy: () => {} };
client.usable = true;
// Should not throw
test.doesNotThrow(() => {
client.close();
}, 'Should handle missing reject function gracefully');
setImmediate(() => {
test.ok(validRejected, 'Valid lock should still be rejected');
test.equal(client.locks.length, 0, 'Locks array should be cleared');
test.done();
});
};
module.exports['Connection Edge: Close with empty locks array'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
client.locks = [];
client.currentLock = false;
client.socket = { destroyed: false, destroy: () => {} };
client.usable = true;
// Should not throw with empty locks
test.doesNotThrow(() => {
client.close();
}, 'Should handle empty locks array');
test.equal(client.currentLock, false);
test.done();
};
module.exports['Connection Edge: Lock rejection is deferred via setImmediate'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
let rejectionTime = null;
let closeTime = null;
client.locks = [
{
path: 'INBOX',
lockId: 'lock1',
resolve: () => {},
reject: () => {
rejectionTime = Date.now();
}
}
];
client.socket = { destroyed: false, destroy: () => {} };
client.usable = true;
client.close();
closeTime = Date.now();
// Rejection should not have happened yet (synchronously)
test.equal(rejectionTime, null, 'Rejection should be deferred');
setImmediate(() => {
test.ok(rejectionTime !== null, 'Rejection should happen after setImmediate');
test.ok(rejectionTime >= closeTime, 'Rejection should happen after close()');
test.done();
});
};
module.exports['Connection Edge: Pending requests and locks both rejected on close'] = test => {
let client = new ImapFlow({
host: 'imap.example.com',
port: 993,
auth: { user: 'test', pass: 'test' }
});
let requestRejected = false;
let lockRejected = false;
// Add pending request - must be in requestQueue and requestTagMap
let request = {
tag: 'A001',
reject: err => {
requestRejected = true;
test.equal(err.code, 'NoConnection');
}
};
client.requestTagMap = new Map();
client.requestTagMap.set('A001', request);
client.requestQueue = [request];
// Add pending lock
client.locks = [
{
path: 'INBOX',
lockId: 'lock1',
resolve: () => {},
reject: err => {
lockRejected = true;
test.equal(err.code, 'NoConnection');
}
}
];
client.socket = { destroyed: false, destroy: () => {} };
client.usable = true;
client.close();
setImmediate(() => {
test.ok(requestRejected, 'Pending request should be rejected');
test.ok(lockRejected, 'Pending lock should be rejected');
test.done();
});
};