@slates/provider 1.0.0-rc.27 → 1.0.0-rc.29

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.
@@ -0,0 +1,654 @@
1
+ import { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { AuthConfigSecretRedactor } from '../auth/redact';
4
+ import { SlateContext } from '../context/context';
5
+ import { runWithContext } from '../context/hook';
6
+ import { SlateError } from './base';
7
+ import {
8
+ BAGGAGE_MAX_SERIALIZED_SIZE,
9
+ BAGGAGE_REDACTED_VALUE,
10
+ BAGGAGE_RESPONSE_MAX_DEPTH,
11
+ BAGGAGE_TRUNCATED_VALUE,
12
+ ERROR_TEXT_MAX_LENGTH,
13
+ sanitizeErrorText,
14
+ sanitizeForBaggage
15
+ } from './utils';
16
+
17
+ let testLogger = { debug() {}, error() {}, info() {}, progress() {}, warn() {} } as any;
18
+
19
+ let contextWithAuth = (auth: Record<string, unknown>) =>
20
+ new SlateContext({}, {}, auth, { key: 'example' } as any, testLogger);
21
+
22
+ let mixedAuthConfig = {
23
+ token: 'tg-secret-123456',
24
+ accountId: '123456',
25
+ email: 'me@acme.com',
26
+ tokenType: 'Bearer',
27
+ subdomain: 'acme',
28
+ scope: 'read',
29
+ expiresAt: '2026-10-13T12:00:00.000Z',
30
+ clientId: '0oa1b2c3d4e5f6g7h8i9'
31
+ };
32
+
33
+ let nest = (levels: number, leaf: unknown): unknown => {
34
+ let value = leaf;
35
+ for (let i = 0; i < levels; i++) value = { [`level${levels - i}`]: value };
36
+ return value;
37
+ };
38
+
39
+ let graphqlErrorBody = {
40
+ errors: [
41
+ {
42
+ message: 'Argument Validation Error',
43
+ path: ['issueCreate'],
44
+ extensions: {
45
+ code: 'INVALID_INPUT',
46
+ userPresentableMessage: 'Team is required',
47
+ validationErrors: [{ field: 'teamId', message: 'must not be empty' }]
48
+ }
49
+ }
50
+ ]
51
+ };
52
+
53
+ describe('sanitizeForBaggage', () => {
54
+ it('keeps the default depth limit for generic baggage values', () => {
55
+ expect(sanitizeForBaggage(nest(5, 'deep'))).toEqual({
56
+ level1: { level2: { level3: { level4: BAGGAGE_TRUNCATED_VALUE } } }
57
+ });
58
+ });
59
+
60
+ it('preserves nested provider error details at the response depth', () => {
61
+ expect(sanitizeForBaggage(graphqlErrorBody)).toMatchObject({
62
+ errors: [{ extensions: { code: BAGGAGE_TRUNCATED_VALUE } }]
63
+ });
64
+
65
+ expect(
66
+ sanitizeForBaggage(graphqlErrorBody, { maxDepth: BAGGAGE_RESPONSE_MAX_DEPTH })
67
+ ).toEqual(graphqlErrorBody);
68
+
69
+ let quickbooksFault = {
70
+ Fault: {
71
+ Error: [
72
+ { Message: 'Invalid Reference Id', Detail: 'Account 99 not found', code: '2500' }
73
+ ],
74
+ type: 'ValidationFault'
75
+ }
76
+ };
77
+ expect(
78
+ sanitizeForBaggage(quickbooksFault, { maxDepth: BAGGAGE_RESPONSE_MAX_DEPTH })
79
+ ).toEqual(quickbooksFault);
80
+
81
+ expect(
82
+ sanitizeForBaggage(nest(BAGGAGE_RESPONSE_MAX_DEPTH + 1, 'deep'), {
83
+ maxDepth: BAGGAGE_RESPONSE_MAX_DEPTH
84
+ })
85
+ ).toEqual(nest(BAGGAGE_RESPONSE_MAX_DEPTH, BAGGAGE_TRUNCATED_VALUE));
86
+ });
87
+
88
+ it('redacts secret-named keys at every depth', () => {
89
+ let sanitized = sanitizeForBaggage(
90
+ {
91
+ access_token: 'at-123',
92
+ nested: {
93
+ errors: [
94
+ {
95
+ extensions: {
96
+ refreshToken: 'rt-123',
97
+ client_secret: 'cs-123',
98
+ githubToken: 'gh-123',
99
+ session: { id: 's-1' },
100
+ headers: { Authorization: 'Bearer abc', Cookie: 'sid=1' },
101
+ password: 'hunter2',
102
+ 'x-api-key': 'key-123',
103
+ private_key: 'pk-123',
104
+ token_type: 'bearer',
105
+ code: 'BAD_INPUT',
106
+ message: 'kept'
107
+ }
108
+ }
109
+ ]
110
+ }
111
+ },
112
+ { maxDepth: BAGGAGE_RESPONSE_MAX_DEPTH }
113
+ ) as any;
114
+
115
+ let extensions = sanitized.nested.errors[0].extensions;
116
+ expect(sanitized.access_token).toBe(BAGGAGE_REDACTED_VALUE);
117
+ expect(extensions).toMatchObject({
118
+ refreshToken: BAGGAGE_REDACTED_VALUE,
119
+ client_secret: BAGGAGE_REDACTED_VALUE,
120
+ githubToken: BAGGAGE_REDACTED_VALUE,
121
+ session: BAGGAGE_REDACTED_VALUE,
122
+ password: BAGGAGE_REDACTED_VALUE,
123
+ 'x-api-key': BAGGAGE_REDACTED_VALUE,
124
+ private_key: BAGGAGE_REDACTED_VALUE,
125
+ token_type: 'bearer',
126
+ code: 'BAD_INPUT',
127
+ message: 'kept'
128
+ });
129
+ expect(extensions.headers).toEqual({
130
+ Authorization: BAGGAGE_REDACTED_VALUE,
131
+ Cookie: BAGGAGE_REDACTED_VALUE
132
+ });
133
+ expect(JSON.stringify(sanitized)).not.toMatch(/at-123|rt-123|cs-123|gh-123|hunter2/);
134
+ });
135
+
136
+ it('scrubs known credential formats inside string values', () => {
137
+ let jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.c2lnbmF0dXJlLXZhbHVl';
138
+ let sanitized = sanitizeForBaggage({
139
+ detail: `Rejected Authorization: Bearer ${jwt}`,
140
+ hint: 'retry with api_key=abcdef123456'
141
+ }) as Record<string, string>;
142
+
143
+ expect(sanitized.detail).not.toContain(jwt);
144
+ expect(sanitized.hint).toBe('retry with api_key=[redacted]');
145
+ });
146
+
147
+ it('keeps ordinary error prose intact', () => {
148
+ let messages = [
149
+ 'Invalid token provided',
150
+ 'token expired',
151
+ 'Basic plan required for this feature',
152
+ 'Digest authentication failed',
153
+ 'Request failed with error code=404',
154
+ 'The access token has been revoked for organizationAdministrators'
155
+ ];
156
+
157
+ expect(sanitizeForBaggage({ messages })).toEqual({ messages });
158
+ });
159
+
160
+ it('does not throw for values with throwing getters or proxy traps', () => {
161
+ let throwingGetter = {
162
+ get detail() {
163
+ throw new Error('boom');
164
+ }
165
+ };
166
+ let throwingProxy = new Proxy(
167
+ {},
168
+ {
169
+ ownKeys() {
170
+ throw new Error('boom');
171
+ }
172
+ }
173
+ );
174
+
175
+ expect(sanitizeForBaggage({ nested: throwingGetter, kept: 'ok' })).toEqual({
176
+ nested: { detail: '[unserializable]' },
177
+ kept: 'ok'
178
+ });
179
+ expect(sanitizeForBaggage(throwingProxy)).toBe('[unserializable]');
180
+ expect(sanitizeForBaggage({ proxy: throwingProxy, kept: 'ok' })).toEqual({
181
+ proxy: '[unserializable]',
182
+ kept: 'ok'
183
+ });
184
+ });
185
+
186
+ it('keeps flags, counts, cursors, and descriptors under secret-sounding keys', () => {
187
+ let value = {
188
+ pageToken: 'cursor-abc123',
189
+ nextPageToken: 'cursor-def456',
190
+ next_page_token: 'cursor-ghi789',
191
+ tokenCount: 12,
192
+ tokenLimit: 100,
193
+ sessionTimeout: 30,
194
+ sessionExpired: true,
195
+ authError: 'invalid_grant',
196
+ signatureValid: false,
197
+ authorizationUrl: 'https://auth.example.com/authorize',
198
+ token_type: 'bearer',
199
+ token: true,
200
+ secretName: 'prod-db',
201
+ apiKeyId: 'key_123'
202
+ };
203
+
204
+ expect(sanitizeForBaggage(value)).toEqual(value);
205
+ });
206
+
207
+ it('still redacts credential-bearing keys after the suffix allowlist', () => {
208
+ let sanitized = sanitizeForBaggage({
209
+ access_token: 'a',
210
+ refresh_token: 'b',
211
+ id_token: 'c',
212
+ apiKey: 'd',
213
+ api_key: 'e',
214
+ client_secret: 'f',
215
+ password: 'g',
216
+ secret: 'h',
217
+ authorization: 'i',
218
+ cookie: 'j',
219
+ 'x-api-key': 'k',
220
+ sessionToken: 'l',
221
+ privateKey: 'm',
222
+ sessionId: 'n',
223
+ session_id: 'o',
224
+ pin: 1234,
225
+ cvv: 123
226
+ }) as Record<string, unknown>;
227
+
228
+ for (let value of Object.values(sanitized)) expect(value).toBe(BAGGAGE_REDACTED_VALUE);
229
+ });
230
+
231
+ it('keeps validation messages under secret-named fields but not passphrases', () => {
232
+ let sanitized = sanitizeForBaggage(
233
+ {
234
+ errors: {
235
+ password: 'is too short (minimum is 8 characters)',
236
+ token: ['This field is required.'],
237
+ api_key: ['The api key must be at least 32 characters.']
238
+ },
239
+ passphrase: 'correct horse battery staple',
240
+ secret: 'hunter2 is required',
241
+ client_secret: ['abc', 'This field is required.'],
242
+ refresh_token: `required ${'x'.repeat(40)} token`
243
+ },
244
+ { maxDepth: BAGGAGE_RESPONSE_MAX_DEPTH }
245
+ );
246
+
247
+ expect(sanitized).toEqual({
248
+ errors: {
249
+ password: 'is too short (minimum is 8 characters)',
250
+ token: ['This field is required.'],
251
+ api_key: ['The api key must be at least 32 characters.']
252
+ },
253
+ passphrase: BAGGAGE_REDACTED_VALUE,
254
+ secret: BAGGAGE_REDACTED_VALUE,
255
+ client_secret: BAGGAGE_REDACTED_VALUE,
256
+ refresh_token: BAGGAGE_REDACTED_VALUE
257
+ });
258
+ });
259
+
260
+ it('charges undefined array items and stores bigints as strings', () => {
261
+ expect(
262
+ sanitizeForBaggage({ list: [1, undefined, 2], big: 12345678901234567890n })
263
+ ).toEqual({
264
+ list: [1, null, 2],
265
+ big: '12345678901234567890'
266
+ });
267
+ expect(() => JSON.stringify(sanitizeForBaggage({ big: 1n }))).not.toThrow();
268
+
269
+ let sparse = sanitizeForBaggage(
270
+ Array.from({ length: 20 }, () => undefined),
271
+ {
272
+ maxSerializedSize: 30
273
+ }
274
+ );
275
+ expect(JSON.stringify(sparse).length).toBeLessThanOrEqual(30 + 8);
276
+ });
277
+
278
+ it('skips a key that overflows the budget but keeps later keys that fit', () => {
279
+ let sanitized = sanitizeForBaggage(
280
+ { [`k${'x'.repeat(200)}`]: 1, short: 'ok' },
281
+ { maxSerializedSize: 64 }
282
+ );
283
+ expect(sanitized).toEqual({ short: 'ok' });
284
+ });
285
+
286
+ it('redacts literal auth-config secrets inside a Slate context', async () => {
287
+ let context = new SlateContext(
288
+ {},
289
+ {},
290
+ { token: 'tg-secret-123456', region: 'eu' },
291
+ { key: 'example' } as any,
292
+ { debug() {}, error() {}, info() {}, progress() {}, warn() {} } as any
293
+ );
294
+
295
+ let sanitized = await runWithContext(context, async () =>
296
+ sanitizeForBaggage({
297
+ echoed: 'tg-secret-123456',
298
+ detail: 'request to /bottg-secret-123456/getMe failed',
299
+ region: 'eu'
300
+ })
301
+ );
302
+
303
+ expect(JSON.stringify(sanitized)).not.toContain('tg-secret-123456');
304
+ // Only secret-named auth-config fields are replaced; `region` stays readable.
305
+ expect(sanitized).toMatchObject({ region: 'eu' });
306
+ expect(sanitizeForBaggage({ echoed: 'tg-secret-123456' })).toEqual({
307
+ echoed: 'tg-secret-123456'
308
+ });
309
+ });
310
+
311
+ it('caps strings, arrays, and object keys', () => {
312
+ let sanitized = sanitizeForBaggage({
313
+ text: 'x'.repeat(5_000),
314
+ list: Array.from({ length: 50 }, (_, i) => i),
315
+ wide: Object.fromEntries(Array.from({ length: 50 }, (_, i) => [`k${i}`, i]))
316
+ }) as { text: string; list: number[]; wide: Record<string, number> };
317
+
318
+ expect(sanitized.text).toBe(`${'x'.repeat(2_000)}...[truncated]`);
319
+ expect(sanitized.list).toHaveLength(20);
320
+ expect(Object.keys(sanitized.wide)).toHaveLength(30);
321
+ });
322
+
323
+ it('bounds the serialized size of large nested bodies', () => {
324
+ let leaf = 'y'.repeat(3_000);
325
+ // Children are shared by reference so the input stays small in memory while
326
+ // its logical size (40^8 leaves) is enormous.
327
+ let wide = (levels: number): unknown => {
328
+ if (levels === 0) return leaf;
329
+ let child = wide(levels - 1);
330
+ return Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`k${i}`, child]));
331
+ };
332
+
333
+ let sanitized = sanitizeForBaggage(wide(BAGGAGE_RESPONSE_MAX_DEPTH - 1), {
334
+ maxDepth: BAGGAGE_RESPONSE_MAX_DEPTH
335
+ });
336
+ let size = JSON.stringify(sanitized).length;
337
+
338
+ expect(size).toBeGreaterThan(BAGGAGE_MAX_SERIALIZED_SIZE * 0.9);
339
+ expect(size).toBeLessThanOrEqual(BAGGAGE_MAX_SERIALIZED_SIZE + 256);
340
+ expect(JSON.stringify(sanitized).includes('...[truncated]')).toBe(true);
341
+
342
+ let escaped = sanitizeForBaggage(Array.from({ length: 20 }, () => '\u0001'.repeat(2_000)));
343
+ expect(JSON.stringify(escaped).length).toBeLessThanOrEqual(
344
+ BAGGAGE_MAX_SERIALIZED_SIZE + 256
345
+ );
346
+ });
347
+
348
+ it('does not let __proto__ keys change the prototype', () => {
349
+ let sanitized = sanitizeForBaggage(JSON.parse('{"__proto__": {"polluted": true}}'));
350
+ expect(Object.getPrototypeOf(sanitized)).toBe(Object.prototype);
351
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
352
+ });
353
+ });
354
+
355
+ describe('SlateError.fromAxios response baggage', () => {
356
+ it('keeps nested upstream error bodies and redacts secrets in baggage and url', () => {
357
+ let config = {
358
+ url: '/graphql?api_key=secret-key-value&first=10',
359
+ baseURL: 'https://api.example.com',
360
+ method: 'post',
361
+ headers: {}
362
+ } as InternalAxiosRequestConfig;
363
+ let response = {
364
+ status: 400,
365
+ statusText: 'Bad Request',
366
+ headers: {},
367
+ config,
368
+ data: {
369
+ ...graphqlErrorBody,
370
+ debug: { request: { headers: { authorization: 'Bearer leaked-token' } } }
371
+ }
372
+ } as AxiosResponse;
373
+
374
+ let error = SlateError.fromAxios(
375
+ new AxiosError('Request failed', 'ERR_BAD_REQUEST', config, undefined, response)
376
+ );
377
+
378
+ expect(error.data.baggage?.response).toMatchObject({
379
+ errors: [
380
+ {
381
+ extensions: {
382
+ code: 'INVALID_INPUT',
383
+ validationErrors: [{ field: 'teamId', message: 'must not be empty' }]
384
+ }
385
+ }
386
+ ],
387
+ debug: { request: { headers: { authorization: BAGGAGE_REDACTED_VALUE } } }
388
+ });
389
+ expect(error.data.upstream?.url).toBe(
390
+ 'https://api.example.com/graphql?api_key=[redacted]&first=10'
391
+ );
392
+ expect(JSON.stringify(error.toResponse())).not.toMatch(/leaked-token|secret-key-value/);
393
+ });
394
+
395
+ it('redacts auth-config secrets embedded in URL paths and upstream messages', async () => {
396
+ let token = '123456:ABCdefGHIjklMNOpqrSTU';
397
+ let context = new SlateContext(
398
+ {},
399
+ {},
400
+ { token },
401
+ { key: 'telegram' } as any,
402
+ { debug() {}, error() {}, info() {}, progress() {}, warn() {} } as any
403
+ );
404
+ let config = {
405
+ url: 'sendMessage',
406
+ baseURL: `https://api.telegram.org/bot${token}/`,
407
+ method: 'post',
408
+ headers: {}
409
+ } as InternalAxiosRequestConfig;
410
+ let response = {
411
+ status: 401,
412
+ statusText: 'Unauthorized',
413
+ headers: {},
414
+ config,
415
+ data: {
416
+ description: `Unauthorized: token ${token} rejected; Authorization: Bearer abc`,
417
+ echoed: { value: token }
418
+ }
419
+ } as AxiosResponse;
420
+
421
+ let error = await runWithContext(context, async () =>
422
+ SlateError.fromAxios(
423
+ new AxiosError('Request failed', 'ERR_BAD_REQUEST', config, undefined, response)
424
+ )
425
+ );
426
+
427
+ expect(error.data.upstream?.url).toMatch(
428
+ /^https:\/\/api\.telegram\.org\/bot\$\$MT\$secret\$authConfig\$token\$\$\/sendMessage$/
429
+ );
430
+ expect(error.data.message).toBe(
431
+ 'Unauthorized: token $$MT$secret$authConfig$token$$ rejected; Authorization: Bearer [redacted]'
432
+ );
433
+ expect(JSON.stringify(error.toResponse())).not.toContain(token);
434
+ expect(JSON.stringify(error.toResponse())).not.toContain('ABCdefGHI');
435
+ });
436
+
437
+ it('scrubs credentials from inferred messages without a context and keeps prose', () => {
438
+ let build = (description: string) => {
439
+ let config = { url: '/x', baseURL: 'https://api.example.com', headers: {} } as any;
440
+ let response = {
441
+ status: 400,
442
+ statusText: 'Bad Request',
443
+ headers: {},
444
+ config,
445
+ data: { description }
446
+ } as AxiosResponse;
447
+ return SlateError.fromAxios(
448
+ new AxiosError('Request failed', 'ERR_BAD_REQUEST', config, undefined, response)
449
+ ).data.message;
450
+ };
451
+
452
+ expect(build("Rejected {'access_token': 'abc'}")).toBe(
453
+ "Rejected {'access_token': '[redacted]'}"
454
+ );
455
+ expect(build('Invalid token provided')).toBe('Invalid token provided');
456
+ expect(build('Request failed with error code=404')).toBe(
457
+ 'Request failed with error code=404'
458
+ );
459
+ expect(build('x'.repeat(5_000))).toHaveLength(2_003);
460
+ });
461
+ });
462
+
463
+ describe('auth-config literal redaction scope', () => {
464
+ it('keeps non-secret auth-config values in baggage, error text, and URLs', async () => {
465
+ let context = contextWithAuth(mixedAuthConfig);
466
+ let body = {
467
+ accountId: '123456',
468
+ email: 'me@acme.com',
469
+ type: 'Bearer',
470
+ subdomain: 'acme',
471
+ scope: 'read',
472
+ expiresAt: '2026-10-13T12:00:00.000Z',
473
+ clientId: '0oa1b2c3d4e5f6g7h8i9',
474
+ detail: 'workspace acme (account 123456) for me@acme.com',
475
+ echoed: 'tg-secret-123456'
476
+ };
477
+
478
+ let sanitized = await runWithContext(context, async () => sanitizeForBaggage(body));
479
+ expect(sanitized).toEqual({
480
+ ...body,
481
+ echoed: '$$MT$secret$authConfig$token'
482
+ });
483
+
484
+ let message = await runWithContext(context, async () =>
485
+ sanitizeErrorText('Subdomain acme not found for account 123456; token tg-secret-123456')
486
+ );
487
+ expect(message).toBe(
488
+ 'Subdomain acme not found for account 123456; token $$MT$secret$authConfig$token$$'
489
+ );
490
+
491
+ let config = {
492
+ url: '/accounts?account_id=123456&scope=read&email=me@acme.com',
493
+ baseURL: 'https://acme.example.com',
494
+ method: 'get',
495
+ headers: {}
496
+ } as InternalAxiosRequestConfig;
497
+ let response = {
498
+ status: 404,
499
+ statusText: 'Not Found',
500
+ headers: {},
501
+ config,
502
+ data: { message: 'acme' }
503
+ } as AxiosResponse;
504
+ let error = await runWithContext(context, async () =>
505
+ SlateError.fromAxios(
506
+ new AxiosError('Request failed', 'ERR_BAD_REQUEST', config, undefined, response)
507
+ )
508
+ );
509
+
510
+ expect(error.data.message).toBe('acme');
511
+ expect(error.data.upstream?.url).toBe(
512
+ 'https://acme.example.com/accounts?account_id=123456&scope=read&email=me@acme.com'
513
+ );
514
+ });
515
+
516
+ it('redacts camelCase and snake_case secret auth fields embedded in text', () => {
517
+ let redactor = new AuthConfigSecretRedactor({
518
+ apiSecret: 'api-secret-value-1',
519
+ accessKey: 'access-key-value-2',
520
+ botToken: 'bot-token-value-3',
521
+ githubToken: 'github-token-value-4',
522
+ client_secret: 'client-secret-value-5',
523
+ subscriptionKey: 'subscription-key-6',
524
+ oauth: { refreshToken: 'nested-refresh-7' }
525
+ });
526
+
527
+ let text = sanitizeErrorText(
528
+ 'got api-secret-value-1, access-key-value-2, bot-token-value-3, github-token-value-4, ' +
529
+ 'client-secret-value-5, subscription-key-6, nested-refresh-7',
530
+ redactor
531
+ );
532
+
533
+ expect(text).toBe(
534
+ 'got $$MT$secret$authConfig$apiSecret$$, $$MT$secret$authConfig$accessKey$$, ' +
535
+ '$$MT$secret$authConfig$botToken$$, $$MT$secret$authConfig$githubToken$$, ' +
536
+ '$$MT$secret$authConfig$client_secret$$, $$MT$secret$authConfig$subscriptionKey$$, ' +
537
+ '$$MT$secret$authConfig$oauth.refreshToken$$'
538
+ );
539
+ });
540
+
541
+ it('still replaces a secret value shared with a non-secret field', () => {
542
+ let redactor = new AuthConfigSecretRedactor({
543
+ token: 'shared-value',
544
+ username: 'shared-value'
545
+ });
546
+ expect(sanitizeForBaggage({ echoed: 'shared-value' }, { redactor })).toEqual({
547
+ echoed: '$$MT$secret$authConfig$token'
548
+ });
549
+ });
550
+ });
551
+
552
+ describe('sanitizeErrorText', () => {
553
+ it('uses the context redactor for undefined and skips it for null', async () => {
554
+ let context = contextWithAuth({ token: 'tg-secret-123456' });
555
+
556
+ await runWithContext(context, async () => {
557
+ expect(sanitizeErrorText('bad tg-secret-123456')).toBe(
558
+ 'bad $$MT$secret$authConfig$token$$'
559
+ );
560
+ expect(sanitizeErrorText('bad tg-secret-123456', null)).toBe('bad tg-secret-123456');
561
+ });
562
+ });
563
+
564
+ it('caps its input and redacts credentials straddling the cut', () => {
565
+ let huge = sanitizeErrorText('x'.repeat(200_000));
566
+ expect(huge).toBe(`${'x'.repeat(ERROR_TEXT_MAX_LENGTH)}...[truncated]`);
567
+
568
+ let key = `sk_live_${'a'.repeat(24)}`;
569
+ let straddling = sanitizeErrorText(
570
+ `${'y'.repeat(ERROR_TEXT_MAX_LENGTH - 5)} ${key} ${'z'.repeat(1_000)}`
571
+ );
572
+ expect(straddling).not.toContain('sk_li');
573
+ expect(straddling.endsWith('...[truncated]')).toBe(true);
574
+
575
+ let pairs = 'a=b '.repeat(50_000);
576
+ let started = Date.now();
577
+ sanitizeErrorText(pairs, null);
578
+ expect(Date.now() - started).toBeLessThan(1_000);
579
+ });
580
+ });
581
+
582
+ describe('sanitizeForBaggage secret key classification', () => {
583
+ it('redacts session identifiers and secret ids despite the id suffix', () => {
584
+ let sanitized = sanitizeForBaggage({
585
+ userSessionId: 'u1',
586
+ chat_session_ids: ['c1'],
587
+ sess_id: 's0',
588
+ JSESSIONID: 'j1',
589
+ PHPSESSID: 'p1',
590
+ sessid: 's1',
591
+ 'ASP.NET_SessionId': 'a1',
592
+ secret_id: 'x1',
593
+ clientSecretId: 'x2'
594
+ }) as Record<string, unknown>;
595
+
596
+ for (let value of Object.values(sanitized)) expect(value).toBe(BAGGAGE_REDACTED_VALUE);
597
+ });
598
+
599
+ it('keeps non-secret ids and timestamps under secret-sounding keys', () => {
600
+ let value = {
601
+ accountId: 'acc_1',
602
+ tokenId: 'tok_1',
603
+ tokenExpiresAt: '2026-10-13T12:00:00.000Z',
604
+ apiKeyId: 'key_123'
605
+ };
606
+ expect(sanitizeForBaggage(value)).toEqual(value);
607
+ });
608
+
609
+ it('redacts numbers under code, secret, and key fields but keeps plain codes and counts', () => {
610
+ let sanitized = sanitizeForBaggage({
611
+ verification_code: 123456,
612
+ mfaCode: 654321,
613
+ auth_code: 1111,
614
+ client_secret: 42,
615
+ api_key: 12345,
616
+ code: 404,
617
+ error_code: 500,
618
+ tokenCount: 3,
619
+ token: 0
620
+ });
621
+
622
+ expect(sanitized).toEqual({
623
+ verification_code: BAGGAGE_REDACTED_VALUE,
624
+ mfaCode: BAGGAGE_REDACTED_VALUE,
625
+ auth_code: BAGGAGE_REDACTED_VALUE,
626
+ client_secret: BAGGAGE_REDACTED_VALUE,
627
+ api_key: BAGGAGE_REDACTED_VALUE,
628
+ code: 404,
629
+ error_code: 500,
630
+ tokenCount: 3,
631
+ token: 0
632
+ });
633
+ });
634
+ });
635
+
636
+ describe('SlateError.fromAxios message scrubbing order', () => {
637
+ it('scrubs a credential that straddles the message cap before cutting', () => {
638
+ let config = { url: '/x', baseURL: 'https://api.example.com', headers: {} } as any;
639
+ let response = {
640
+ status: 400,
641
+ statusText: 'Bad Request',
642
+ headers: {},
643
+ config,
644
+ data: { description: `${'x'.repeat(1_985)} Bearer abcdef123456ghijkl` }
645
+ } as AxiosResponse;
646
+
647
+ let message = SlateError.fromAxios(
648
+ new AxiosError('Request failed', 'ERR_BAD_REQUEST', config, undefined, response)
649
+ ).data.message;
650
+
651
+ expect(message).not.toContain('abcdef1');
652
+ expect(message).toHaveLength(2_003);
653
+ });
654
+ });