imapflow 1.6.4 → 1.6.5

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.
@@ -30,6 +30,17 @@ let setBoolOpt = (attributes, term, value) => {
30
30
  attributes.push({ type: 'ATOM', value: term.toUpperCase() });
31
31
  };
32
32
 
33
+ /**
34
+ * Normalizes a user-supplied sequence set (string, number, bigint, or an array of
35
+ * them) into the single string value of a SEQUENCE token. An array is one
36
+ * comma-joined set: separate tokens would be parsed by the server as extra
37
+ * sequence-number search keys ANDed to the query, not as part of the set.
38
+ *
39
+ * @param {*} value - The sequence set value(s)
40
+ * @returns {string} The joined sequence set string
41
+ */
42
+ let toSequenceValue = value => [].concat(value).join(',');
43
+
33
44
  /**
34
45
  * Adds a search option with its value(s) to the attributes array.
35
46
  * Handles NOT operations and array values.
@@ -37,23 +48,20 @@ let setBoolOpt = (attributes, term, value) => {
37
48
  * @param {Array} attributes - Array to append the attribute to
38
49
  * @param {string} term - The search term (e.g., 'FROM', 'SUBJECT')
39
50
  * @param {*} value - The value for the search term (string, array, or falsy for NOT)
40
- * @param {string} [type='ATOM'] - The attribute type
41
51
  */
