@suveren/gateway 0.29.0 → 0.30.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 (34) hide show
  1. package/content/integrations/gmail.json +13 -2
  2. package/content/integrations/mail.json +12 -2
  3. package/content/integrations/mollie.json +10 -0
  4. package/dist/control-plane/index.mjs +125 -37
  5. package/dist/mcp-server/http.mjs +772 -420
  6. package/dist/ui/assets/index-C2BnlNib.js +123 -0
  7. package/dist/ui/assets/{index-DUIlMVlP.css → index-DPgewsLG.css} +1 -1
  8. package/dist/ui/index.html +2 -2
  9. package/node_modules/qs/.github/THREAT_MODEL.md +4 -2
  10. package/node_modules/qs/CHANGELOG.md +38 -14
  11. package/node_modules/qs/README.md +31 -3
  12. package/node_modules/qs/dist/qs.js +20 -20
  13. package/node_modules/qs/lib/parse.js +94 -4
  14. package/node_modules/qs/lib/stringify.js +4 -4
  15. package/node_modules/qs/lib/utils.js +130 -6
  16. package/node_modules/qs/package.json +4 -4
  17. package/node_modules/qs/test/parse.js +174 -0
  18. package/node_modules/qs/test/stringify.js +263 -33
  19. package/node_modules/qs/test/utils.js +184 -0
  20. package/node_modules/sanitize-html/index.js +47 -10
  21. package/node_modules/sanitize-html/package.json +2 -3
  22. package/package.json +1 -1
  23. package/dist/ui/assets/index-C3vTF2Ye.js +0 -123
  24. package/node_modules/deepmerge/.editorconfig +0 -7
  25. package/node_modules/deepmerge/.eslintcache +0 -1
  26. package/node_modules/deepmerge/changelog.md +0 -167
  27. package/node_modules/deepmerge/dist/cjs.js +0 -133
  28. package/node_modules/deepmerge/dist/umd.js +0 -139
  29. package/node_modules/deepmerge/index.d.ts +0 -20
  30. package/node_modules/deepmerge/index.js +0 -106
  31. package/node_modules/deepmerge/license.txt +0 -21
  32. package/node_modules/deepmerge/package.json +0 -42
  33. package/node_modules/deepmerge/readme.md +0 -264
  34. package/node_modules/deepmerge/rollup.config.js +0 -22
@@ -62,10 +62,39 @@ test('parse()', function (t) {
62
62
  st.end();
63
63
  });
64
64
 
65
+ t.test('comma: `[]=` in a value does not wrap the value in an extra array', function (st) {
66
+ st.deepEqual(qs.parse('a[b]=1,2,3[]=x', { comma: true }), { a: { b: ['1', '2', '3[]=x'] } });
67
+ st.deepEqual(qs.parse('a[b]=1,2[]=3', { comma: true }), { a: { b: ['1', '2[]=3'] } });
68
+ st.deepEqual(qs.parse('a[]=1,2,3', { comma: true }), { a: [['1', '2', '3']] });
69
+ st.end();
70
+ });
71
+
65
72
  t.test('allows enabling dot notation', function (st) {
66
73
  st.deepEqual(qs.parse('a.b=c'), { 'a.b': 'c' });
67
74
  st.deepEqual(qs.parse('a.b=c', { allowDots: true }), { a: { b: 'c' } });
68
75
 
76
+ // a literal dot inside a bracket segment must not be rewritten by allowDots (#617)
77
+ st.deepEqual(
78
+ qs.parse('outer[a.b]=1', { allowDots: true }),
79
+ { outer: { 'a.b': '1' } },
80
+ 'a dot inside brackets stays part of the key when allowDots is true'
81
+ );
82
+ st.deepEqual(
83
+ qs.parse('outer[a.b]=1', { allowDots: true }),
84
+ qs.parse('outer[a.b]=1', { allowDots: false }),
85
+ 'allowDots does not change how a bracket segment containing a dot is parsed'
86
+ );
87
+ st.deepEqual(
88
+ qs.parse('a].b=c', { allowDots: true }),
89
+ { 'a]': { b: 'c' } },
90
+ 'a stray close bracket does not stop a later dot from being converted'
91
+ );
92
+ st.deepEqual(
93
+ qs.parse('a]].b[c.d]=e', { allowDots: true }),
94
+ { 'a]]': { b: { 'c.d': 'e' } } },
95
+ 'stray close brackets do not unbalance a later bracket segment'
96
+ );
97
+
69
98
  st.end();
70
99
  });
