imapflow 1.3.6 → 1.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/lib/commands/authenticate.js +5 -5
- package/lib/commands/fetch.js +1 -0
- package/lib/commands/logout.js +1 -0
- package/lib/commands/select.js +2 -1
- package/lib/commands/store.js +1 -0
- package/lib/handler/token-parser.js +0 -5
- package/lib/imap-flow.js +36 -6
- package/lib/tools.js +8 -0
- package/package.json +1 -1
- package/test/commands-branches-test.js +1068 -0
- package/test/fixtures/test-tls.js +8 -0
- package/test/handler-branches-test.js +334 -0
- package/test/imap-compiler-test.js +47 -0
- package/test/imap-flow-compress-test.js +154 -0
- package/test/imap-flow-coverage-test.js +609 -0
- package/test/imap-flow-fetch-download-test.js +801 -0
- package/test/imap-flow-internals-test.js +450 -0
- package/test/imap-flow-methods-test.js +738 -0
- package/test/imap-flow-proxy-paths-test.js +215 -0
- package/test/imap-flow-secure-test.js +327 -0
- package/test/imap-flow-server-test.js +1046 -0
- package/test/imap-formal-syntax-test.js +18 -0
- package/test/imap-parser-test.js +52 -0
- package/test/imap-stream-edge-cases-test.js +64 -0
- package/test/jp-decoder-test.js +48 -0
- package/test/limited-passthrough-test.js +17 -0
- package/test/search-compiler-test.js +31 -0
- package/test/search-test.js +61 -0
- package/test/token-parser-test.js +64 -0
- package/test/tools-test.js +360 -0
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* eslint-disable new-cap */
|
|
4
|
+
// BigInt() is a standard JS function but triggers new-cap rule
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Additional branch-coverage tests for lib/commands/*.js
|
|
8
|
+
//
|
|
9
|
+
// Each test targets one or more specific uncovered branches identified via c8.
|
|
10
|
+
// The mock connection factory mirrors the one in commands-integration-test.js.
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
const createMockConnection = (overrides = {}) => {
|
|
14
|
+
const states = {
|
|
15
|
+
NOT_AUTHENTICATED: 1,
|
|
16
|
+
AUTHENTICATED: 2,
|
|
17
|
+
SELECTED: 3,
|
|
18
|
+
LOGOUT: 4
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const defaultMailbox = {
|
|
22
|
+
path: 'INBOX',
|
|
23
|
+
flags: new Set(['\\Seen', '\\Answered', '\\Flagged', '\\Deleted', '\\Draft']),
|
|
24
|
+
permanentFlags: new Set(['\\*']),
|
|
25
|
+
exists: 100,
|
|
26
|
+
recent: 5,
|
|
27
|
+
uidNext: 1000,
|
|
28
|
+
uidValidity: BigInt(12345),
|
|
29
|
+
noModseq: false
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
states,
|
|
34
|
+
state: overrides.state || states.SELECTED,
|
|
35
|
+
id: 'test-connection-id',
|
|
36
|
+
capabilities: new Map(overrides.capabilities || [['IMAP4rev1', true]]),
|
|
37
|
+
enabled: new Set(overrides.enabled || []),
|
|
38
|
+
authCapabilities: new Map(),
|
|
39
|
+
folders: overrides.folders || new Map(),
|
|
40
|
+
mailbox: overrides.mailbox || { ...defaultMailbox },
|
|
41
|
+
namespace: overrides.namespace || { delimiter: '/', prefix: '' },
|
|
42
|
+
expectCapabilityUpdate: overrides.expectCapabilityUpdate || false,
|
|
43
|
+
log: {
|
|
44
|
+
warn: () => {},
|
|
45
|
+
info: () => {},
|
|
46
|
+
error: () => {},
|
|
47
|
+
debug: () => {},
|
|
48
|
+
trace: () => {}
|
|
49
|
+
},
|
|
50
|
+
close: overrides.close || (() => {}),
|
|
51
|
+
emit: overrides.emit || (() => {}),
|
|
52
|
+
write: overrides.write || (() => {}),
|
|
53
|
+
currentSelectCommand: false,
|
|
54
|
+
messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
|
|
55
|
+
messageCopy: overrides.messageCopy || (async () => {}),
|
|
56
|
+
messageDelete: overrides.messageDelete || (async () => {}),
|
|
57
|
+
run: overrides.run || (async () => {}),
|
|
58
|
+
exec:
|
|
59
|
+
overrides.exec ||
|
|
60
|
+
(async () => ({
|
|
61
|
+
next: () => {},
|
|
62
|
+
response: { attributes: [] }
|
|
63
|
+
})),
|
|
64
|
+
...overrides
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// copy.js — line 22 branch: `options = options || {}` with falsy options
|
|
70
|
+
// Call copy with range + destination but no options argument so the `|| {}`
|
|
71
|
+
// right-hand side is taken.
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
const copyCommand = require('../lib/commands/copy');
|
|
75
|
+
|
|
76
|
+
module.exports['Branches: copy with undefined options uses {} fallback'] = async test => {
|
|
77
|
+
let execCmd = null;
|
|
78
|
+
const connection = createMockConnection({
|
|
79
|
+
state: 3,
|
|
80
|
+
exec: async cmd => {
|
|
81
|
+
execCmd = cmd;
|
|
82
|
+
return { next: () => {}, response: { attributes: [] } };
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// No options argument -> options is undefined -> `options || {}` fallback
|
|
87
|
+
const result = await copyCommand(connection, '1:5', 'Archive');
|
|
88
|
+
test.ok(result);
|
|
89
|
+
test.equal(execCmd, 'COPY'); // not UID COPY, since options.uid is undefined
|
|
90
|
+
test.done();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// move.js — line 22 branch: `options = options || {}` with falsy options
|
|
95
|
+
// MOVE capability present, no options argument.
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
const moveCommand = require('../lib/commands/move');
|
|
99
|
+
|
|
100
|
+
module.exports['Branches: move with undefined options uses {} fallback'] = async test => {
|
|
101
|
+
let execCmd = null;
|
|
102
|
+
const connection = createMockConnection({
|
|
103
|
+
state: 3,
|
|
104
|
+
capabilities: new Map([['MOVE', true]]),
|
|
105
|
+
exec: async cmd => {
|
|
106
|
+
execCmd = cmd;
|
|
107
|
+
return { next: () => {}, response: { attributes: [] } };
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const result = await moveCommand(connection, '1:5', 'Archive');
|
|
112
|
+
test.ok(result);
|
|
113
|
+
test.equal(execCmd, 'MOVE');
|
|
114
|
+
test.done();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// expunge.js — line 20 branch: `options = options || {}` with falsy options
|
|
119
|
+
// Call expunge with range but no options argument.
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
const expungeCommand = require('../lib/commands/expunge');
|
|
123
|
+
|
|
124
|
+
module.exports['Branches: expunge with undefined options uses {} fallback'] = async test => {
|
|
125
|
+
let execCmd = null;
|
|
126
|
+
const connection = createMockConnection({
|
|
127
|
+
state: 3,
|
|
128
|
+
exec: async cmd => {
|
|
129
|
+
execCmd = cmd;
|
|
130
|
+
return { next: () => {}, response: { attributes: [] } };
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// No options argument -> options || {} fallback; byUid false -> plain EXPUNGE
|
|
135
|
+
const result = await expungeCommand(connection, '1:5');
|
|
136
|
+
test.equal(result, true);
|
|
137
|
+
test.equal(execCmd, 'EXPUNGE');
|
|
138
|
+
test.done();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// store.js — line 56 branch: `: [].concat(flags || [])` — flags passed as a
|
|
143
|
+
// single (non-array) value, so the Array.isArray() ternary takes the false side.
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
const storeCommand = require('../lib/commands/store');
|
|
147
|
+
|
|
148
|
+
module.exports['Branches: store with a single non-array flag value'] = async test => {
|
|
149
|
+
let storedFlags = null;
|
|
150
|
+
const connection = createMockConnection({
|
|
151
|
+
state: 3,
|
|
152
|
+
mailbox: { path: 'INBOX', flags: new Set(['\\Seen']), permanentFlags: new Set(['\\Seen']) },
|
|
153
|
+
exec: async (cmd, attrs) => {
|
|
154
|
+
// attrs[2] is the flags list
|
|
155
|
+
storedFlags = attrs[2].map(a => a.value);
|
|
156
|
+
return { next: () => {}, response: { attributes: [] } };
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// flags is a single string, not an array -> exercises `[].concat(flags || [])`
|
|
161
|
+
const result = await storeCommand(connection, '1:5', '\\Seen', { operation: 'add' });
|
|
162
|
+
test.equal(result, true);
|
|
163
|
+
test.deepEqual(storedFlags, ['\\Seen']);
|
|
164
|
+
test.done();
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
module.exports['Branches: store with falsy flags and set operation uses [] fallback'] = async test => {
|
|
168
|
+
let storedFlags = null;
|
|
169
|
+
const connection = createMockConnection({
|
|
170
|
+
state: 3,
|
|
171
|
+
mailbox: { path: 'INBOX', flags: new Set(['\\Seen']), permanentFlags: new Set(['\\Seen']) },
|
|
172
|
+
exec: async (cmd, attrs) => {
|
|
173
|
+
storedFlags = attrs[2].map(a => a.value);
|
|
174
|
+
return { next: () => {}, response: { attributes: [] } };
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// flags is undefined (falsy, non-array) -> exercises the `flags || []` fallback in
|
|
179
|
+
// `[].concat(flags || [])`. Empty flags are allowed for the 'set' operation (clears all).
|
|
180
|
+
const result = await storeCommand(connection, '1:5', undefined, { operation: 'set' });
|
|
181
|
+
test.equal(result, true);
|
|
182
|
+
test.deepEqual(storedFlags, []);
|
|
183
|
+
test.done();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// status.js — line 30 branch: `Object.keys(query || {})` with undefined query
|
|
188
|
+
// Call status with no query argument so the `|| {}` fallback runs. With no
|
|
189
|
+
// query items, queryAttributes is empty and the command returns false.
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
const statusCommand = require('../lib/commands/status');
|
|
193
|
+
|
|
194
|
+
module.exports['Branches: status with undefined query uses {} fallback'] = async test => {
|
|
195
|
+
let execCalled = false;
|
|
196
|
+
const connection = createMockConnection({
|
|
197
|
+
state: 2,
|
|
198
|
+
exec: async () => {
|
|
199
|
+
execCalled = true;
|
|
200
|
+
return { next: () => {} };
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// query undefined -> Object.keys(query || {}) -> no attributes -> returns false
|
|
205
|
+
const result = await statusCommand(connection, 'INBOX');
|
|
206
|
+
test.equal(result, false);
|
|
207
|
+
test.equal(execCalled, false);
|
|
208
|
+
test.done();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// ============================================================================
|
|
212
|
+
// append.js — line 110 branch: responseCode `: ''` fallback.
|
|
213
|
+
// Tagged response has a section with length but section[0] has no string value,
|
|
214
|
+
// so the ternary picks '' and APPENDUID parsing is skipped.
|
|
215
|
+
// ============================================================================
|
|
216
|
+
|
|
217
|
+
const appendCommand = require('../lib/commands/append');
|
|
218
|
+
|
|
219
|
+
module.exports['Branches: append tagged section with non-string code uses empty fallback'] = async test => {
|
|
220
|
+
const connection = createMockConnection({
|
|
221
|
+
state: 2,
|
|
222
|
+
mailbox: { path: 'INBOX', exists: 0, flags: new Set(), permanentFlags: new Set() },
|
|
223
|
+
exec: async () => ({
|
|
224
|
+
next: () => {},
|
|
225
|
+
// section has length, but section[0].value is not a string -> responseCode = ''
|
|
226
|
+
response: { attributes: [{ section: [{ value: 123 }, { value: '99' }] }] }
|
|
227
|
+
}),
|
|
228
|
+
// No EXISTS so map.seq stays unset; no UID search needed
|
|
229
|
+
search: async () => []
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const result = await appendCommand(connection, 'Sent', 'Subject: test\r\n\r\nbody');
|
|
233
|
+
test.ok(result);
|
|
234
|
+
test.equal(result.destination, 'Sent');
|
|
235
|
+
// APPENDUID not parsed because responseCode === ''
|
|
236
|
+
test.equal(result.uid, undefined);
|
|
237
|
+
test.done();
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// logout.js — line 33 / finally branch: successful LOGOUT where `response`
|
|
242
|
+
// exists and response.next is a function (the `response && typeof ...` true
|
|
243
|
+
// side of the finally cleanup). Ensures the try succeeds (return true) and the
|
|
244
|
+
// finally runs response.next() + close().
|
|
245
|
+
// ============================================================================
|
|
246
|
+
|
|
247
|
+
const logoutCommand = require('../lib/commands/logout');
|
|
248
|
+
|
|
249
|
+
module.exports['Branches: logout success runs response.next in finally'] = async test => {
|
|
250
|
+
let nextCalled = false;
|
|
251
|
+
let closeCalled = false;
|
|
252
|
+
const connection = createMockConnection({
|
|
253
|
+
state: 2,
|
|
254
|
+
exec: async () => ({
|
|
255
|
+
next: () => {
|
|
256
|
+
nextCalled = true;
|
|
257
|
+
}
|
|
258
|
+
}),
|
|
259
|
+
close: () => {
|
|
260
|
+
closeCalled = true;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const result = await logoutCommand(connection);
|
|
265
|
+
test.equal(result, true);
|
|
266
|
+
test.equal(nextCalled, true);
|
|
267
|
+
test.equal(closeCalled, true);
|
|
268
|
+
test.equal(connection.state, connection.states.LOGOUT);
|
|
269
|
+
test.done();
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
module.exports['Branches: logout success with response lacking next() skips next call'] = async test => {
|
|
273
|
+
let closeCalled = false;
|
|
274
|
+
const connection = createMockConnection({
|
|
275
|
+
state: 2,
|
|
276
|
+
// exec resolves with a truthy response object that has NO next function,
|
|
277
|
+
// exercising the `typeof response.next === 'function'` false side in finally.
|
|
278
|
+
exec: async () => ({ response: { attributes: [] } }),
|
|
279
|
+
close: () => {
|
|
280
|
+
closeCalled = true;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const result = await logoutCommand(connection);
|
|
285
|
+
test.equal(result, true);
|
|
286
|
+
test.equal(closeCalled, true);
|
|
287
|
+
test.equal(connection.state, connection.states.LOGOUT);
|
|
288
|
+
test.done();
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// ============================================================================
|
|
292
|
+
// namespace.js — line 98 branch: getListPrefix LIST untagged where
|
|
293
|
+
// attributes[2] is missing, so prefix `|| ''` fallback is taken.
|
|
294
|
+
// Triggered via the no-NAMESPACE-capability fallback path.
|
|
295
|
+
// ============================================================================
|
|
296
|
+
|
|
297
|
+
const namespaceCommand = require('../lib/commands/namespace');
|
|
298
|
+
|
|
299
|
+
module.exports['Branches: namespace fallback LIST without prefix uses empty string'] = async test => {
|
|
300
|
+
const connection = createMockConnection({
|
|
301
|
+
state: 2,
|
|
302
|
+
capabilities: new Map([['IMAP4rev1', true]]), // no NAMESPACE -> getListPrefix path
|
|
303
|
+
exec: async (cmd, attrs, opts) => {
|
|
304
|
+
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
305
|
+
// attributes[0]=flags, [1]=delimiter, but NO [2] -> prefix falls back to ''
|
|
306
|
+
await opts.untagged.LIST({
|
|
307
|
+
attributes: [[{ value: '\\Noselect' }], { value: '/' }]
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return { next: () => {} };
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const result = await namespaceCommand(connection);
|
|
315
|
+
test.ok(result);
|
|
316
|
+
test.equal(result.prefix, '');
|
|
317
|
+
test.equal(result.delimiter, '/');
|
|
318
|
+
test.done();
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// ============================================================================
|
|
322
|
+
// quota.js — line 88 branch: QUOTAROOT untagged where attributes[1] is missing,
|
|
323
|
+
// so quotaRoot resolves to `false` and map.quotaRoot is not set.
|
|
324
|
+
// ============================================================================
|
|
325
|
+
|
|
326
|
+
const quotaCommand = require('../lib/commands/quota');
|
|
327
|
+
|
|
328
|
+
module.exports['Branches: quota QUOTAROOT without root name yields false'] = async test => {
|
|
329
|
+
const connection = createMockConnection({
|
|
330
|
+
state: 2,
|
|
331
|
+
capabilities: new Map([['QUOTA', true]]),
|
|
332
|
+
exec: async (cmd, attrs, opts) => {
|
|
333
|
+
if (cmd === 'GETQUOTAROOT' && opts && opts.untagged) {
|
|
334
|
+
if (opts.untagged.QUOTAROOT) {
|
|
335
|
+
// attributes[1] missing -> quotaRoot = false -> map.quotaRoot stays unset
|
|
336
|
+
await opts.untagged.QUOTAROOT({ attributes: [{ value: 'INBOX' }] });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return { next: () => {} };
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const result = await quotaCommand(connection, 'INBOX');
|
|
344
|
+
test.ok(result);
|
|
345
|
+
test.equal(result.path, 'INBOX');
|
|
346
|
+
test.equal(result.quotaRoot, undefined);
|
|
347
|
+
test.done();
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// fetch.js — line 234 branch: ETHROTTLE retry where err.throttleReset is set
|
|
352
|
+
// and larger than the computed backoff, so the `err.throttleReset` side of the
|
|
353
|
+
// delay ternary is taken. First exec throws ETHROTTLE, second succeeds.
|
|
354
|
+
// throttleReset is just above the 1000ms backoff to keep the test fast.
|
|
355
|
+
// ============================================================================
|
|
356
|
+
|
|
357
|
+
const fetchCommand = require('../lib/commands/fetch');
|
|
358
|
+
|
|
359
|
+
module.exports['Branches: fetch ETHROTTLE uses throttleReset delay then retries'] = async test => {
|
|
360
|
+
let calls = 0;
|
|
361
|
+
const connection = createMockConnection({
|
|
362
|
+
state: 3,
|
|
363
|
+
mailbox: { path: 'INBOX', exists: 1, flags: new Set(), permanentFlags: new Set(), noModseq: true },
|
|
364
|
+
exec: async () => {
|
|
365
|
+
calls++;
|
|
366
|
+
if (calls === 1) {
|
|
367
|
+
const err = new Error('throttled');
|
|
368
|
+
err.code = 'ETHROTTLE';
|
|
369
|
+
err.throttleReset = 1001; // > backoffDelay(1000) -> throttleReset branch taken
|
|
370
|
+
err.responseText = 'throttled';
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
return { next: () => {}, response: { attributes: [] } };
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const result = await fetchCommand(connection, '1:*', { uid: true }, { uid: true });
|
|
378
|
+
test.ok(result);
|
|
379
|
+
test.equal(calls, 2);
|
|
380
|
+
test.done();
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// ============================================================================
|
|
384
|
+
// search.js — line 98 branch: `options = options || {}` with undefined options.
|
|
385
|
+
// Plain SEARCH ALL call without an options argument.
|
|
386
|
+
// ============================================================================
|
|
387
|
+
|
|
388
|
+
const searchCommand = require('../lib/commands/search');
|
|
389
|
+
|
|
390
|
+
module.exports['Branches: search with undefined options uses {} fallback'] = async test => {
|
|
391
|
+
let execCmd = null;
|
|
392
|
+
const connection = createMockConnection({
|
|
393
|
+
state: 3,
|
|
394
|
+
exec: async (cmd, attrs, opts) => {
|
|
395
|
+
execCmd = cmd;
|
|
396
|
+
if (opts && opts.untagged && opts.untagged.SEARCH) {
|
|
397
|
+
await opts.untagged.SEARCH({ attributes: [{ value: '1' }, { value: '2' }] });
|
|
398
|
+
}
|
|
399
|
+
return { next: () => {} };
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// No options argument -> options || {} fallback; not UID -> plain SEARCH
|
|
404
|
+
const result = await searchCommand(connection, true);
|
|
405
|
+
test.deepEqual(result, [1, 2]);
|
|
406
|
+
test.equal(execCmd, 'SEARCH');
|
|
407
|
+
test.done();
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// ============================================================================
|
|
411
|
+
// search.js — line 141 branch: `: 'SEARCH'` side of ESEARCH command name.
|
|
412
|
+
// ESEARCH path with options.uid falsy.
|
|
413
|
+
// search.js — line 150 branch: `|| attrs[start].type === 'LIST'` — the leading
|
|
414
|
+
// tag token is an object with type 'LIST' (not a plain Array), so the second
|
|
415
|
+
// operand of the OR is evaluated and used to skip it.
|
|
416
|
+
// ============================================================================
|
|
417
|
+
|
|
418
|
+
module.exports['Branches: search ESEARCH without uid emits SEARCH and skips LIST-typed tag'] = async test => {
|
|
419
|
+
let execCmd = null;
|
|
420
|
+
const connection = createMockConnection({
|
|
421
|
+
state: 3,
|
|
422
|
+
capabilities: new Map([['ESEARCH', true]]),
|
|
423
|
+
exec: async (cmd, attrs, opts) => {
|
|
424
|
+
execCmd = cmd;
|
|
425
|
+
if (opts && opts.untagged && opts.untagged.ESEARCH) {
|
|
426
|
+
// attrs[0] is a LIST-typed object (not a plain Array) -> exercises
|
|
427
|
+
// the `attrs[start].type === 'LIST'` branch on line 150.
|
|
428
|
+
await opts.untagged.ESEARCH({
|
|
429
|
+
attributes: [{ type: 'LIST' }, { type: 'ATOM', value: 'COUNT' }, { type: 'ATOM', value: '7' }]
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return { next: () => {} };
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// returnOptions present + ESEARCH capability + options.uid falsy
|
|
437
|
+
const result = await searchCommand(connection, true, { returnOptions: ['COUNT'] });
|
|
438
|
+
test.equal(execCmd, 'SEARCH'); // not 'UID SEARCH'
|
|
439
|
+
test.equal(result.count, 7);
|
|
440
|
+
test.done();
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// ============================================================================
|
|
444
|
+
// search.js — line 59 `: null` and line 60 `break;` branches:
|
|
445
|
+
// parseEsearchResponse PARTIAL where the value token is neither a plain Array
|
|
446
|
+
// nor an object with an `.attributes` array, so `items` is null and the loop
|
|
447
|
+
// breaks without setting result.partial.
|
|
448
|
+
// ============================================================================
|
|
449
|
+
|
|
450
|
+
module.exports['Branches: parseEsearchResponse PARTIAL with non-list value is ignored'] = test => {
|
|
451
|
+
const parse = searchCommand.parseEsearchResponse;
|
|
452
|
+
// PARTIAL followed by a plain ATOM (no array, no .attributes) -> items === null -> break
|
|
453
|
+
const result = parse([
|
|
454
|
+
{ type: 'ATOM', value: 'PARTIAL' },
|
|
455
|
+
{ type: 'ATOM', value: 'notalist' }
|
|
456
|
+
]);
|
|
457
|
+
test.equal(result.partial, undefined);
|
|
458
|
+
test.done();
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// ============================================================================
|
|
462
|
+
// select.js — line 38 branch: `: false` — after running LIST, the requested
|
|
463
|
+
// path is still not present in connection.folders, so folderListData is false.
|
|
464
|
+
// ============================================================================
|
|
465
|
+
|
|
466
|
+
const selectCommand = require('../lib/commands/select');
|
|
467
|
+
|
|
468
|
+
module.exports['Branches: select with folder list missing requested path sets folderListData false'] = async test => {
|
|
469
|
+
const connection = createMockConnection({
|
|
470
|
+
state: 2,
|
|
471
|
+
folders: new Map(),
|
|
472
|
+
run: async cmd => {
|
|
473
|
+
if (cmd === 'LIST') {
|
|
474
|
+
// Returns a folder that is NOT the requested 'INBOX' path
|
|
475
|
+
return [{ path: 'Other', delimiter: '/' }];
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
exec: async () => ({
|
|
479
|
+
next: () => {},
|
|
480
|
+
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
481
|
+
}),
|
|
482
|
+
emit: () => {}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const result = await selectCommand(connection, 'INBOX');
|
|
486
|
+
test.ok(result);
|
|
487
|
+
test.equal(result.path, 'INBOX');
|
|
488
|
+
// No delimiter/specialUse copied since folderListData was false
|
|
489
|
+
test.equal(result.delimiter, undefined);
|
|
490
|
+
test.done();
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// ============================================================================
|
|
494
|
+
// select.js — line 97 `: false` (PERMANENTFLAGS list entry not a string) and
|
|
495
|
+
// line 170 `: false` (FLAGS list entry not a string).
|
|
496
|
+
// ============================================================================
|
|
497
|
+
|
|
498
|
+
module.exports['Branches: select filters non-string flags in FLAGS and PERMANENTFLAGS'] = async test => {
|
|
499
|
+
const connection = createMockConnection({
|
|
500
|
+
state: 2,
|
|
501
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
502
|
+
run: async () => [],
|
|
503
|
+
exec: async (cmd, attrs, opts) => {
|
|
504
|
+
if (opts && opts.untagged) {
|
|
505
|
+
if (opts.untagged.FLAGS) {
|
|
506
|
+
// A list containing a non-string flag (numeric value)
|
|
507
|
+
// -> exercises the `: false` filter on line 170.
|
|
508
|
+
await opts.untagged.FLAGS({ attributes: [[{ value: '\\Seen' }, { value: 42 }]] });
|
|
509
|
+
}
|
|
510
|
+
if (opts.untagged.OK) {
|
|
511
|
+
// PERMANENTFLAGS list with a non-string entry -> line 97 `: false`
|
|
512
|
+
await opts.untagged.OK({
|
|
513
|
+
attributes: [{ section: [{ type: 'ATOM', value: 'PERMANENTFLAGS' }, [{ value: '\\*' }, { value: 7 }]] }]
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
next: () => {},
|
|
519
|
+
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
520
|
+
};
|
|
521
|
+
},
|
|
522
|
+
emit: () => {}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const result = await selectCommand(connection, 'INBOX');
|
|
526
|
+
test.ok(result);
|
|
527
|
+
// Non-string flag filtered out
|
|
528
|
+
test.ok(result.flags.has('\\Seen'));
|
|
529
|
+
test.equal(result.flags.size, 1);
|
|
530
|
+
// Non-string permanent flag filtered out
|
|
531
|
+
test.ok(result.permanentFlags.has('\\*'));
|
|
532
|
+
test.equal(result.permanentFlags.size, 1);
|
|
533
|
+
test.done();
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// Regression: a malformed `* FLAGS` untagged response (empty attributes, or a
|
|
537
|
+
// non-array attributes[0]) must be ignored, not crash on `attributes[0].map(...)`.
|
|
538
|
+
module.exports['Branches: select FLAGS guard ignores malformed attributes without throwing'] = async test => {
|
|
539
|
+
test.expect(4);
|
|
540
|
+
|
|
541
|
+
const makeConn = flagsAttributes =>
|
|
542
|
+
createMockConnection({
|
|
543
|
+
state: 2,
|
|
544
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
545
|
+
run: async () => [],
|
|
546
|
+
exec: async (cmd, attrs, opts) => {
|
|
547
|
+
if (opts && opts.untagged && opts.untagged.FLAGS) {
|
|
548
|
+
await opts.untagged.FLAGS({ attributes: flagsAttributes });
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
next: () => {},
|
|
552
|
+
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
553
|
+
};
|
|
554
|
+
},
|
|
555
|
+
emit: () => {}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// Empty attributes (server sent `* FLAGS` with no list): bail on the length guard.
|
|
559
|
+
const emptyResult = await selectCommand(makeConn([]), 'INBOX');
|
|
560
|
+
test.ok(emptyResult);
|
|
561
|
+
test.equal(emptyResult.flags, undefined);
|
|
562
|
+
|
|
563
|
+
// attributes[0] present but not an array: bail on the Array.isArray guard.
|
|
564
|
+
const nonArrayResult = await selectCommand(makeConn([{ value: '\\Seen' }]), 'INBOX');
|
|
565
|
+
test.ok(nonArrayResult);
|
|
566
|
+
test.equal(nonArrayResult.flags, undefined);
|
|
567
|
+
|
|
568
|
+
test.done();
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
// ============================================================================
|
|
572
|
+
// list.js — multiple branches.
|
|
573
|
+
// ============================================================================
|
|
574
|
+
|
|
575
|
+
const listCommand = require('../lib/commands/list');
|
|
576
|
+
|
|
577
|
+
// list.js line 47 branch: statusQuery key with a falsy value -> `return;`.
|
|
578
|
+
module.exports['Branches: list statusQuery with falsy value is skipped'] = async test => {
|
|
579
|
+
const connection = createMockConnection({
|
|
580
|
+
state: 2,
|
|
581
|
+
capabilities: new Map([
|
|
582
|
+
['LIST-STATUS', true],
|
|
583
|
+
['SPECIAL-USE', true]
|
|
584
|
+
]),
|
|
585
|
+
exec: async (cmd, attrs, opts) => {
|
|
586
|
+
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
587
|
+
await opts.untagged.LIST({
|
|
588
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
return { next: () => {} };
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// messages:false is skipped (line 47), unseen:true is kept
|
|
596
|
+
const result = await listCommand(connection, '', '*', {
|
|
597
|
+
statusQuery: { messages: false, unseen: true }
|
|
598
|
+
});
|
|
599
|
+
test.ok(Array.isArray(result));
|
|
600
|
+
test.done();
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// list.js lines 128/129 (LIST untagged attributes[2] missing -> '' fallbacks),
|
|
604
|
+
// 162 (parentPath '' when no delimiter) and 163 (parent [entry.path] when no
|
|
605
|
+
// delimiter). LIST entry with a NIL delimiter and no name attribute.
|
|
606
|
+
module.exports['Branches: list LIST entry without delimiter or name uses fallbacks'] = async test => {
|
|
607
|
+
const connection = createMockConnection({
|
|
608
|
+
state: 2,
|
|
609
|
+
exec: async (cmd, attrs, opts) => {
|
|
610
|
+
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
611
|
+
// attributes[1] (delimiter) is NIL (null) and attributes[2] (name) missing
|
|
612
|
+
await opts.untagged.LIST({
|
|
613
|
+
attributes: [[{ value: '\\Noselect' }], null]
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return { next: () => {} };
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const result = await listCommand(connection, '', '*');
|
|
621
|
+
test.ok(Array.isArray(result));
|
|
622
|
+
test.equal(result.length, 1);
|
|
623
|
+
// path defaulted to '' (no attributes[2]); no delimiter -> parentPath '' and parent ['']
|
|
624
|
+
test.equal(result[0].path, '');
|
|
625
|
+
test.equal(result[0].parentPath, '');
|
|
626
|
+
test.done();
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// list.js line 184 (`: false` when STATUS attributes[1] not an array) and
|
|
630
|
+
// line 185 (`if (!statusList || !statusPath) return;`).
|
|
631
|
+
module.exports['Branches: list STATUS with non-array values is ignored'] = async test => {
|
|
632
|
+
const connection = createMockConnection({
|
|
633
|
+
state: 2,
|
|
634
|
+
capabilities: new Map([
|
|
635
|
+
['LIST-STATUS', true],
|
|
636
|
+
['SPECIAL-USE', true]
|
|
637
|
+
]),
|
|
638
|
+
exec: async (cmd, attrs, opts) => {
|
|
639
|
+
if (cmd === 'LIST' && opts && opts.untagged) {
|
|
640
|
+
if (opts.untagged.LIST) {
|
|
641
|
+
await opts.untagged.LIST({
|
|
642
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
if (opts.untagged.STATUS) {
|
|
646
|
+
// attributes[0] missing -> statusPath '' fallback (line 183); and
|
|
647
|
+
// attributes[1] is not an array -> statusList false -> early return (line 185)
|
|
648
|
+
await opts.untagged.STATUS({ attributes: [undefined, { value: 'notanarray' }] });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return { next: () => {} };
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
|
|
656
|
+
test.ok(Array.isArray(result));
|
|
657
|
+
// No status attached because the STATUS untagged response was ignored
|
|
658
|
+
const inbox = result.find(e => e.path === 'INBOX');
|
|
659
|
+
test.ok(inbox);
|
|
660
|
+
test.done();
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
// list.js line 203 (`: false` when STATUS key value is not a string) and
|
|
664
|
+
// line 206 (`if (!key || !entry || typeof entry.value !== 'string') return;`).
|
|
665
|
+
module.exports['Branches: list STATUS with non-string key/value pairs skipped'] = async test => {
|
|
666
|
+
const connection = createMockConnection({
|
|
667
|
+
state: 2,
|
|
668
|
+
capabilities: new Map([
|
|
669
|
+
['LIST-STATUS', true],
|
|
670
|
+
['SPECIAL-USE', true]
|
|
671
|
+
]),
|
|
672
|
+
exec: async (cmd, attrs, opts) => {
|
|
673
|
+
if (cmd === 'LIST' && opts && opts.untagged) {
|
|
674
|
+
if (opts.untagged.LIST) {
|
|
675
|
+
await opts.untagged.LIST({
|
|
676
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
if (opts.untagged.STATUS) {
|
|
680
|
+
// key token has a non-string value (line 203 -> key = false),
|
|
681
|
+
// then the value token also has a non-string value (line 206 guard).
|
|
682
|
+
await opts.untagged.STATUS({
|
|
683
|
+
attributes: [{ value: 'INBOX' }, [{ value: 999 }, { value: 12 }]]
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return { next: () => {} };
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
|
|
692
|
+
test.ok(Array.isArray(result));
|
|
693
|
+
test.done();
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// list.js line 231 / 269 branches: `mailbox || ''` empty side. Call list with
|
|
697
|
+
// an undefined mailbox argument so both the LIST and LSUB calls take the ''
|
|
698
|
+
// fallback for the mailbox pattern.
|
|
699
|
+
module.exports['Branches: list with undefined mailbox pattern uses empty fallback'] = async test => {
|
|
700
|
+
let listMailboxArg;
|
|
701
|
+
let lsubMailboxArg;
|
|
702
|
+
const connection = createMockConnection({
|
|
703
|
+
state: 2,
|
|
704
|
+
exec: async (cmd, attrs, opts) => {
|
|
705
|
+
if (cmd === 'LIST') {
|
|
706
|
+
listMailboxArg = attrs[1];
|
|
707
|
+
if (opts && opts.untagged && opts.untagged.LIST) {
|
|
708
|
+
await opts.untagged.LIST({
|
|
709
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (cmd === 'LSUB') {
|
|
714
|
+
lsubMailboxArg = attrs[1];
|
|
715
|
+
}
|
|
716
|
+
return { next: () => {} };
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// mailbox undefined -> `mailbox || ''` fallback used at lines 231 and 269
|
|
721
|
+
const result = await listCommand(connection, '', undefined);
|
|
722
|
+
test.ok(Array.isArray(result));
|
|
723
|
+
test.equal(listMailboxArg, '');
|
|
724
|
+
test.equal(lsubMailboxArg, '');
|
|
725
|
+
test.done();
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
// list.js lines 278/279 (LSUB attributes[2] missing -> '' fallbacks) and
|
|
729
|
+
// lines 293/294 (LSUB entry without delimiter -> parentPath '' / parent [path]).
|
|
730
|
+
module.exports['Branches: list LSUB entry without delimiter or name uses fallbacks'] = async test => {
|
|
731
|
+
const connection = createMockConnection({
|
|
732
|
+
state: 2,
|
|
733
|
+
exec: async (cmd, attrs, opts) => {
|
|
734
|
+
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
735
|
+
await opts.untagged.LIST({
|
|
736
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
|
|
740
|
+
// delimiter NIL and no name attribute -> '' fallbacks + no-delimiter parent logic
|
|
741
|
+
await opts.untagged.LSUB({
|
|
742
|
+
attributes: [[{ value: '\\HasNoChildren' }], null]
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
return { next: () => {} };
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const result = await listCommand(connection, '', '*');
|
|
750
|
+
test.ok(Array.isArray(result));
|
|
751
|
+
test.done();
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// list.js line 342 branch: `if (!a.specialUse && b.specialUse) return 1;`.
|
|
755
|
+
// To make V8's sort invoke the comparator with a non-special `a` and a
|
|
756
|
+
// special-use `b`, the special-use entry must come FIRST in insertion order,
|
|
757
|
+
// followed by several plain folders.
|
|
758
|
+
module.exports['Branches: list sort returns 1 when a lacks specialUse but b has it'] = async test => {
|
|
759
|
+
const connection = createMockConnection({
|
|
760
|
+
state: 2,
|
|
761
|
+
exec: async (cmd, attrs, opts) => {
|
|
762
|
+
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
763
|
+
// INBOX (name-based \\Inbox special use) emitted FIRST, then plain folders.
|
|
764
|
+
await opts.untagged.LIST({
|
|
765
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
766
|
+
});
|
|
767
|
+
await opts.untagged.LIST({
|
|
768
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'AAA' }]
|
|
769
|
+
});
|
|
770
|
+
await opts.untagged.LIST({
|
|
771
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'BBB' }]
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
return { next: () => {} };
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const result = await listCommand(connection, '', '*');
|
|
779
|
+
test.ok(Array.isArray(result));
|
|
780
|
+
// INBOX (special use) sorts before the plain folders
|
|
781
|
+
test.equal(result[0].path, 'INBOX');
|
|
782
|
+
test.deepEqual(
|
|
783
|
+
result.slice(1).map(e => e.path),
|
|
784
|
+
['AAA', 'BBB']
|
|
785
|
+
);
|
|
786
|
+
test.done();
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
// ============================================================================
|
|
790
|
+
// idle.js — NOOP fallback path (no IDLE capability).
|
|
791
|
+
// Covers line 216 (`case 'NOOP'`) and line 224 (`maxIdleTime ?` truthy side).
|
|
792
|
+
// We trigger one NOOP poll, then break the loop via preCheck().
|
|
793
|
+
// ============================================================================
|
|
794
|
+
|
|
795
|
+
const idleCommand = require('../lib/commands/idle');
|
|
796
|
+
|
|
797
|
+
module.exports['Branches: idle NOOP fallback runs poll then breaks via preCheck'] = async test => {
|
|
798
|
+
test.expect(1);
|
|
799
|
+
let noopCalled = false;
|
|
800
|
+
let resolveNoop;
|
|
801
|
+
let noopRan = new Promise(resolve => {
|
|
802
|
+
resolveNoop = resolve;
|
|
803
|
+
});
|
|
804
|
+
const connection = createMockConnection({
|
|
805
|
+
state: 3,
|
|
806
|
+
capabilities: new Map([['IMAP4rev1', true]]), // no IDLE -> NOOP polling path
|
|
807
|
+
currentSelectCommand: { command: 'SELECT', arguments: [{ type: 'ATOM', value: 'INBOX' }] },
|
|
808
|
+
missingIdleCommand: 'NOOP',
|
|
809
|
+
exec: async cmd => {
|
|
810
|
+
if (cmd === 'NOOP') {
|
|
811
|
+
noopCalled = true;
|
|
812
|
+
resolveNoop();
|
|
813
|
+
}
|
|
814
|
+
return { next: () => {} };
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// maxIdleTime provided -> exercises `maxIdleTime ? Math.min(...) : NOOP_INTERVAL` truthy side
|
|
819
|
+
const idlePromise = idleCommand(connection, 60000);
|
|
820
|
+
|
|
821
|
+
// Wait until the first NOOP poll has actually run, then cross a macrotask
|
|
822
|
+
// boundary so the loop's .then() has armed its next-poll setTimeout before we
|
|
823
|
+
// break out. This guarantees preCheck()'s clearTimeout clears the armed timer,
|
|
824
|
+
// so no 60s timer leaks and the poll loop stops deterministically.
|
|
825
|
+
await noopRan;
|
|
826
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
827
|
+
await connection.preCheck();
|
|
828
|
+
|
|
829
|
+
await idlePromise;
|
|
830
|
+
test.equal(noopCalled, true);
|
|
831
|
+
test.done();
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// ============================================================================
|
|
835
|
+
// idle.js — IDLE path with currentLock set, exercising the `?.lockId` optional
|
|
836
|
+
// chaining truthy sides (lines 37, 62, 83, 101) and the cleanup while-loop
|
|
837
|
+
// (line 109) that drains preCheckWaitQueue. We request a break (preCheck)
|
|
838
|
+
// before the server acknowledges with the "+" continuation, so that the
|
|
839
|
+
// onPlusTag handler invokes preCheck() which sends DONE and drains the queue.
|
|
840
|
+
// ============================================================================
|
|
841
|
+
|
|
842
|
+
module.exports['Branches: idle IDLE path with currentLock drives preCheck and cleanup'] = async test => {
|
|
843
|
+
let doneWritten = false;
|
|
844
|
+
let onPlusTagFn = null;
|
|
845
|
+
let resolveExec = null;
|
|
846
|
+
|
|
847
|
+
// Deferred that resolves once runIdle has entered exec (preCheck registered).
|
|
848
|
+
let resolveEntered;
|
|
849
|
+
const execEntered = new Promise(resolve => {
|
|
850
|
+
resolveEntered = resolve;
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
const connection = createMockConnection({
|
|
854
|
+
state: 3,
|
|
855
|
+
capabilities: new Map([['IDLE', true]]),
|
|
856
|
+
currentLock: { lockId: 'lock-1' }, // makes `currentLock?.lockId` take the defined (truthy) side
|
|
857
|
+
write: data => {
|
|
858
|
+
if (data === 'DONE') {
|
|
859
|
+
doneWritten = true;
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
exec: (cmd, attrs, opts) => {
|
|
863
|
+
// Capture onPlusTag, signal entry, and keep IDLE pending until resolved below.
|
|
864
|
+
onPlusTagFn = opts.onPlusTag;
|
|
865
|
+
resolveEntered();
|
|
866
|
+
return new Promise(resolve => {
|
|
867
|
+
resolveExec = () => resolve({ next: () => {}, response: { attributes: [] } });
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
const idlePromise = idleCommand(connection);
|
|
873
|
+
|
|
874
|
+
await execEntered;
|
|
875
|
+
|
|
876
|
+
// Request an IDLE break before the "+" continuation. canEnd is still false,
|
|
877
|
+
// so DONE is not sent yet; a waiter is queued in preCheckWaitQueue.
|
|
878
|
+
const breakPromise = typeof connection.preCheck === 'function' ? connection.preCheck() : Promise.resolve();
|
|
879
|
+
|
|
880
|
+
// Simulate the server "+" continuation. doneRequested is already true, so
|
|
881
|
+
// onPlusTag sets canEnd and calls preCheck(), which writes DONE and drains the
|
|
882
|
+
// queue (covers the `?.lockId` truthy sides and the preCheckWaitQueue drain).
|
|
883
|
+
if (onPlusTagFn) {
|
|
884
|
+
await onPlusTagFn();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Let the IDLE exec resolve so runIdle proceeds to its cleanup block.
|
|
888
|
+
if (resolveExec) {
|
|
889
|
+
resolveExec();
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
await breakPromise;
|
|
893
|
+
await idlePromise;
|
|
894
|
+
|
|
895
|
+
test.equal(doneWritten, true);
|
|
896
|
+
test.done();
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
// ============================================================================
|
|
900
|
+
// idle.js — lines 109-112: the cleanup-block while-loop that drains
|
|
901
|
+
// preCheckWaitQueue. This runs when a break was requested but DONE was never
|
|
902
|
+
// sent (server never sent the "+" continuation, so canEnd stayed false and
|
|
903
|
+
// preCheck remained registered). When exec then resolves, runIdle's cleanup
|
|
904
|
+
// finds preCheck === connectionPreCheck and drains the still-queued waiters.
|
|
905
|
+
// ============================================================================
|
|
906
|
+
|
|
907
|
+
module.exports['Branches: idle cleanup drains queued waiters when DONE never sent'] = async test => {
|
|
908
|
+
let onPlusTagFn = null;
|
|
909
|
+
let resolveExec = null;
|
|
910
|
+
let resolveEntered;
|
|
911
|
+
const execEntered = new Promise(resolve => {
|
|
912
|
+
resolveEntered = resolve;
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
const connection = createMockConnection({
|
|
916
|
+
state: 3,
|
|
917
|
+
capabilities: new Map([['IDLE', true]]),
|
|
918
|
+
currentLock: { lockId: 'lock-9' }, // exercises `currentLock?.lockId` truthy side in cleanup trace
|
|
919
|
+
write: () => {}, // would only be called if DONE were sent; it never is here
|
|
920
|
+
exec: (cmd, attrs, opts) => {
|
|
921
|
+
onPlusTagFn = opts.onPlusTag;
|
|
922
|
+
resolveEntered();
|
|
923
|
+
return new Promise(resolve => {
|
|
924
|
+
resolveExec = () => resolve({ next: () => {}, response: { attributes: [] } });
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
const idlePromise = idleCommand(connection);
|
|
930
|
+
await execEntered;
|
|
931
|
+
|
|
932
|
+
// Request a break: canEnd is still false (no "+" continuation yet), so DONE is
|
|
933
|
+
// NOT sent and connection.preCheck stays registered. The waiter is queued.
|
|
934
|
+
const breakPromise = connection.preCheck();
|
|
935
|
+
|
|
936
|
+
// Resolve exec WITHOUT invoking onPlusTag, so runIdle reaches its cleanup block
|
|
937
|
+
// with preCheck still === connectionPreCheck and the queue non-empty -> drains it.
|
|
938
|
+
test.ok(typeof onPlusTagFn === 'function');
|
|
939
|
+
resolveExec();
|
|
940
|
+
|
|
941
|
+
await breakPromise; // resolves only if the cleanup drain ran
|
|
942
|
+
await idlePromise;
|
|
943
|
+
test.done();
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
// ============================================================================
|
|
947
|
+
// idle.js — line 88 catch: the onPlusTag handler wraps `await preCheck()` in a
|
|
948
|
+
// try/catch. We make connection.write('DONE') throw so that preCheck rejects and
|
|
949
|
+
// the catch on line 88 logs the error. A break is requested before the "+"
|
|
950
|
+
// continuation so that onPlusTag invokes preCheck() (which calls write).
|
|
951
|
+
// ============================================================================
|
|
952
|
+
|
|
953
|
+
module.exports['Branches: idle onPlusTag catches preCheck error when DONE write throws'] = async test => {
|
|
954
|
+
let onPlusTagFn = null;
|
|
955
|
+
let resolveExec = null;
|
|
956
|
+
let resolveEntered;
|
|
957
|
+
const execEntered = new Promise(resolve => {
|
|
958
|
+
resolveEntered = resolve;
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
const connection = createMockConnection({
|
|
962
|
+
state: 3,
|
|
963
|
+
capabilities: new Map([['IDLE', true]]),
|
|
964
|
+
write: data => {
|
|
965
|
+
if (data === 'DONE') {
|
|
966
|
+
throw new Error('socket write failed');
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
exec: (cmd, attrs, opts) => {
|
|
970
|
+
onPlusTagFn = opts.onPlusTag;
|
|
971
|
+
resolveEntered();
|
|
972
|
+
return new Promise(resolve => {
|
|
973
|
+
resolveExec = () => resolve({ next: () => {}, response: { attributes: [] } });
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
const idlePromise = idleCommand(connection);
|
|
979
|
+
await execEntered;
|
|
980
|
+
|
|
981
|
+
// Request a break before the "+" continuation: queues a waiter, sets
|
|
982
|
+
// doneRequested true, but canEnd is still false so write isn't called yet.
|
|
983
|
+
const breakPromise = connection.preCheck();
|
|
984
|
+
|
|
985
|
+
// Server "+" continuation: onPlusTag sets canEnd and calls preCheck(), which
|
|
986
|
+
// attempts connection.write('DONE') -> throws -> caught by the line 88 catch.
|
|
987
|
+
await onPlusTagFn();
|
|
988
|
+
|
|
989
|
+
// The break never resolves because DONE failed; resolve exec so IDLE completes,
|
|
990
|
+
// and detach the rejected break handler to avoid an unhandled rejection.
|
|
991
|
+
resolveExec();
|
|
992
|
+
breakPromise.catch(() => {});
|
|
993
|
+
|
|
994
|
+
await idlePromise;
|
|
995
|
+
test.done();
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
// ============================================================================
|
|
999
|
+
// authenticate.js — error paths in authOauth (line 88), authLogin (line 136)
|
|
1000
|
+
// and authPlain (line 173). Each calls handleAuthError when exec rejects.
|
|
1001
|
+
// We make exec reject so the catch -> handleAuthError -> throw path runs.
|
|
1002
|
+
// ============================================================================
|
|
1003
|
+
|
|
1004
|
+
const authenticateCommand = require('../lib/commands/authenticate');
|
|
1005
|
+
|
|
1006
|
+
module.exports['Branches: authenticate OAuth error path calls handleAuthError'] = async test => {
|
|
1007
|
+
const connection = createMockConnection({
|
|
1008
|
+
state: 1, // NOT_AUTHENTICATED
|
|
1009
|
+
servername: 'imap.example.com',
|
|
1010
|
+
capabilities: new Map([['AUTH=OAUTHBEARER', true]]),
|
|
1011
|
+
exec: async () => {
|
|
1012
|
+
const err = new Error('auth failed');
|
|
1013
|
+
err.response = { attributes: [] };
|
|
1014
|
+
throw err;
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test.expect(1);
|
|
1019
|
+
try {
|
|
1020
|
+
await authenticateCommand(connection, 'user@example.com', { accessToken: 'token123' });
|
|
1021
|
+
test.ok(false, 'should have thrown');
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
test.equal(err.authenticationFailed, true);
|
|
1024
|
+
}
|
|
1025
|
+
test.done();
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
module.exports['Branches: authenticate PLAIN error path calls handleAuthError'] = async test => {
|
|
1029
|
+
const connection = createMockConnection({
|
|
1030
|
+
state: 1,
|
|
1031
|
+
capabilities: new Map([['AUTH=PLAIN', true]]),
|
|
1032
|
+
exec: async () => {
|
|
1033
|
+
const err = new Error('auth failed');
|
|
1034
|
+
err.response = { attributes: [] };
|
|
1035
|
+
throw err;
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
test.expect(1);
|
|
1040
|
+
try {
|
|
1041
|
+
await authenticateCommand(connection, 'user@example.com', { password: 'secret' });
|
|
1042
|
+
test.ok(false, 'should have thrown');
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
test.equal(err.authenticationFailed, true);
|
|
1045
|
+
}
|
|
1046
|
+
test.done();
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
module.exports['Branches: authenticate LOGIN error path calls handleAuthError'] = async test => {
|
|
1050
|
+
const connection = createMockConnection({
|
|
1051
|
+
state: 1,
|
|
1052
|
+
capabilities: new Map([['AUTH=LOGIN', true]]), // no AUTH=PLAIN -> LOGIN chosen
|
|
1053
|
+
exec: async () => {
|
|
1054
|
+
const err = new Error('auth failed');
|
|
1055
|
+
err.response = { attributes: [] };
|
|
1056
|
+
throw err;
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
test.expect(1);
|
|
1061
|
+
try {
|
|
1062
|
+
await authenticateCommand(connection, 'user@example.com', { password: 'secret' });
|
|
1063
|
+
test.ok(false, 'should have thrown');
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
test.equal(err.authenticationFailed, true);
|
|
1066
|
+
}
|
|
1067
|
+
test.done();
|
|
1068
|
+
};
|