imapflow 1.4.9 → 1.6.0

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.
Files changed (48) hide show
  1. package/.github/workflows/test.yml +20 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/CLAUDE.md +3 -5
  5. package/lib/commands/fetch.js +18 -14
  6. package/lib/commands/idle.js +197 -104
  7. package/lib/commands/list.js +19 -8
  8. package/lib/commands/quota.js +3 -0
  9. package/lib/commands/select.js +5 -0
  10. package/lib/commands/status.js +10 -1
  11. package/lib/connection-deadline.js +98 -0
  12. package/lib/handler/imap-compiler.js +20 -14
  13. package/lib/handler/imap-stream.js +141 -50
  14. package/lib/handler/limits.js +43 -0
  15. package/lib/handler/token-parser.js +38 -1
  16. package/lib/imap-flow.d.ts +47 -5
  17. package/lib/imap-flow.js +594 -283
  18. package/lib/proxy-connection.js +393 -98
  19. package/lib/special-use.js +660 -51
  20. package/lib/tools.js +52 -3
  21. package/package.json +2 -2
  22. package/test/commands-branches-test.js +17 -1
  23. package/test/commands-integration-test.js +353 -2
  24. package/test/connection-edge-cases-test.js +4 -40
  25. package/test/fixtures/fake-timers.js +115 -0
  26. package/test/handler-branches-test.js +4 -28
  27. package/test/idle-polling-test.js +349 -0
  28. package/test/imap-compiler-test.js +85 -0
  29. package/test/imap-flow-compress-test.js +12 -0
  30. package/test/imap-flow-coverage-test.js +3 -3
  31. package/test/imap-flow-fetch-download-test.js +56 -0
  32. package/test/imap-flow-internals-test.js +23 -0
  33. package/test/imap-flow-proxy-paths-test.js +151 -0
  34. package/test/imap-flow-secure-test.js +182 -9
  35. package/test/imap-flow-server-test.js +229 -0
  36. package/test/imap-parser-test.js +112 -1
  37. package/test/imap-stream-test.js +46 -0
  38. package/test/integration/README.md +17 -5
  39. package/test/integration/rev2-live-test.js +125 -0
  40. package/test/integration/run-rev2-tests.sh +14 -0
  41. package/test/parser-limits-test.js +274 -0
  42. package/test/proxy-connection-test.js +553 -442
  43. package/test/reliability-improvements-test.js +87 -0
  44. package/test/search-compiler-test.js +17 -0
  45. package/test/special-use-test.js +337 -0
  46. package/test/tag-correlation-test.js +333 -0
  47. package/test/timer-policy-test.js +214 -0
  48. package/test/tools-test.js +42 -4
@@ -300,6 +300,62 @@ module.exports['Download: base64 body part is decoded'] = async test => {
300
300
  test.done();
301
301
  };
302
302
 