71
100
 
@@ -360,6 +389,14 @@ test('parse()', function (t) {
360
389
  st.end();
361
390
  });
362
391
 
392
+ t.test('currently moves the key/value split to a later `]=` when the first `=` comes before any bracket group', function (st) {
393
+ characterizeParse(st, 'a=b[c]=d', undefined, { 'a=b': { c: 'd' } }, 'literal brackets after the first `=`');
394
+ characterizeParse(st, 'q=%5B1%5D=2', undefined, { 'q=': ['2'] }, 'encoded brackets collapse to an array');
395
+ characterizeParse(st, 'role=guest%5Bx%5Dy%5D=z', undefined, { 'role=guest': { x: 'z' } }, 'group opens after the first `=`, and the text after it is dropped');
396
+ characterizeParse(st, 'role=guest%5D=admin', undefined, { 'role=guest]': 'admin' }, 'encoded `]=` with no bracket group at all');
397
+ st.end();
398
+ });
399
+
363
400
  t.test('allows to specify array indices', function (st) {
364
401
  st.deepEqual(qs.parse('a[1]=c&a[0]=b&a[2]=d'), { a: ['b', 'c', 'd'] });
365
402
  st.deepEqual(qs.parse('a[1]=c&a[0]=b'), { a: ['b', 'c'] });
@@ -745,6 +782,38 @@ test('parse()', function (t) {
745
782
  st.end();
746
783
  });
747
784
 
785
+ t.test('does not mutate an object input', function (st) {
786
+ var arr = ['x'];
787
+ st.deepEqual(qs.parse({ a: arr, 'a[]': 'y' }), { a: ['x', 'y'] }, 'appends to a copy of an input array');
788
+ st.deepEqual(arr, ['x'], 'the input array is unchanged');
789
+
790
+ var nested = { b: ['x'] };
791
+ st.deepEqual(qs.parse({ a: nested, 'a[b][]': 'y' }), { a: { b: ['x', 'y'] } }, 'appends to a copy of a nested input array');
792
+ st.deepEqual(nested, { b: ['x'] }, 'the nested input array is unchanged');
793
+
794
+ var obj = { b: 'c' };
795
+ st.deepEqual(qs.parse({ a: obj, 'a[d]': 'e' }), { a: { b: 'c', d: 'e' } }, 'merges into a copy of an input object');
796
+ st.deepEqual(obj, { b: 'c' }, 'the input object is unchanged');
797
+
798
+ var sparse = { b: [1, undefined, 2] };
799
+ st.deepEqual(qs.parse({ a: sparse }), { a: { b: [1, 2] } }, 'compacts a copy of an input array');
800
+ st.deepEqual(sparse, { b: [1, undefined, 2] }, 'the input array is not compacted');
801
+
802
+ var nullProto = { __proto__: null, b: 'c' };
803
+ st.equal(Object.getPrototypeOf(qs.parse({ a: nullProto }).a), null, 'a null-prototype input object stays null-prototype');
804
+
805
+ var date = new Date(0);
806
+ st.equal(qs.parse({ a: date }).a, date, 'a non-plain object is not copied');
807
+
808
+ var cyclic = { b: 'c' };
809
+ cyclic.self = cyclic;
810
+ var result = qs.parse({ a: cyclic });
811
+ st.notEqual(result.a, cyclic, 'a cyclic input object is copied');
812
+ st.equal(result.a.self, result.a, 'the copy keeps the cycle');
813
+
814
+ st.end();
815
+ });
816
+
748
817
  t.test('does not blow up when Buffer global is missing', function (st) {
749
818
  var restore = mockProperty(global, 'Buffer', { 'delete': true });
750
819
 
@@ -962,6 +1031,38 @@ test('parse()', function (t) {
962
1031
  st.end();
963
1032
  });
964
1033
 
1034
+ t.test('strictMerge conflicts do not overwrite indexed values already at that path', function (st) {
1035
+ st.deepEqual(
1036
+ qs.parse('a[z]=o&a=p&a[y]=q&a[2]=keep&[a]=r'),
1037
+ { a: { 0: { z: 'o' }, 1: 'p', 2: 'keep', 3: 'r', y: 'q' } },
1038
+ 'an indexed key merged into the container is kept'
1039
+ );
1040
+
1041
+ st.deepEqual(
1042
+ qs.parse('a[z]=o&a=p&a[2]=keep&a[y]=q&[a]=r'),
1043
+ { a: { 0: { z: 'o' }, 1: 'p', 2: 'keep', 3: 'r', y: 'q' } },
1044
+ 'an indexed key that extended the container while it was an array is kept'
1045
+ );
1046
+
1047
+ st.end();
1048
+ });
1049
+
1050
+ t.test('strictMerge conflicts do not overwrite values once the container overflows', function (st) {
1051
+ st.deepEqual(
1052
+ qs.parse('a[z]=v0&a=v1&a[y]=v2&[a]=v3&[a]x=v4&a[2]=v5&[a]x1=v6', { arrayLimit: 2 }),
1053
+ { a: { 0: { z: 'v0' }, 1: 'v1', 2: ['v3', 'v5'], 3: 'v4', 4: 'v6', y: 'v2' } },
1054
+ 'a conflict appended after the container overflowed keeps every value'
1055
+ );
1056
+
1057
+ st.deepEqual(
1058
+ qs.parse('a[2]=v1&a=v5&a=v7&[a]x=v9', { parseArrays: false, arrayLimit: 1 }),
1059
+ { a: { 0: 'v5', 1: 'v7', 2: 'v1', 3: 'v9' } },
1060
+ 'an index the container already holds is not reused'
1061
+ );
1062
+
1063
+ st.end();
1064
+ });
1065
+
965
1066
  t.test('dunder proto is ignored', function (st) {
966
1067
  var payload = 'categories[__proto__]=login&categories[__proto__]&categories[length]=42';
967
1068
  var result = qs.parse(payload, { allowPrototypes: true });
@@ -1463,6 +1564,57 @@ test('parse()', function (t) {
1463
1564
  sst.end();
1464
1565
  });
1465
1566
 
1567
+ st.test('enforces arrayLimit when a strictMerge conflict appends to its container', function (sst) {
1568
+ var aliased = 'a[z]=o&a=p&a[y]=q&[a]=r';
1569
+ sst['throws'](
1570
+ function () { qs.parse(aliased, { arrayLimit: 2, throwOnLimitExceeded: true }); },
1571
+ new RangeError('Array limit exceeded. Only 2 elements allowed in an array.'),
1572
+ 'a third conflicting value throws once the container would exceed arrayLimit'
1573
+ );
1574
+ sst.deepEqual(
1575
+ qs.parse(aliased, { arrayLimit: 3, throwOnLimitExceeded: true }),
1576
+ { a: { 0: { z: 'o' }, 1: 'p', 2: 'r', y: 'q' } },
1577
+ 'appends while the container stays within arrayLimit'
1578
+ );
1579
+ sst.deepEqual(
1580
+ qs.parse(aliased, { arrayLimit: 2 }),
1581
+ { a: { 0: { z: 'o' }, 1: 'p', 2: 'r', y: 'q' } },
1582
+ 'appends without throwing when throwOnLimitExceeded is not set'
1583
+ );
1584
+
1585
+ sst.end();
1586
+ });
1587
+
1588
+ st.test('enforces arrayLimit when differently spelled keys collide', function (sst) {
1589
+ sst['throws'](
1590
+ function () { qs.parse('[a]=x&a=y', { arrayLimit: 1, throwOnLimitExceeded: true }); },
1591
+ new RangeError('Array limit exceeded. Only 1 element allowed in an array.'),
1592
+ 'two primitives throw, as `a=x&a=y` does'
1593
+ );
1594
+ sst['throws'](
1595
+ function () { qs.parse('a[b]=x&a=y', { arrayLimit: 1, throwOnLimitExceeded: true }); },
1596
+ new RangeError('Array limit exceeded. Only 1 element allowed in an array.'),
1597
+ 'an object and a primitive throw under strictMerge'
1598
+ );
1599
+ sst.deepEqual(
1600
+ qs.parse('[a]=x&a=y', { arrayLimit: 1 }),
1601
+ { a: { 0: 'x', 1: 'y' } },
1602
+ 'without throwOnLimitExceeded, two primitives convert to an object, as `a=x&a=y` does'
1603
+ );
1604
+ sst.deepEqual(
1605
+ qs.parse('a[b]=x&a=y', { arrayLimit: 1 }),
1606
+ { a: { 0: { b: 'x' }, 1: 'y' } },
1607
+ 'and so do an object and a primitive'
1608
+ );
1609
+ sst.deepEqual(
1610
+ qs.parse('a[b]=x&a=y', { arrayLimit: 2, throwOnLimitExceeded: true }),
1611
+ { a: [{ b: 'x' }, 'y'] },
1612
+ 'a collision within arrayLimit is still an array'
1613
+ );
1614
+
1615
+ sst.end();
1616
+ });
1617
+
1466
1618
  st.test('does not throw when cumulative comma combine stays within arrayLimit', function (sst) {
1467
1619
  var result = qs.parse('a=1,2,3&a=4', { comma: true, arrayLimit: 5, throwOnLimitExceeded: true });
1468
1620
  sst.deepEqual(result, { a: ['1', '2', '3', '4'] }, 'combined array within limit is preserved');
@@ -1807,6 +1959,28 @@ test('`duplicates` option', function (t) {
1807
1959
  st.end();
1808
1960
  });
1809
1961
 
1962
+ t.test('`[]=` in a value does not force combining', function (st) {
1963
+ st.deepEqual(
1964
+ qs.parse('a[b]=1&a[b]=2[]=3', { duplicates: 'last' }),
1965
+ { a: { b: '2[]=3' } },
1966
+ 'duplicates last: `[]=` in the value is not a bracketed key'
1967
+ );
1968
+
1969
+ st.deepEqual(
1970
+ qs.parse('a[b]=1&a[b]=2[]=3', { duplicates: 'first' }),
1971
+ { a: { b: '1' } },
1972
+ 'duplicates first: `[]=` in the value is not a bracketed key'
1973
+ );
1974
+
1975
+ st.deepEqual(
1976
+ qs.parse('a[b]=1&a[b]=2[]=3', { duplicates: 'combine' }),
1977
+ { a: { b: ['1', '2[]=3'] } },
1978
+ 'duplicates combine: still combines'
1979
+ );
1980
+
1981
+ st.end();
1982
+ });
1983
+
1810
1984
  t.end();
1811
1985
  });
1812
1986
 
@@ -212,63 +212,94 @@ test('stringify()', function (t) {
212
212
  st.end();
213
213
  });
214
214
 
215
- // Characterization of the CURRENT (buggy) behavior: with `allowDots` + `encodeDotInKeys`,
216
- // `stringify` percent-encodes the structural `.` separators that `allowDots` inserts between
217
- // nested keys, not just the literal dots inside a key, which breaks the stringify -> parse
218
- // round-trip. These assertions pin that behavior so it cannot change silently: the fix in
219
- // https://github.com/ljharb/qs/pull/564 changes every output below, so landing that fix MUST
220
- // update this test (that is the point — it forces the behavior change to be explicit).
221
- t.test('[characterization] encodeDotInKeys currently over-encodes structural separator dots (pre-#564)', function (st) {
215
+ t.test('does not encode the structural separator dots between nested keys when encodeDotInKeys and allowDots are provided', function (st) {
222
216
  st.equal(
223
- qs.stringify({ a: { b: { c: 'd' } } }, { allowDots: true, encodeDotInKeys: true }),
224
- 'a%252Eb.c=d',
225
- 'over-encodes the a->b separator (#564: should become a.b.c=d)'
217
+ qs.stringify(
218
+ { a: { b: { c: 'd' } } },
219
+ { allowDots: true, encodeDotInKeys: true }
220
+ ),
221
+ 'a.b.c=d',
222
+ 'no key contains a literal dot, so encodeDotInKeys is a no-op on the separators'
226
223
  );
227
224
  st.equal(
228
- qs.stringify({ a: { b: { c: { d: 'e' } } } }, { allowDots: true, encodeDotInKeys: true }),
229
- 'a%252Eb%252Ec.d=e',
230
- 'clobbers every separator but the last (#564: should become a.b.c.d=e)'
225
+ qs.stringify(
226
+ { a: { b: { c: { d: 'e' } } } },
227
+ { allowDots: true, encodeDotInKeys: true }
228
+ ),
229
+ 'a.b.c.d=e',
230
+ 'every nesting separator is preserved, not just the last one'
231
231
  );
232
- st.equal(
233
- qs.stringify({ 'a.b': { c: { d: 'e' } } }, { allowDots: true, encodeDotInKeys: true }),
234
- 'a%252Eb%252Ec.d=e',
235
- 'top-level dotted key: deeper separators also get encoded (#564: should become a%252Eb.c.d=e)'
232
+
233
+ st.deepEqual(
234
+ qs.parse(
235
+ qs.stringify({ a: { b: { c: 'd' } } }, { allowDots: true, encodeDotInKeys: true }),
236
+ { allowDots: true, decodeDotInKeys: true }
237
+ ),
238
+ { a: { b: { c: 'd' } } },
239
+ 'stringify -> parse round-trips a deeply nested object'
236
240
  );
241
+
242
+ st.end();
243
+ });
244
+
245
+ t.test('encodeDotInKeys encodes only literal dots inside nested keys, never the structural separators, at any depth', function (st) {
237
246
  st.equal(
238
247
  qs.stringify({ a: { 'b.c': { d: 'e' } } }, { allowDots: true, encodeDotInKeys: true }),
239
- 'a%252Eb%252Ec.d=e',
240
- 'nested dotted key + object value: the separator around it is encoded (#564: should become a.b%252Ec.d=e)'
248
+ 'a.b%252Ec.d=e',
249
+ 'a nested key with a literal dot and an object value encodes only its own dot'
250
+ );
251
+ st.equal(
252
+ qs.stringify({ 'a.b': { c: { d: 'e' } } }, { allowDots: true, encodeDotInKeys: true }),
253
+ 'a%252Eb.c.d=e',
254
+ 'a top-level dotted key stays encoded while deeper separators stay literal'
241
255
  );
242
256
  st.equal(
243
257
  qs.stringify({ a: { b: { 'c.d': { e: 'f' } } } }, { allowDots: true, encodeDotInKeys: true }),
244
- 'a%252Eb%252Ec%252Ed.e=f',
245
- 'deeply nested dotted key: all separators encoded (#564: should become a.b.c%252Ed.e=f)'
258
+ 'a.b.c%252Ed.e=f',
259
+ 'a literal dot in a deeply nested key is encoded without touching the separators around it'
246
260
  );
247
261
 
248
262
  st.equal(
249
263
  qs.stringify({ a: { b: [1, 2] } }, { allowDots: true, encodeDotInKeys: true }),
250
- 'a%252Eb%5B0%5D=1&a%252Eb%5B1%5D=2',
251
- 'array value under nested keys: separator encoded (#564: should become a.b%5B0%5D=1&a.b%5B1%5D=2)'
264
+ 'a.b%5B0%5D=1&a.b%5B1%5D=2',
265
+ 'separator dots are preserved for an array value nested under keys'
252
266
  );
253
267
  st.equal(
254
268
  qs.stringify({ a: { 'b.c': [1, 2] } }, { allowDots: true, encodeDotInKeys: true }),
255
- 'a%252Eb%252Ec%5B0%5D=1&a%252Eb%252Ec%5B1%5D=2',
256
- 'array value under nested dotted key (#564: should become a.b%252Ec%5B0%5D=1&a.b%252Ec%5B1%5D=2)'
269
+ 'a.b%252Ec%5B0%5D=1&a.b%252Ec%5B1%5D=2',
270
+ 'a nested dotted key with an array value encodes only its own dot'
257
271
  );
258
272
 
259
- st.equal(
260
- qs.stringify({ a: { 'b.c': { d: 'e' } } }, { allowDots: false, encodeDotInKeys: true }),
261
- 'a%5Bb%252Ec%5D%5Bd%5D=e',
262
- 'allowDots:false object value encodes the nested-key dot, unlike the primitive-value case (#564: should become a%5Bb.c%5D%5Bd%5D=e)'
273
+ st.deepEqual(
274
+ qs.parse(
275
+ qs.stringify({ a: { 'b.c': { d: 'e' } } }, { allowDots: true, encodeDotInKeys: true }),
276
+ { allowDots: true, decodeDotInKeys: true }
277
+ ),
278
+ { a: { 'b.c': { d: 'e' } } },
279
+ 'stringify -> parse round-trips a nested dotted key with an object value'
263
280
  );
264
-
265
281
  st.deepEqual(
266
282
  qs.parse(
267
- qs.stringify({ a: { b: { c: 'd' } } }, { allowDots: true, encodeDotInKeys: true }),
283
+ qs.stringify({ 'a.b': { c: { d: 'e' } } }, { allowDots: true, encodeDotInKeys: true }),
268
284
  { allowDots: true, decodeDotInKeys: true }
269
285
  ),
270
- { 'a.b': { c: 'd' } },
271
- 'the over-encoding breaks the round-trip: {a:{b:{c}}} comes back as {"a.b":{c}} (#564 restores it)'
286
+ { 'a.b': { c: { d: 'e' } } },
287
+ 'stringify -> parse round-trips a top-level dotted key with deep nesting'
288
+ );
289
+
290
+ st.end();
291
+ });
292
+
293
+ t.test('with allowDots false, encodeDotInKeys leaves nested-key dots literal, consistently for object and primitive values', function (st) {
294
+ st.equal(
295
+ qs.stringify({ a: { 'b.c': { d: 'e' } } }, { allowDots: false, encodeDotInKeys: true }),
296
+ 'a%5Bb.c%5D%5Bd%5D=e',
297
+ 'nested dotted key with an object value'
298
+ );
299
+ st.equal(
300
+ qs.stringify({ a: { 'b.c': 'e' } }, { allowDots: false, encodeDotInKeys: true }),
301
+ 'a%5Bb.c%5D=e',
302
+ 'nested dotted key with a primitive value matches the object-value case'
272
303
  );
273
304
 
274
305
  st.end();
@@ -394,6 +425,41 @@ test('stringify()', function (t) {
394
425
  st.end();
395
426
  });
396
427
 
428
+ t.test('ignores inherited array format names', function (st) {
429
+ var array = { a: ['b', 'c'] };
430
+ var indices = 'a%5B0%5D=b&a%5B1%5D=c';
431
+ var repeat = 'a=b&a=c';
432
+
433
+ st.equal(qs.stringify(array, { arrayFormat: 'unsupported' }), indices, 'unknown format falls back to `indices`');
434
+
435
+ var names = [
436
+ 'constructor',
437
+ 'toString',
438
+ '__proto__',
439
+ 'valueOf',
440
+ 'hasOwnProperty',
441
+ 'isPrototypeOf',
442
+ 'propertyIsEnumerable',
443
+ 'toLocaleString'
444
+ ];
445
+ for (var i = 0; i < names.length; i++) {
446
+ var name = names[i];
447
+ st.equal(qs.stringify(array, { arrayFormat: name }), indices, '`' + name + '` falls back to `indices`');
448
+ st.equal(
449
+ qs.stringify(array, { arrayFormat: name, indices: true }),
450
+ indices,
451
+ '`' + name + '` with `indices: true` falls back to `indices`'
452
+ );
453
+ st.equal(
454
+ qs.stringify(array, { arrayFormat: name, indices: false }),
455
+ repeat,
456
+ '`' + name + '` with `indices: false` falls back to `repeat`'
457
+ );
458
+ }
459
+
460
+ st.end();
461
+ });
462
+
397
463
  t.test('`skipNulls` option', function (st) {
398
464
  st.equal(
399
465
  qs.stringify({ a: 'b', c: null }, { skipNulls: true }),
@@ -569,6 +635,53 @@ test('stringify()', function (t) {
569
635
  st.end();
570
636
  });
571
637
 
638
+ t.test('encodes comma array values with the same encoder arguments as other values, with encodeValuesOnly', function (st) {
639
+ var commaValuesOnly = { arrayFormat: 'comma', encodeValuesOnly: true };
640
+
641
+ st.equal(
642
+ qs.stringify({ a: ['é', 'x'], b: 'é' }, utils.assign({ charset: 'iso-8859-1' }, commaValuesOnly)),
643
+ 'a=%E9,x&b=%E9',
644
+ 'respects charset iso-8859-1'
645
+ );
646
+ st.deepEqual(
647
+ qs.parse(qs.stringify({ a: ['é', 'x'] }, utils.assign({ charset: 'iso-8859-1' }, commaValuesOnly)), { charset: 'iso-8859-1', comma: true }),
648
+ { a: ['é', 'x'] },
649
+ 'round-trips in iso-8859-1'
650
+ );
651
+ st.equal(
652
+ qs.stringify({ a: ['☺'] }, utils.assign({ charset: 'iso-8859-1' }, commaValuesOnly)),
653
+ 'a=%26%239786%3B',
654
+ 'encodes unrepresentable chars as numeric entities in iso-8859-1'
655
+ );
656
+ st.equal(
657
+ qs.stringify({ a: ['(x)', 'y z'], b: '(x)' }, utils.assign({ format: qs.formats.RFC1738 }, commaValuesOnly)),
658
+ 'a=(x),y+z&b=(x)',
659
+ 'respects format RFC1738'
660
+ );
661
+
662
+ var calls = [];
663
+ st.equal(
664
+ qs.stringify({ a: ['b', 'c'] }, utils.assign({
665
+ encoder: function (str, defaultEncoder, charset, kind, format) {
666
+ calls[calls.length] = [str, typeof defaultEncoder, charset, kind, format];
667
+ return defaultEncoder(str, defaultEncoder, charset, kind, format).toUpperCase();
668
+ }
669
+ }, commaValuesOnly)),
670
+ 'a=B,C',
671
+ 'a custom encoder can call defaultEncoder'
672
+ );
673
+ st.deepEqual(
674
+ calls,
675
+ [
676
+ ['b', 'function', 'utf-8', 'value', qs.formats.RFC3986],
677
+ ['c', 'function', 'utf-8', 'value', qs.formats.RFC3986]
678
+ ],
679
+ 'a custom encoder receives defaultEncoder, charset, kind, and format'
680
+ );
681
+
682
+ st.end();
683
+ });
684
+
572
685
  t.test('stringifies comma and empty non-array values', function (st) {
573
686
  st.equal(qs.stringify({ a: ',', b: '', c: 'c,d%' }, { encode: false, arrayFormat: 'indices' }), 'a=,&b=&c=c,d%');
574
687
  st.equal(qs.stringify({ a: ',', b: '', c: 'c,d%' }, { encode: false, arrayFormat: 'brackets' }), 'a=,&b=&c=c,d%');
@@ -865,6 +978,112 @@ test('stringify()', function (t) {
865
978
  st.end();
866
979
  });
867
980
 
981
+ t.test('arrayFormat=comma: does not crash on entries with no callable `toString`', function (st) {
982
+ var json = JSON.parse('{ "a": ["b", { "toString": "x" }, "c"] }');
983
+ st.equal(qs.stringify(json, { arrayFormat: 'comma' }), 'a=b%2C%5Bobject%20Object%5D%2Cc', 'JSON own `toString`');
984
+ st.equal(qs.stringify(json, { arrayFormat: 'comma', encode: false }), 'a=b,[object Object],c', 'with encode: false');
985
+ st.equal(
986
+ qs.stringify(json, { arrayFormat: 'comma', encodeValuesOnly: true }),
987
+ 'a=b,%5Bobject%20Object%5D,c',
988
+ 'with encodeValuesOnly'
989
+ );
990
+ st.equal(
991
+ qs.stringify(JSON.parse('{ "a": [{ "toString": "x" }] }'), { arrayFormat: 'comma', commaRoundTrip: true }),
992
+ 'a%5B%5D=%5Bobject%20Object%5D',
993
+ 'with commaRoundTrip'
994
+ );
995
+ st.equal(
996
+ qs.stringify(JSON.parse('{ "a": [{ "toString": "x" }] }'), { arrayFormat: 'comma' }),
997
+ qs.stringify({ a: [{ b: 1 }] }, { arrayFormat: 'comma' }),
998
+ 'renders the same as a plain object entry'
999
+ );
1000
+
1001
+ st.equal(
1002
+ qs.stringify(qs.parse('a[][b]=c', { plainObjects: true }), { arrayFormat: 'comma' }),
1003
+ 'a=%5Bobject%20Object%5D',
1004
+ 'null-prototype entry from `parse` with plainObjects'
1005
+ );
1006
+ st.equal(
1007
+ qs.stringify(qs.parse('a[][b]=c', { plainObjects: true }), { arrayFormat: 'comma', encodeValuesOnly: true }),
1008
+ 'a=%5Bobject%20Object%5D',
1009
+ 'null-prototype entry from `parse` with plainObjects, with encodeValuesOnly'
1010
+ );
1011
+ st.equal(
1012
+ qs.stringify(qs.parse('a[0][toString]=x', { allowPrototypes: true }), { arrayFormat: 'comma' }),
1013
+ 'a=%5Bobject%20Object%5D',
1014
+ 'own `toString` entry from `parse` with allowPrototypes'
1015
+ );
1016
+
1017
+ var nested = JSON.parse('{ "a": [[1, [{ "toString": "x" }]], "y"] }');
1018
+ st.equal(qs.stringify(nested, { arrayFormat: 'comma', encode: false }), 'a=1,[object Object],y', 'nested array');
1019
+ st.equal(
1020
+ qs.stringify(nested, { arrayFormat: 'comma', encodeValuesOnly: true }),
1021
+ 'a=1%2C%5Bobject%20Object%5D,y',
1022
+ 'nested array, with encodeValuesOnly'
1023
+ );
1024
+ st.equal(qs.stringify({ a: [[1, 2], 3] }, { arrayFormat: 'comma', encode: false }), 'a=1,2,3', 'nested array is still flattened');
1025
+
1026
+ var cyclic = [{ toString: 'x' }];
1027
+ cyclic[cyclic.length] = cyclic;
1028
+ st.equal(
1029
+ qs.stringify({ a: cyclic }, { arrayFormat: 'comma', encode: false }),
1030
+ 'a=[object Object],[object Object],',
1031
+ 'cyclic nested array joins its cycle as empty'
1032
+ );
1033
+
1034
+ var customJoin = [{ toString: 'x' }];
1035
+ customJoin.join = function () { return 'j'; };
1036
+ st.equal(qs.stringify({ a: [customJoin, 'y'] }, { arrayFormat: 'comma', encode: false }), 'a=j,y', 'own `join` is still used');
1037
+
1038
+ st.equal(
1039
+ qs.stringify({ a: [{ toString: 'x', valueOf: function () { return 7; } }] }, { arrayFormat: 'comma' }),
1040
+ 'a=7',
1041
+ 'primitive `valueOf` is still used'
1042
+ );
1043
+ st.equal(
1044
+ qs.stringify({ a: [{ toString: 'x', valueOf: function () { return null; } }] }, { arrayFormat: 'comma' }),
1045
+ 'a=null',
1046
+ 'null `valueOf` is still used'
1047
+ );
1048
+ st.equal(
1049
+ qs.stringify({ a: [{ toString: 'x', valueOf: function () { return {}; } }] }, { arrayFormat: 'comma' }),
1050
+ 'a=%5Bobject%20Object%5D',
1051
+ 'non-primitive `valueOf` falls back'
1052
+ );
1053
+
1054
+ st.equal(
1055
+ qs.stringify({ a: [{ toString: function () { return 't'; } }, new Date(0)] }, { arrayFormat: 'comma', encode: false }),
1056
+ 'a=t,1970-01-01T00:00:00.000Z',
1057
+ 'callable `toString` and dates are unaffected'
1058
+ );
1059
+
1060
+ st['throws'](
1061
+ function () {
1062
+ qs.stringify({ a: [{ toString: function () { throw new SyntaxError('mine'); } }] }, { arrayFormat: 'comma' });
1063
+ },
1064
+ SyntaxError,
1065
+ 'a throwing `toString` still throws'
1066
+ );
1067
+ st['throws'](
1068
+ function () {
1069
+ qs.stringify({ a: [{ toString: 'x', valueOf: function () { throw new EvalError('mine'); } }] }, { arrayFormat: 'comma' });
1070
+ },
1071
+ EvalError,
1072
+ 'a throwing `valueOf` still throws'
1073
+ );
1074
+
1075
+ st.end();
1076
+ });
1077
+
1078
+ t.test('arrayFormat=comma: entries with `Symbol.toPrimitive` are unaffected', { skip: !hasSymbols() || typeof Symbol.toPrimitive !== 'symbol' }, function (st) {
1079
+ var entry = { toString: 'x' };
1080
+ entry[Symbol.toPrimitive] = function () { return 'p'; };
1081
+ st.equal(qs.stringify({ a: [entry] }, { arrayFormat: 'comma' }), 'a=p');
1082
+ st.equal(qs.stringify({ a: [entry] }, { arrayFormat: 'comma', encodeValuesOnly: true }), 'a=p');
1083
+
1084
+ st.end();
1085
+ });
1086
+
868
1087
  t.test('stringifies a null object', { skip: !hasProto }, function (st) {
869
1088
  st.equal(qs.stringify({ __proto__: null, a: 'b' }), 'a=b');
870
1089
  st.end();
@@ -1480,6 +1699,17 @@ test('stringify()', function (t) {
1480
1699
  st.end();
1481
1700
  });
1482
1701
 
1702
+ t.test('encodes a literal `+` as `%2B` in iso-8859-1 mode so it round-trips', function (st) {
1703
+ st.equal(qs.stringify({ a: 'b+c' }, { charset: 'iso-8859-1' }), 'a=b%2Bc', 'a `+` in a value is percent-encoded');
1704
+ st.equal(qs.stringify({ 'a+b': 'c' }, { charset: 'iso-8859-1' }), 'a%2Bb=c', 'a `+` in a key is percent-encoded');
1705
+ st.deepEqual(
1706
+ qs.parse(qs.stringify({ a: 'b+c' }, { charset: 'iso-8859-1' }), { charset: 'iso-8859-1' }),
1707
+ { a: 'b+c' },
1708
+ 'the `+` survives a round-trip instead of decoding to a space'
1709
+ );
1710
+ st.end();
1711
+ });
1712
+
1483
1713
  t.test('respects an explicit charset of utf-8 (the default)', function (st) {
1484
1714
  st.equal(qs.stringify({ a: 'æ' }, { charset: 'utf-8' }), 'a=%C3%A6');
1485
1715
  st.end();