42
- let setOpt = (attributes, term, value, type) => {
43
- type = type || 'ATOM';
44
-
52
+ let setOpt = (attributes, term, value) => {
45
53
  // Handle NOT operations for false or null values
46
54
  if (value === false || value === null) {
47
- attributes.push({ type, value: 'NOT' });
55
+ attributes.push({ type: 'ATOM', value: 'NOT' });
48
56
  }
49
57
 
50
- attributes.push({ type, value: term.toUpperCase() });
58
+ attributes.push({ type: 'ATOM', value: term.toUpperCase() });
51
59
 
52
- // Handle array values (e.g., multiple UIDs)
60
+ // Handle array values (e.g. HEADER name/value pairs)
53
61
  if (Array.isArray(value)) {
54
- value.forEach(entry => attributes.push({ type, value: (entry || '').toString() }));
62
+ value.forEach(entry => attributes.push({ type: 'ATOM', value: (entry || '').toString() }));
55
63
  } else {
56
- attributes.push({ type, value: value.toString() });
64
+ attributes.push({ type: 'ATOM', value: value.toString() });
57
65
  }
58
66
  };
59
67
 
@@ -158,12 +166,12 @@ module.exports.searchCompiler = (connection, query) => {
158
166
  // Custom sequence range support (non-standard)
159
167
  case 'SEQ':
160
168
  {
161
- let value = params[term];
162
- if (typeof value === 'number') {
163
- value = value.toString();
164
- }
165
- // Only accept valid sequence strings (no whitespace)
166
- if (typeof value === 'string' && /^\S+$/.test(value)) {
169
+ // Passed through as a SEQUENCE token: the compiler validates the
170
+ // set grammar and throws a coded error. An invalid value used to
171
+ // be dropped silently here, which turned a bad filter into an
172
+ // unrestricted search that matched every message.
173
+ let value = params[term] || params[term] === 0 ? toSequenceValue(params[term]) : '';
174
+ if (value) {
167
175
  attributes.push({ type: 'SEQUENCE', value });
168
176
  }
169
177
  }
@@ -239,7 +247,7 @@ module.exports.searchCompiler = (connection, query) => {
239
247
  case 'UID':
240
248
  if (params[term]) {
241
249
  attributes.push({ type: 'ATOM', value: 'UID' });
242
- [].concat(params[term]).forEach(entry => attributes.push({ type: 'SEQUENCE', value: (entry ?? '').toString() }));
250
+ attributes.push({ type: 'SEQUENCE', value: toSequenceValue(params[term]) });
243
251
  }
244
252
  break;
245
253
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.6.4",
3
+ "version": "1.6.5",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -2910,6 +2910,40 @@ module.exports['Commands: compress handles error'] = async test => {
2910
2910
  test.done();
2911
2911
  };
2912
2912
 
2913
+ module.exports['Commands: compress fails the connection on trailing data'] = async test => {
2914
+ // Per RFC 4978 the server switches to DEFLATE at its tagged OK, so data already
2915
+ // buffered behind the OK was consumed as cleartext and the deflate stream is
2916
+ // truncated. Declining the upgrade is not a protocol option at that point - the
2917
+ // session is unrecoverable in both directions and must fail closed.
2918
+ let closeAfterCalled = false;
2919
+ let nextCalled = false;
2920
+ const connection = createMockConnection({
2921
+ capabilities: new Map([['COMPRESS=DEFLATE', true]]),
2922
+ closeAfter: () => {
2923
+ closeAfterCalled = true;
2924
+ },
2925
+ exec: async () => ({
2926
+ hasTrailingData: true,
2927
+ next: () => {
2928
+ nextCalled = true;
2929
+ test.ok(closeAfterCalled, 'teardown must be scheduled before parser backpressure is released');
2930
+ }
2931
+ })
2932
+ });
2933
+
2934
+ let err = null;
2935
+ try {
2936
+ await compressCommand(connection);
2937
+ } catch (e) {
2938
+ err = e;
2939
+ }
2940
+ test.ok(err, 'compress must throw');
2941
+ test.equal(err && err.code, 'COMPRESS_TRAILING_DATA');
2942
+ test.ok(closeAfterCalled, 'the connection must be closed');
2943
+ test.ok(nextCalled, 'parser backpressure must still be released');
2944
+ test.done();
2945
+ };
2946
+
2913
2947
  // ============================================
2914
2948
  // STARTTLS Command Tests
2915
2949
  // ============================================
@@ -3895,6 +3929,52 @@ module.exports['Commands: list runs separate INBOX query when using namespace']
3895
3929
  test.done();
3896
3930
  };
3897
3931
 
3932
+ module.exports['Commands: list INBOX fixup propagates non-reducible failures'] = async test => {
3933
+ // Both sides of the fixup's retry guard: a NO on the extended call is an operational
3934
+ // failure (not a RETURN options rejection), and a BAD on a call that already ran plain
3935
+ // has no options left to reduce - neither may trigger the plain retry
3936
+ for (let { capabilities, status } of [
3937
+ {
3938
+ capabilities: [
3939
+ ['IMAP4rev1', true],
3940
+ ['LIST-EXTENDED', true]
3941
+ ],
3942
+ status: 'NO'
3943
+ },
3944
+ { capabilities: [['IMAP4rev1', true]], status: 'BAD' }
3945
+ ]) {
3946
+ let listCalls = 0;
3947
+ let lsubCalls = 0;
3948
+ const connection = createMockConnection({
3949
+ state: 3,
3950
+ capabilities: new Map(capabilities),
3951
+ exec: async (cmd, attrs) => {
3952
+ if (cmd === 'LSUB') {
3953
+ lsubCalls++;
3954
+ }
3955
+ if (cmd === 'LIST') {
3956
+ listCalls++;
3957
+ if (attrs[1] === 'INBOX') {
3958
+ throw commandError('Command failed', status);
3959
+ }
3960
+ }
3961
+ return { next: () => {} };
3962
+ }
3963
+ });
3964
+
3965
+ try {
3966
+ await listCommand(connection, 'Mail/', '*');
3967
+ test.ok(false, 'Should have thrown');
3968
+ } catch (err) {
3969
+ test.equal(err.responseStatus, status);
3970
+ }
3971
+ // Main listing and the failed INBOX call - no plain retry, no LSUB merge
3972
+ test.equal(listCalls, 2);
3973
+ test.equal(lsubCalls, 0);
3974
+ }
3975
+ test.done();
3976
+ };
3977
+
3898
3978
  module.exports['Commands: list handles LSUB merging'] = async test => {
3899
3979
  const connection = createMockConnection({
3900
3980
  state: 3,
@@ -4851,18 +4931,28 @@ module.exports['Commands: list discards partial results from a rejected INBOX fi
4851
4931
  state: 3,
4852
4932
  capabilities: new Map([
4853
4933
  ['IMAP4rev1', true],
4854
- ['LIST-EXTENDED', true]
4934
+ ['LIST-EXTENDED', true],
4935
+ ['SPECIAL-USE', true]
4855
4936
  ]),
4856
4937
  exec: async (cmd, attrs, opts) => {
4857
4938
  if (cmd === 'LIST') {
4858
4939
  listAttempts.push(JSON.stringify(attrs));
4859
4940
  if (listAttempts.length === 1) {
4860
4941
  await opts.untagged.LIST({
4861
- attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '.' }, { value: 'Prefix.Folder1' }]
4942
+ attributes: [
4943
+ [{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }, { value: '\\Sent' }],
4944
+ { value: '.' },
4945
+ { value: 'Prefix.Folder1' }
4946
+ ]
4862
4947
  });
4863
4948
  } else if (listAttempts.length === 2) {
4864
- // The fixup attempt streams an untagged INBOX line and THEN gets
4865
- // the tagged BAD - the partial line must not survive the retry
4949
+ // The fixup attempt streams untagged lines and THEN gets the tagged
4950
+ // BAD - the partial lines must not survive the retry. "Aliased" also
4951
+ // claims \Sent and sorts ahead of the main run's Prefix.Folder1, so
4952
+ // it would steal the special-use slot if it were not rolled back
4953
+ await opts.untagged.LIST({
4954
+ attributes: [[{ value: '\\Sent' }], { value: '.' }, { value: 'Aliased' }]
4955
+ });
4866
4956
  await opts.untagged.LIST({
4867
4957
  attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
4868
4958
  });
@@ -4881,7 +4971,9 @@ module.exports['Commands: list discards partial results from a rejected INBOX fi
4881
4971
  test.equal(listAttempts.length, 3);
4882
4972
  // Exactly one INBOX entry - the rejected attempt's partial line was discarded
4883
4973
  test.equal(result.filter(e => e.path === 'INBOX').length, 1);
4884
- test.ok(result.find(e => e.path === 'Prefix.Folder1'));
4974
+ test.ok(!result.some(e => e.path === 'Aliased'), 'the rejected attempt entry is gone');
4975
+ // The main run's special-use match survived the rollback of the rejected attempt
4976
+ test.equal(result.find(e => e.path === 'Prefix.Folder1').specialUse, '\\Sent');
4885
4977
  test.done();
4886
4978
  };
4887
4979
 
@@ -347,3 +347,84 @@ module.exports['Polling: no polling session without a saved select command'] = a
347
347
  test.equal(connection.idling, false, 'idling untouched');
348
348
  test.done();
349
349
  };
350
+
351
+ module.exports['Polling: a falsy STATUS result stops the loop as PollFailed'] = async test => {
352
+ await withFakeTimers(async timers => {
353
+ let warnings = [];
354
+ let connection = createConnection({
355
+ missingIdleCommand: 'STATUS',
356
+ // Only STATUS is ever polled; a failure without a NO status makes the
357
+ // real STATUS implementation swallow the error and return false
358
+ respond: async () => {
359
+ throw new Error('Command failed');
360
+ }
361
+ });
362
+ connection.log.warn = entry => warnings.push(entry);
363
+
364
+ let idlePromise = idleCommand(connection, 60000);
365
+ await timers.drain();
366
+ await idlePromise;
367
+
368
+ test.ok(
369
+ warnings.some(entry => entry && entry.err && entry.err.code === 'PollFailed'),
370
+ 'the falsy STATUS result surfaced as a PollFailed error'
371
+ );
372
+ test.equal(connection.idling, false, 'idling reset after the failed poll');
373
+ test.equal(connection.preCheck, false, 'preCheck released');
374
+ test.equal(timers.count(), 0, 'polling stopped');
375
+ test.done();
376
+ });
377
+ };
378
+
379
+ module.exports['Polling: a repeated break call is a no-op'] = async test => {
380
+ await withFakeTimers(async timers => {
381
+ let connection = createConnection();
382
+
383
+ let idlePromise = idleCommand(connection, 60000);
384
+ await timers.drain();
385
+ test.deepEqual(connection.commands, ['NOOP'], 'the immediate first poll ran');
386
+
387
+ // A caller may hold on to the break function and invoke it more than once
388
+ let preCheck = connection.preCheck;
389
+ await preCheck();
390
+ await preCheck();
391
+ await idlePromise;
392
+
393
+ test.equal(connection.idling, false, 'idling reset');
394
+ test.equal(connection.preCheck, false, 'preCheck released');
395
+ test.equal(timers.count(), 0, 'no timer left armed');
396
+
397
+ await timers.fire();
398
+ test.deepEqual(connection.commands, ['NOOP'], 'no further poll after the duplicate break');
399
+ test.done();
400
+ });
401
+ };
402
+
403
+ module.exports['Polling: a break in the initiation tick prevents the first poll'] = async test => {
404
+ await withFakeTimers(async timers => {
405
+ let connection = createConnection();
406
+ // Simulate a break request (e.g. a command being queued) landing in the same
407
+ // tick the loop is initiated: trap the loop installing its own preCheck and
408
+ // break through it immediately, before the first poll has started
409
+ let installedPreCheck = connection.preCheck;
410
+ Object.defineProperty(connection, 'preCheck', {
411
+ get: () => installedPreCheck,
412
+ set: value => {
413
+ installedPreCheck = value;
414
+ if (typeof value === 'function') {
415
+ value();
416
+ }
417
+ }
418
+ });
419
+
420
+ let idlePromise = idleCommand(connection, 60000);
421
+ await timers.drain();
422
+ await idlePromise;
423
+
424
+ test.deepEqual(connection.commands, [], 'the already-cancelled session never polled');
425
+ test.equal(connection.idling, false, 'idling reset');
426
+ test.equal(connection.preCheck, false, 'preCheck released');
427
+ test.equal(timers.count(), 0, 'no timer left armed');
428
+ test.done();
429
+ });
430
+ };
@@ -642,10 +642,11 @@ module.exports['IMAP Compiler: partial range in SECTION'] = test =>
642
642
 
643
643
  // Returns the error a compile attempt raised, or undefined when it succeeded.
644
644
  // Several cases below assert that a token can never reach the wire, and the
645
- // try/catch is the only interesting part of each.
646
- const compileError = async attributes => {
645
+ // try/catch is the only interesting part of each. Takes either an attributes
646
+ // array (compiled under a default tag/command) or a full response object.
647
+ const compileError = async input => {
647
648
  try {
648
- await compiler({ tag: 'A', command: 'CMD', attributes });
649
+ await compiler(Array.isArray(input) ? { tag: 'A', command: 'CMD', attributes: input } : input);
649
650
  } catch (err) {
650
651
  return err;
651
652
  }
@@ -663,10 +664,50 @@ module.exports['IMAP Compiler: SEQUENCE rejects values that are not sequence set
663
664
 
664
665
  module.exports['IMAP Compiler: SEQUENCE accepts every valid sequence-set form'] = test =>
665
666
  asyncWrapper(test, async test => {
666
- for (let value of ['1', '*', '1:*', '*:1', '1,3,5', '1:3,7,9:*', '4294967295']) {
667
+ // '$' is the RFC 5182 SEARCHRES saved-result marker, valid as the entire set
668
+ for (let value of ['1', '*', '1:*', '*:1', '1,3,5', '1:3,7,9:*', '4294967295', '$']) {
667
669
  const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value }] })).toString();
668
670
  test.equal(compiled, `A FETCH ${value}`);
669
671
  }
672
+
673
+ // '$' only stands for the whole set, never an element of one
674
+ for (let value of ['$,1', '1,$', '$:2']) {
675
+ let err = await compileError([{ type: 'SEQUENCE', value }]);
676
+ test.equal(err && err.code, 'InvalidSequenceSet', `${JSON.stringify(value)} must be rejected`);
677
+ }
678
+ });
679
+
680
+ module.exports['IMAP Compiler: SEQUENCE validation stays linear on huge valid sets'] = test =>
681
+ asyncWrapper(test, async test => {
682
+ // The regex this replaced overflowed the engine's backtrack stack with an
683
+ // uncoded RangeError at roughly a million comma-separated elements - a set
684
+ // a fetch over a large mailbox can legitimately produce
685
+ const huge = Array.from({ length: 1500000 }, (_, i) => i + 1).join(',');
686
+ const compiled = await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value: huge }] });
687
+ test.ok(compiled.toString().endsWith(huge), 'the huge but valid set must compile');
688
+ });
689
+
690
+ module.exports['IMAP Compiler: SEQUENCE validation is skipped when logging'] = test =>
691
+ asyncWrapper(test, async test => {
692
+ // The incoming token parser accepts sequence-shaped tokens the strict grammar
693
+ // rejects (an ESEARCH set like "1:2:3", a folder name like "12:30:00"). Every
694
+ // parsed server response is re-compiled for the log, so a throw here would let
695
+ // one quirky server line kill the whole connection.
696
+ for (let value of ['1:2:3', '12:30:00', '1,2:3:4']) {
697
+ const compiled = (await compiler({ tag: '*', command: 'OK', attributes: [{ type: 'SEQUENCE', value }] }, { isLogging: true })).toString();
698
+ test.equal(compiled, `* OK ${value}`, 'logging re-compile must pass the token through');
699
+ }
700
+ });
701
+
702
+ module.exports['IMAP Compiler: tag and command cannot carry a line terminator'] = test =>
703
+ asyncWrapper(test, async test => {
704
+ // The tag/command preamble is written verbatim ahead of any token, so it goes
705
+ // through the same choke point as everything else
706
+ let err = await compileError({ tag: 'A\r\nZZ LOGOUT', command: 'NOOP' });
707
+ test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the tag must be rejected');
708
+
709
+ err = await compileError({ tag: 'A', command: 'NOOP\r\nZZ LOGOUT' });
710
+ test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the command must be rejected');
670
711
  });
671
712
 
672
713
  module.exports['IMAP Compiler: quoted strings use IMAP escaping, not JSON escaping'] = test =>
@@ -707,6 +748,23 @@ module.exports['IMAP Compiler: NUMBER coerces its value instead of writing it th
707
748
  test.equal((await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '42' }] })).toString(), 'A CMD 42');