303
+ module.exports['Download: binary-fetched part skips the client-side transfer decoding'] = async test => {
304
+ let client = makeClient();
305
+ // BINARY fetch (RFC 3516, FETCH side folded into IMAP4rev2): the server
306
+ // decodes the content-transfer-encoding itself, so the client gets raw bytes
307
+ // even though the MIME headers still declare base64 - decoding again would
308
+ // corrupt the data. formatMessageResponse records the parts that arrived via
309
+ // BINARY[...] in the binaryParts set, which the mock mirrors here.
310
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
311
+ let decoded = Buffer.from('Hello World');
312
+ client.fetchOne = async () => {
313
+ let bodyParts = new Map();
314
+ bodyParts.set('2.mime', mime);
315
+ bodyParts.set('2', decoded);
316
+ return { uid: 1, size: decoded.length, bodyParts, binaryParts: new Set(['2']) };
317
+ };
318
+ let { meta, content } = await client.download('1', '2', { chunkSize: 1024, binary: true });
319
+ test.equal(meta.encoding, 'base64');
320
+ let data = await collect(content);
321
+ test.equal(data.toString(), 'Hello World');
322
+ test.done();
323
+ };
324
+
325
+ module.exports['DownloadMany: binary-fetched parts skip the client-side transfer decoding'] = async test => {
326
+ let client = makeClient();
327
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
328
+ client.fetchOne = async () => {
329
+ let bodyParts = new Map();
330
+ bodyParts.set('2.mime', mime);
331
+ bodyParts.set('2', Buffer.from('part two'));
332
+ return { uid: 1, bodyParts, binaryParts: new Set(['2']) };
333
+ };
334
+ let res = await client.downloadMany('1', ['2'], { binary: true });
335
+ test.equal(res['2'].content.toString(), 'part two');
336
+ test.equal(res['2'].meta.encoding, 'base64');
337
+ test.done();
338
+ };
339
+
340
+ module.exports['Download: BODY-answered part is still decoded even when binary was requested'] = async test => {
341
+ let client = makeClient();
342
+ // RFC 3516 lets a server answer a BINARY request with a NO or a BODY response
343
+ // (e.g. [UNKNOWN-CTE]) - decoding must follow what actually arrived, so a part
344
+ // absent from binaryParts gets the client-side decoder despite options.binary
345
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
346
+ let encoded = Buffer.from(libbase64.encode(Buffer.from('Hello World')));
347
+ client.fetchOne = async () => {
348
+ let bodyParts = new Map();
349
+ bodyParts.set('2.mime', mime);
350
+ bodyParts.set('2', encoded);
351
+ return { uid: 1, size: encoded.length, bodyParts };
352
+ };
353
+ let { content } = await client.download('1', '2', { chunkSize: 1024, binary: true });
354
+ let data = await collect(content);
355
+ test.equal(data.toString(), 'Hello World');
356
+ test.done();
357
+ };
358
+
303
359
  module.exports['Download: quoted-printable inline text with charset + filename'] = async test => {
304
360
  let client = makeClient();
305
361
  let mime = Buffer.from(
@@ -38,16 +38,39 @@ module.exports['Internals: emitError routes to upgrade rejector while upgrading'
38
38
  client.socket = { destroyed: true, destroy: () => {} };
39
39
  client.upgrading = true;
40
40
  let rejected = null;
41
+ // Stands in for the upgrade's settle() helper, which owns clearing `upgrading`,
42
+ // the upgrade timer and the temporary handshake handlers.
41
43
  client._upgradeReject = err => {
42
44
  rejected = err;
45
+ client.upgrading = false;
43
46
  };
44
47
  let err = new Error('tls boom');
45
48
  client.emitError(err);
46
49
  test.equal(rejected, err);
50
+ test.equal(client._upgradeReject, null, 'the rejector is consumed exactly once');
47
51
  test.equal(client.upgrading, false);
48
52
  test.done();
49
53
  };
50
54
 
55
+ module.exports['Internals: emitError closes when an upgrade has no rejector'] = test => {
56
+ let client = makeClient();
57
+ client.socket = { destroyed: true, destroy: () => {} };
58
+ client.upgrading = true;
59
+ client._upgradeReject = null;
60
+
61
+ let emitted = false;
62
+ client.on('error', () => {
63
+ emitted = true;
64
+ });
65
+
66
+ client.emitError(new Error('tls boom'));
67
+
68
+ test.equal(client.upgrading, false, 'the upgrade flag is cleared');
69
+ test.equal(emitted, false, 'no duplicate error event while the upgrade owns reporting');
70
+ client.close();
71
+ test.done();
72
+ };
73
+
51
74
  module.exports['Internals: emitError rejects pending connect promise'] = test => {
52
75
  let client = makeClient();
53
76
  client.socket = { destroyed: true, destroy: () => {} };
@@ -8,6 +8,7 @@ const net = require('net');
8
8
  const tls = require('tls');
9
9
  const proxyquire = require('proxyquire');
10
10
  const { cert, key } = require('./fixtures/test-tls');
11
+ const { ImapFlow } = require('../lib/imap-flow');
11
12
 
12
13
  const CAPS = 'IMAP4rev1 ID ENABLE NAMESPACE';
13
14
 
@@ -213,3 +214,153 @@ module.exports['Proxy: proxyConnection throwing rejects connect'] = async test =
213
214
  client.close();
214
215
  test.done();
215
216
  };
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // End to end through a real HTTP CONNECT proxy (ImapFlow's own CONNECT helper)
220
+ // ---------------------------------------------------------------------------
221
+
222
+ // Minimal HTTP CONNECT proxy. `onConnect(socket, headers)` may take over the exchange (used to
223
+ // simulate a proxy that never answers); otherwise the tunnel is established and piped. With
224
+ // `coalesce`, the 200 response is held back until the destination speaks and then written in the
225
+ // same segment as the destination's greeting - which is what a real proxy often does.
226
+ const createHttpProxy = (onConnect, { coalesce = false } = {}) =>
227
+ net.createServer(socket => {
228
+ socket.on('error', () => {});
229
+ let buf = '';
230
+ const onData = data => {
231
+ buf += data.toString('binary');
232
+ let idx = buf.indexOf('\r\n\r\n');
233
+ if (idx < 0) {
234
+ return;
235
+ }
236
+ socket.removeListener('data', onData);
237
+ let headers = buf.slice(0, idx);
238
+ let rest = buf.slice(idx + 4);
239
+ let authority = (headers.split('\r\n')[0].match(/^CONNECT (\S+)/) || [])[1] || '';
240
+
241
+ if (onConnect && onConnect(socket, headers)) {
242
+ return;
243
+ }
244
+
245
+ let [, targetPort] = authority.split(':');
246
+ let upstream = net.connect(Number(targetPort), '127.0.0.1', () => {
247
+ if (rest) {
248
+ upstream.write(Buffer.from(rest, 'binary'));
249
+ }
250
+
251
+ const startTunnel = () => {
252
+ socket.pipe(upstream);
253
+ upstream.pipe(socket);
254
+ };
255
+
256
+ if (!coalesce) {
257
+ socket.write('HTTP/1.1 200 Connection established\r\n\r\n');
258
+ return startTunnel();
259
+ }
260
+
261
+ // Hold the CONNECT response until the destination sends something, then emit both
262
+ // in a single write so the client has to preserve the trailing bytes itself.
263
+ upstream.once('data', firstChunk => {
264
+ socket.write(Buffer.concat([Buffer.from('HTTP/1.1 200 Connection established\r\n\r\n'), firstChunk]));
265
+ startTunnel();
266
+ });
267
+ });
268
+ upstream.on('error', () => socket.destroy());
269
+ };
270
+ socket.on('data', onData);
271
+ });
272
+
273
+ module.exports['Proxy: session established through a real HTTP CONNECT proxy'] = async test => {
274
+ let imapServer = createServer();
275
+ let imapPort = await listen(imapServer);
276
+ let proxy = createHttpProxy();
277
+ let proxyPort = await listen(proxy);
278
+
279
+ let client = new ImapFlow({
280
+ host: '127.0.0.1',
281
+ port: imapPort,
282
+ secure: false,
283
+ proxy: `http://127.0.0.1:${proxyPort}`,
284
+ disableAutoIdle: true,
285
+ disableCompression: true,
286
+ logger: false,
287
+ auth: { user: 'test', pass: 'secret' }
288
+ });
289
+ client.on('error', () => {});
290
+
291
+ await client.connect();
292
+ test.ok(client.usable, 'session established through the CONNECT tunnel');
293
+ test.ok(client.authenticated);
294
+
295
+ await client.logout();
296
+ client.close();
297
+ proxy.close();
298
+ imapServer.close();
299
+ test.done();
300
+ };
301
+
302
+ module.exports['Proxy: a greeting coalesced with the CONNECT response is not lost'] = async test => {
303
+ // The proxy answers CONNECT and the destination greeting in one segment. The bytes after the
304
+ // header terminator belong to the tunnel, so they have to reach the parser - otherwise the
305
+ // greeting is swallowed and connect() only fails at the greeting timeout.
306
+ let imapServer = createServer();
307
+ let imapPort = await listen(imapServer);
308
+ let proxy = createHttpProxy(null, { coalesce: true });
309
+ let proxyPort = await listen(proxy);
310
+
311
+ let client = new ImapFlow({
312
+ host: '127.0.0.1',
313
+ port: imapPort,
314
+ secure: false,
315
+ proxy: `http://127.0.0.1:${proxyPort}`,
316
+ greetingTimeout: 1500,
317
+ disableAutoIdle: true,
318
+ disableCompression: true,
319
+ logger: false,
320
+ auth: { user: 'test', pass: 'secret' }
321
+ });
322
+ client.on('error', () => {});
323
+
324
+ await client.connect();
325
+ test.ok(client.usable, 'the coalesced greeting was parsed, so the session came up');
326
+ test.ok(client.capabilities.has('IMAP4rev1'), 'capabilities from the coalesced greeting are present');
327
+
328
+ await client.logout();
329
+ client.close();
330
+ proxy.close();
331
+ imapServer.close();
332
+ test.done();
333
+ };
334
+
335
+ module.exports['Proxy: a stalled proxy negotiation is bounded by connectionTimeout'] = async test => {
336
+ // The proxy accepts the CONNECT request and then goes silent
337
+ let proxy = createHttpProxy(() => true);
338
+ let proxyPort = await listen(proxy);
339
+
340
+ let client = new ImapFlow({
341
+ host: '127.0.0.1',
342
+ port: 143,
343
+ secure: false,
344
+ proxy: `http://127.0.0.1:${proxyPort}`,
345
+ connectionTimeout: 150,
346
+ disableAutoIdle: true,
347
+ disableCompression: true,
348
+ logger: false,
349
+ auth: { user: 'test', pass: 'secret' }
350
+ });
351
+ client.on('error', () => {});
352
+
353
+ let started = Date.now();
354
+ let err = await client.connect().then(
355
+ () => null,
356
+ e => e
357
+ );
358
+
359
+ test.ok(err, 'connect rejected instead of hanging in proxy negotiation');
360
+ test.equal(err.code, 'CONNECT_TIMEOUT', 'the documented connection timeout covers proxy negotiation');
361
+ test.ok(Date.now() - started < 3000, 'the deadline applied to the proxy phase, not only to the transport');
362
+
363
+ client.close();
364
+ proxy.close();
365
+ test.done();
366
+ };
@@ -13,13 +13,17 @@ const CAPS = 'IMAP4rev1 ID ENABLE NAMESPACE';
13
13
 
14
14
  // Shared per-connection IMAP line handler used by both the plaintext and the
15
15
  // upgraded TLS phases. Returns responses for the minimal session commands.
16
- const handleLine = (sock, line, onStartTls) => {
16
+ // `caps` is the full capability list to advertise for this phase - the STARTTLS
17
+ // test uses different pre- and post-TLS sets to prove the client discards the
18
+ // plaintext capabilities and re-fetches them over TLS (RFC 9051 6.2.1).
19
+ const handleLine = (sock, line, onStartTls, caps) => {
20
+ caps = caps || `${CAPS} STARTTLS`;
17
21
  let parts = line.split(' ');
18
22
  let tag = parts[0];
19
23
  let cmd = (parts[1] || '').toUpperCase();
20
24
  switch (cmd) {
21
25
  case 'CAPABILITY':
22
- sock.write(`* CAPABILITY ${CAPS} STARTTLS\r\n${tag} OK CAPABILITY done\r\n`);
26
+ sock.write(`* CAPABILITY ${caps}\r\n${tag} OK CAPABILITY done\r\n`);
23
27
  break;
24
28
  case 'STARTTLS':
25
29
  sock.write(`${tag} OK Begin TLS\r\n`);
@@ -79,21 +83,93 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
79
83
  let server = net.createServer(rawSocket => {
80
84
  rawSocket.on('error', () => {});
81
85
 
86
+ let detachPlain;
87
+ detachPlain = lineReader(rawSocket, line => {
88
+ handleLine(
89
+ rawSocket,
90
+ line,
91
+ () => {
92
+ // Upgrade: stop reading plaintext, wrap the socket in TLS
93
+ detachPlain();
94
+ let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
95
+ tlsSocket.on('error', () => {});
96
+ tlsSocket.on('secure', () => {});
97
+ // post-TLS phase advertises a different capability set
98
+ lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
99
+ },
100
+ `${CAPS} STARTTLS PRETLS-ONLY`
101
+ );
102
+ });
103
+
104
+ rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
105
+ });
106
+
107
+ let port = await listen(server);
108
+ let client = new ImapFlow({
109
+ host: '127.0.0.1',
110
+ port,
111
+ secure: false,
112
+ doSTARTTLS: true,
113
+ servername: 'localhost',
114
+ tls: { rejectUnauthorized: false },
115
+ disableAutoIdle: true,
116
+ disableCompression: true,
117
+ logger: false,
118
+ auth: { user: 'test', pass: 'secret' }
119
+ });
120
+ client.on('error', () => {});
121
+
122
+ await client.connect();
123
+ test.ok(client.secureConnection, 'connection upgraded to TLS');
124
+ test.ok(client.authenticated, 'authenticated over TLS');
125
+ test.ok(client.usable);
126
+ // RFC 9051 6.2.1: capabilities cached before STARTTLS MUST be discarded and
127
+ // re-fetched over the TLS channel
128
+ test.ok(client.capabilities.has('POSTTLS-ONLY'), 'post-TLS capabilities were re-fetched');
129
+ test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
130
+
131
+ await client.noop();
132
+ await client.logout();
133
+ client.close();
134
+ server.close();
135
+ test.done();
136
+ };
137
+
138
+ // Builds the STARTTLS happy-path server used by the watchdog and cleanup tests below.
139
+ const createStartTlsServer = () =>
140
+ net.createServer(rawSocket => {
141
+ rawSocket.on('error', () => {});
142
+
82
143
  let detachPlain;
83
144
  detachPlain = lineReader(rawSocket, line => {
84
145
  handleLine(rawSocket, line, () => {
85
- // Upgrade: stop reading plaintext, wrap the socket in TLS
86
146
  detachPlain();
87
147
  let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
88
148
  tlsSocket.on('error', () => {});
89
- tlsSocket.on('secure', () => {});
90
- lineReader(tlsSocket, l => handleLine(tlsSocket, l));
149
+ lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, CAPS));
91
150
  });
92
151
  });
93
152
 
94
153
  rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
95
154
  });
96
155
 
156
+ // Records every socket that went through configureSocket(), which is the single place the
157
+ // transport options (keepalive + inactivity watchdog) are applied.
158
+ const trackConfiguredSockets = client => {
159
+ let sockets = [];
160
+ let original = client.configureSocket.bind(client);
161
+ client.configureSocket = socket => {
162
+ sockets.push(socket);
163
+ return original(socket);
164
+ };
165
+ return sockets;
166
+ };
167
+
168
+ module.exports['Secure: STARTTLS session keeps the inactivity watchdog'] = async test => {
169
+ // Regression: the inactivity timer was armed on the plain socket only, while the timeout
170
+ // listener was attached to the TLS socket, so an upgraded session had no watchdog at all
171
+ // and a dead connection was never noticed.
172
+ let server = createStartTlsServer();
97
173
  let port = await listen(server);
98
174
  let client = new ImapFlow({
99
175
  host: '127.0.0.1',
@@ -102,25 +178,122 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
102
178
  doSTARTTLS: true,
103
179
  servername: 'localhost',
104
180
  tls: { rejectUnauthorized: false },
181
+ socketTimeout: 200,
105
182
  disableAutoIdle: true,
106
183
  disableCompression: true,
107
184
  logger: false,
108
185
  auth: { user: 'test', pass: 'secret' }
109
186
  });
110
- client.on('error', () => {});
187
+
188
+ let errors = [];
189
+ client.on('error', err => errors.push(err));
190
+ let closed = new Promise(resolve => client.once('close', resolve));
191
+ let configured = trackConfiguredSockets(client);
111
192
 
112
193
  await client.connect();
113
194
  test.ok(client.secureConnection, 'connection upgraded to TLS');
114
- test.ok(client.authenticated, 'authenticated over TLS');
115
- test.ok(client.usable);
195
+ test.equal(configured.length, 2, 'both the plain socket and the upgraded socket are configured');
196
+ test.equal(client.socket.timeout, 200, 'the upgraded socket carries the configured inactivity timeout');
197
+ test.equal(configured[0].timeout, 0, 'the superseded plain-socket timer is cleared, not left armed');
198
+
199
+ // No traffic from here on: the watchdog has to fire and take the connection down.
200
+ await closed;
201
+
202
+ test.ok(
203
+ errors.some(err => err.code === 'ETIMEOUT'),
204
+ 'the inactivity watchdog reported a socket timeout'
205
+ );
206
+ test.ok(client.isClosed, 'the connection closed after the timeout');
207
+
208
+ client.close();
209
+ server.close();
210
+ test.done();
211
+ };
212
+
213
+ module.exports['Secure: STARTTLS leaves no upgrade state behind on success'] = async test => {
214
+ // Item 8: every terminal path of the upgrade runs through one settlement helper, so after
215
+ // a successful handshake no timer, rejector, flag or temporary handler survives.
216
+ let server = createStartTlsServer();
217
+ let port = await listen(server);
218
+ let client = new ImapFlow({
219
+ host: '127.0.0.1',
220
+ port,
221
+ secure: false,
222
+ doSTARTTLS: true,
223
+ servername: 'localhost',
224
+ tls: { rejectUnauthorized: false },
225
+ disableAutoIdle: true,
226
+ disableCompression: true,
227
+ logger: false,
228
+ auth: { user: 'test', pass: 'secret' }
229
+ });
230
+ client.on('error', () => {});
231
+
232
+ await client.connect();
233
+
234
+ test.equal(client.upgrading, false, 'upgrading flag cleared');
235
+ test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
236
+ test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
237
+ // The handshake-only handler is gone, leaving the generic socket error handler as the single
238
+ // error path (during the handshake it is the other way round, which is what prevents a
239
+ // handshake error from firing two handlers at once).
240
+ test.equal(client.socket.listenerCount('error'), 1, 'the TLS socket keeps exactly one error path');
116
241
 
117
- await client.noop();
118
242
  await client.logout();
119
243
  client.close();
120
244
  server.close();
121
245
  test.done();
122
246
  };
123
247
 
248
+ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade'] = async test => {
249
+ // The server acknowledges STARTTLS but never performs a handshake. close() has to settle
250
+ // the pending upgrade instead of leaving the session promise dangling forever.
251
+ let server = net.createServer(rawSocket => {
252
+ rawSocket.on('error', () => {});
253
+ let detach = lineReader(rawSocket, line => {
254
+ handleLine(rawSocket, line, () => {
255
+ detach(); // silence: no ServerHello, no data at all
256
+ });
257
+ });
258
+ rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
259
+ });
260
+ let port = await listen(server);
261
+ let client = new ImapFlow({
262
+ host: '127.0.0.1',
263
+ port,
264
+ secure: false,
265
+ doSTARTTLS: true,
266
+ servername: 'localhost',
267
+ tls: { rejectUnauthorized: false },
268
+ disableAutoIdle: true,
269
+ disableCompression: true,
270
+ logger: false,
271
+ auth: { user: 'test', pass: 'secret' }
272
+ });
273
+ client.on('error', () => {});
274
+
275
+ let connectResult = client.connect().then(
276
+ () => null,
277
+ err => err
278
+ );
279
+
280
+ // Wait until the upgrade is in flight, then close the connection under it.
281
+ while (!client.upgrading) {
282
+ await new Promise(resolve => setTimeout(resolve, 10));
283
+ }
284
+ client.close();
285
+
286
+ let err = await connectResult;
287
+ test.ok(err, 'connect rejected rather than hanging on the abandoned upgrade');
288
+ test.ok(['ClosedAfterConnectText', 'ClosedAfterConnectTLS', 'NoConnection'].includes(err.code), `connect rejected with ${err.code}`);
289
+ test.equal(client.upgrading, false, 'upgrading flag cleared');
290
+ test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
291
+ test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
292
+
293
+ server.close();
294
+ test.done();
295
+ };
296
+
124
297
  // ---------------------------------------------------------------------------
125
298
  // Direct TLS connection
126
299
  // ---------------------------------------------------------------------------