708
749
  });
709
750
 
751
+ module.exports['IMAP Compiler: numeric tokens clamp to bounded non-negative integers'] = test =>
752
+ asyncWrapper(test, async test => {
753
+ // Infinity, negatives and unsafe magnitudes would otherwise emit bytes outside
754
+ // the number alphabet ("Infinity", "-5", "1e+300") and get a server BAD blamed
755
+ // on the server instead of the caller
756
+ for (let [value, expected] of [
757
+ [Infinity, '0'],
758
+ ['Infinity', '0'],
759
+ [-5, '0'],
760
+ ['2e21', '0'],
761
+ [4.6, '5']
762
+ ]) {
763
+ const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value }] })).toString();
764
+ test.equal(compiled, `A CMD ${expected}`, `${JSON.stringify(value)} must clamp to ${expected}`);
765
+ }
766
+ });
767
+
710
768
  module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
711
769
  asyncWrapper(test, async test => {
712
770
  // The partial range is the last token component written verbatim, so it is
@@ -719,6 +777,18 @@ module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
719
777
  await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 1024] }] })
720
778
  ).toString();
721
779
  test.equal(normal, 'A FETCH BODY.PEEK[]<0.1024>');
780
+
781
+ // Floats and unbounded numerics clamp the same way NUMBER tokens do, so no
782
+ // non-digit byte ("512.5", "Infinity") can appear inside the partial range
783
+ const float = (
784
+ await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 512.5] }] })
785
+ ).toString();
786
+ test.equal(float, 'A FETCH BODY.PEEK[]<0.513>');
787
+
788
+ const unbounded = (
789
+ await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: ['Infinity', '2e21'] }] })
790
+ ).toString();
791
+ test.equal(unbounded, 'A FETCH BODY.PEEK[]<0.0>');
722
792
  });
723
793
 
724
794
  module.exports['IMAP Compiler: logging output never throws on unsendable values'] = test =>
@@ -471,3 +471,137 @@ module.exports['Internals: untaggedFetch includes modseq and flagColor'] = async
471
471
  test.equal(evt.flagColor, 'red');
472
472
  test.done();
473
473
  };
474
+
475
+ // ============================================================================
476
+ // runInternal dispatch guards
477
+ // ============================================================================
478
+
479
+ module.exports['Internals: runInternal returns false for unknown command'] = async test => {
480
+ let client = makeClient();
481
+ client.socket = { destroyed: false };
482
+ let res = await client.runInternal('NOT_A_COMMAND');
483
+ test.equal(res, false);
484
+ test.done();
485
+ };
486
+
487
+ module.exports['Internals: runInternal throws NoConnection when the socket is destroyed'] = async test => {
488
+ let client = makeClient();
489
+ client.socket = { destroyed: true };
490
+ let err = null;
491
+ try {
492
+ await client.runInternal('NOOP');
493
+ } catch (e) {
494
+ err = e;
495
+ }
496
+ test.ok(err);
497
+ test.equal(err.code, 'NoConnection');
498
+ test.done();
499
+ };
500
+
501
+ // ============================================================================
502
+ // send() onSend error containment
503
+ // ============================================================================
504
+
505
+ module.exports['Internals: send contains a throwing onSend callback'] = async test => {
506
+ let client = makeClient();
507
+ let written = [];
508
+ let warnings = [];
509
+ client.socket = { destroyed: false };
510
+ client.writeSocket = { destroyed: false, write: chunk => written.push(chunk) };
511
+ client.state = client.states.AUTHENTICATED;
512
+ client.log.warn = entry => warnings.push(entry);
513
+ client.currentRequest = { tag: 'A1', sent: false };
514
+
515
+ // The command is already on the wire when onSend runs, so a throwing callback
516
+ // must be logged and swallowed instead of rejecting the request
517
+ await client.send({
518
+ tag: 'A1',
519
+ command: 'NOOP',
520
+ attributes: [],
521
+ options: {
522
+ onSend: () => {
523
+ throw new Error('onSend boom');
524
+ }
525
+ }
526
+ });
527
+
528
+ test.equal(written.length, 1, 'the command was written to the socket');
529
+ test.equal(written[0].toString(), 'A1 NOOP\r\n', 'the actual wire bytes went out before onSend ran');
530
+ test.equal(client.currentRequest.sent, true, 'the request is marked as sent');
531
+ test.ok(
532
+ warnings.some(entry => entry && entry.err && entry.err.message === 'onSend boom'),
533
+ 'the callback error was logged'
534
+ );
535
+ test.done();
536
+ };
537
+
538
+ // ============================================================================
539
+ // countUnknownTag teardown guard
540
+ // ============================================================================
541
+
542
+ module.exports['Internals: countUnknownTag ignores tags on a closed connection'] = test => {
543
+ let client = makeClient();
544
+ client.isClosed = true;
545
+ client.countUnknownTag('A1');
546
+ test.equal(client._unknownTagCount, 0, 'teardown crossover is not counted');
547
+ test.done();
548
+ };
549
+
550
+ // ============================================================================
551
+ // rejectUnparsedCompletion
552
+ // ============================================================================
553
+
554
+ // Installs a command that is current and on the wire, returning a holder for
555
+ // the rejection the reader loop would deliver to the caller
556
+ const armInFlight = (client, tag) => {
557
+ let captured = { err: null };
558
+ client.currentRequest = { tag, sent: true };
559
+ client.requestTagMap.set(tag, {
560
+ tag,
561
+ reject: err => {
562
+ captured.err = err;
563
+ }
564
+ });
565
+ return captured;
566
+ };
567
+
568
+ module.exports['Internals: rejectUnparsedCompletion ignores lines without an in-flight command'] = test => {
569
+ let client = makeClient();
570
+
571
+ client.currentRequest = false;
572
+ client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
573
+ test.equal(client.currentRequest, false, 'no request to settle');
574
+
575
+ // A command that is current but not yet on the wire must not be settled either
576
+ client.currentRequest = { tag: 'A1', sent: false };
577
+ client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
578
+ test.ok(client.currentRequest, 'the unsent request is untouched');
579
+ test.done();
580
+ };
581
+
582
+ module.exports['Internals: rejectUnparsedCompletion recovers the tag from NUL-padded raw bytes'] = async test => {
583
+ let client = makeClient();
584
+ let captured = armInFlight(client, 'A1');
585
+
586
+ // The parser died before extracting a tag - the raw line carries the buggy-server
587
+ // NUL padding, so the fallback must skip it and stop at the first non-tag byte
588
+ let parserError = new Error('parse fail');
589
+ client.rejectUnparsedCompletion(Buffer.from('\x00\x00A1 \x07garbage'), parserError);
590
+
591
+ test.ok(captured.err, 'the in-flight command was failed');
592
+ test.equal(captured.err.code, 'ParserError');
593
+ test.equal(captured.err.parserError, parserError);
594
+ test.equal(client.currentRequest, false, 'the request slot was cleared');
595
+ test.done();
596
+ };
597
+
598
+ module.exports['Internals: rejectUnparsedCompletion ignores a mismatched raw tag'] = test => {
599
+ let client = makeClient();
600
+ let captured = armInFlight(client, 'A1');
601
+
602
+ client.rejectUnparsedCompletion(Buffer.from('A2 NO other'), new Error('parse fail'));
603
+
604
+ test.equal(captured.err, null, 'a line for another tag settles nothing');
605
+ test.ok(client.currentRequest, 'the in-flight request stays current');
606
+ test.done();
607
+ };