@heroku/js-blanket 0.0.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.
@@ -0,0 +1,516 @@
1
+ /**
2
+ * Type Safety Tests for Core Scrubber
3
+ *
4
+ * These tests validate that TypeScript types are preserved correctly through scrubbing operations.
5
+ * They use compile-time type assertions to ensure type safety without runtime overhead.
6
+ *
7
+ * Run with: pnpm test (type-checked during pretest)
8
+ */
9
+
10
+ import { expect } from 'chai';
11
+ import { Scrubber } from './scrubber.js';
12
+ import type { ScrubConfig, ScrubResult } from './types.js';
13
+
14
+ /**
15
+ * Compile-time type assertion helper
16
+ * If T is not assignable to Expected, TypeScript will error at compile time
17
+ */
18
+ type AssertType<T, Expected> = T extends Expected
19
+ ? Expected extends T
20
+ ? true
21
+ : never
22
+ : never;
23
+
24
+ describe('Type Safety', () => {
25
+ describe('Generic Type Preservation', () => {
26
+ it('preserves simple object types', () => {
27
+ interface User {
28
+ name: string;
29
+ email: string;
30
+ password: string;
31
+ }
32
+
33
+ const scrubber = new Scrubber({ fields: ['password'] });
34
+ const user: User = {
35
+ name: 'John',
36
+ email: 'john@example.com',
37
+ password: 'secret',
38
+ };
39
+
40
+ const result = scrubber.scrub(user);
41
+
42
+ // Compile-time assertion: result.data should be User type
43
+ const typeCheck: AssertType<typeof result.data, User> = true;
44
+ expect(typeCheck).to.be.true;
45
+
46
+ // Runtime validation
47
+ expect(result.data).to.have.property('name');
48
+ expect(result.data).to.have.property('email');
49
+ expect(result.data).to.have.property('password');
50
+ });
51
+
52
+ it('preserves nested object types', () => {
53
+ interface Address {
54
+ street: string;
55
+ city: string;
56
+ zip: string;
57
+ }
58
+
59
+ interface UserProfile {
60
+ user: {
61
+ name: string;
62
+ email: string;
63
+ };
64
+ address: Address;
65
+ metadata: {
66
+ lastLogin: string;
67
+ loginCount: number;
68
+ };
69
+ }
70
+
71
+ const scrubber = new Scrubber({ fields: ['email'] });
72
+ const profile: UserProfile = {
73
+ user: { name: 'John', email: 'john@example.com' },
74
+ address: { street: '123 Main St', city: 'Springfield', zip: '12345' },
75
+ metadata: { lastLogin: '2024-01-01', loginCount: 42 },
76
+ };
77
+
78
+ const _result = scrubber.scrub(profile);
79
+
80
+ // Compile-time assertion
81
+ const typeCheck: AssertType<typeof _result.data, UserProfile> = true;
82
+ expect(typeCheck).to.be.true;
83
+ });
84
+
85
+ it('preserves array types', () => {
86
+ interface Item {
87
+ id: number;
88
+ name: string;
89
+ secret: string;
90
+ }
91
+
92
+ const scrubber = new Scrubber({ fields: ['secret'] });
93
+ const items: Item[] = [
94
+ { id: 1, name: 'Item 1', secret: 'secret1' },
95
+ { id: 2, name: 'Item 2', secret: 'secret2' },
96
+ ];
97
+
98
+ const result = scrubber.scrub(items);
99
+
100
+ // Compile-time assertion
101
+ const typeCheck: AssertType<typeof result.data, Item[]> = true;
102
+ expect(typeCheck).to.be.true;
103
+
104
+ // Runtime validation
105
+ expect(result.data).to.be.an('array');
106
+ expect(result.data).to.have.lengthOf(2);
107
+ });
108
+
109
+ it('preserves union types', () => {
110
+ type ComplexUnion =
111
+ | { type: 'user'; name: string; email: string }
112
+ | { type: 'admin'; name: string; permissions: string[] };
113
+
114
+ const scrubber = new Scrubber({ fields: ['email'] });
115
+ const data: ComplexUnion = {
116
+ type: 'user',
117
+ name: 'John',
118
+ email: 'john@example.com',
119
+ };
120
+
121
+ const _result = scrubber.scrub(data);
122
+
123
+ // Compile-time assertion
124
+ const typeCheck: AssertType<typeof _result.data, ComplexUnion> = true;
125
+ expect(typeCheck).to.be.true;
126
+ });
127
+
128
+ it('preserves readonly types', () => {
129
+ interface ReadonlyUser {
130
+ readonly id: number;
131
+ readonly name: string;
132
+ password: string;
133
+ }
134
+
135
+ const scrubber = new Scrubber({ fields: ['password'] });
136
+ const user: ReadonlyUser = {
137
+ id: 1,
138
+ name: 'John',
139
+ password: 'secret',
140
+ };
141
+
142
+ const result = scrubber.scrub(user);
143
+
144
+ // Compile-time assertion
145
+ const typeCheck: AssertType<typeof result.data, ReadonlyUser> = true;
146
+ expect(typeCheck).to.be.true;
147
+
148
+ // Immutability: original should not be modified
149
+ expect(user.password).to.equal('secret');
150
+ expect(result.data.password).to.equal('[SCRUBBED]');
151
+ });
152
+
153
+ it('preserves optional property types', () => {
154
+ interface PartialUser {
155
+ name: string;
156
+ email?: string;
157
+ phone?: string;
158
+ password: string;
159
+ }
160
+
161
+ const scrubber = new Scrubber({ fields: ['password'] });
162
+ const user: PartialUser = {
163
+ name: 'John',
164
+ email: 'john@example.com',
165
+ // phone is omitted
166
+ password: 'secret',
167
+ };
168
+
169
+ const _result = scrubber.scrub(user);
170
+
171
+ // Compile-time assertion
172
+ const typeCheck: AssertType<typeof _result.data, PartialUser> = true;
173
+ expect(typeCheck).to.be.true;
174
+ });
175
+ });
176
+
177
+ describe('ScrubResult Type', () => {
178
+ it('has correct result structure', () => {
179
+ const scrubber = new Scrubber({ fields: ['password'] });
180
+ const data = { user: 'john', password: 'secret' };
181
+ const result = scrubber.scrub(data);
182
+
183
+ // Type assertion: result should be ScrubResult
184
+ const typeCheck: AssertType<
185
+ typeof result,
186
+ ScrubResult<typeof data>
187
+ > = true;
188
+ expect(typeCheck).to.be.true;
189
+
190
+ // Runtime validation
191
+ expect(result).to.have.property('data');
192
+ expect(result).to.have.property('scrubbed');
193
+ expect(result).to.have.property('scrubbedPaths');
194
+ expect(result.scrubbed).to.be.a('boolean');
195
+ expect(result.scrubbedPaths).to.be.an('array');
196
+ });
197
+
198
+ it('preserves input type in result.data', () => {
199
+ interface Input {
200
+ a: string;
201
+ b: number;
202
+ c: boolean;
203
+ }
204
+
205
+ const scrubber = new Scrubber({ fields: ['a'] });
206
+ const input: Input = { a: 'test', b: 42, c: true };
207
+ const _result: ScrubResult<Input> = scrubber.scrub(input);
208
+
209
+ // Compile-time assertion
210
+ const typeCheck: AssertType<typeof _result.data, Input> = true;
211
+ expect(typeCheck).to.be.true;
212
+ });
213
+ });
214
+
215
+ describe('ScrubConfig Type', () => {
216
+ it('accepts valid configurations', () => {
217
+ const config1: ScrubConfig = {
218
+ fields: ['password', 'apiToken'],
219
+ };
220
+
221
+ const config2: ScrubConfig = {
222
+ fields: ['password', /api[-_]?key/i],
223
+ paths: ['user.email'],
224
+ patterns: [/\d{3}-\d{2}-\d{4}/g],
225
+ replacement: '[REDACTED]',
226
+ };
227
+
228
+ const config3: ScrubConfig = {
229
+ fields: [],
230
+ paths: [],
231
+ patterns: [],
232
+ recursive: false,
233
+ };
234
+
235
+ // All should be valid ScrubConfig types
236
+ expect(config1).to.be.an('object');
237
+ expect(config2).to.be.an('object');
238
+ expect(config3).to.be.an('object');
239
+ });
240
+
241
+ it('allows partial configurations', () => {
242
+ const partial1: ScrubConfig = {};
243
+ const partial2: ScrubConfig = { fields: ['password'] };
244
+ const partial3: ScrubConfig = { replacement: '[X]' };
245
+
246
+ expect(partial1).to.be.an('object');
247
+ expect(partial2).to.be.an('object');
248
+ expect(partial3).to.be.an('object');
249
+ });
250
+ });
251
+
252
+ describe('Complex Type Scenarios', () => {
253
+ it('handles deeply nested generic types', () => {
254
+ interface DeepNested<T> {
255
+ level1: {
256
+ level2: {
257
+ level3: {
258
+ level4: {
259
+ value: T;
260
+ secret: string;
261
+ };
262
+ };
263
+ };
264
+ };
265
+ }
266
+
267
+ const scrubber = new Scrubber({ fields: ['secret'] });
268
+ const data: DeepNested<number> = {
269
+ level1: {
270
+ level2: {
271
+ level3: {
272
+ level4: {
273
+ value: 42,
274
+ secret: 'hidden',
275
+ },
276
+ },
277
+ },
278
+ },
279
+ };
280
+
281
+ const _result = scrubber.scrub(data);
282
+
283
+ // Compile-time assertion
284
+ const typeCheck: AssertType<
285
+ typeof _result.data,
286
+ DeepNested<number>
287
+ > = true;
288
+ expect(typeCheck).to.be.true;
289
+ });
290
+
291
+ it('handles arrays of complex types', () => {
292
+ interface Event {
293
+ id: string;
294
+ timestamp: Date;
295
+ user: {
296
+ id: string;
297
+ email: string;
298
+ };
299
+ metadata: Record<string, unknown>;
300
+ }
301
+
302
+ const scrubber = new Scrubber({ fields: ['email'] });
303
+ const events: Event[] = [
304
+ {
305
+ id: '1',
306
+ timestamp: new Date(),
307
+ user: { id: 'u1', email: 'user1@example.com' },
308
+ metadata: { key: 'value' },
309
+ },
310
+ {
311
+ id: '2',
312
+ timestamp: new Date(),
313
+ user: { id: 'u2', email: 'user2@example.com' },
314
+ metadata: { key: 'value' },
315
+ },
316
+ ];
317
+
318
+ const _result = scrubber.scrub(events);
319
+
320
+ // Compile-time assertion
321
+ const typeCheck: AssertType<typeof _result.data, Event[]> = true;
322
+ expect(typeCheck).to.be.true;
323
+ });
324
+
325
+ it('handles Record types', () => {
326
+ type UserMap = Record<string, { name: string; password: string }>;
327
+
328
+ const scrubber = new Scrubber({ fields: ['password'] });
329
+ const users: UserMap = {
330
+ user1: { name: 'John', password: 'secret1' },
331
+ user2: { name: 'Jane', password: 'secret2' },
332
+ };
333
+
334
+ const _result = scrubber.scrub(users);
335
+
336
+ // Compile-time assertion
337
+ const typeCheck: AssertType<typeof _result.data, UserMap> = true;
338
+ expect(typeCheck).to.be.true;
339
+ });
340
+
341
+ it('handles mixed primitive and object types', () => {
342
+ interface MixedData {
343
+ string: string;
344
+ number: number;
345
+ boolean: boolean;
346
+ null: null;
347
+ undefined: undefined;
348
+ date: Date;
349
+ regex: RegExp;
350
+ object: { key: string };
351
+ array: number[];
352
+ }
353
+
354
+ const scrubber = new Scrubber({ fields: ['key'] });
355
+ const data: MixedData = {
356
+ string: 'text',
357
+ number: 42,
358
+ boolean: true,
359
+ null: null,
360
+ undefined: undefined,
361
+ date: new Date(),
362
+ regex: /test/,
363
+ object: { key: 'value' },
364
+ array: [1, 2, 3],
365
+ };
366
+
367
+ const _result = scrubber.scrub(data);
368
+
369
+ // Compile-time assertion
370
+ const typeCheck: AssertType<typeof _result.data, MixedData> = true;
371
+ expect(typeCheck).to.be.true;
372
+ });
373
+
374
+ it('handles tuple types', () => {
375
+ type UserTuple = [string, number, boolean, { password: string }];
376
+
377
+ const scrubber = new Scrubber({ fields: ['password'] });
378
+ const tuple: UserTuple = ['John', 30, true, { password: 'secret' }];
379
+
380
+ const result = scrubber.scrub(tuple);
381
+
382
+ // Note: TypeScript treats tuples as arrays at runtime, so the type is preserved
383
+ // but the specific tuple structure is maintained
384
+ expect(result.data).to.be.an('array');
385
+ expect(result.data).to.have.lengthOf(4);
386
+ });
387
+
388
+ it('preserves type safety with unknown types', () => {
389
+ const scrubber = new Scrubber({ fields: ['password'] });
390
+ const data: unknown = { user: 'john', password: 'secret' };
391
+
392
+ const _result = scrubber.scrub(data);
393
+
394
+ // Compile-time assertion: unknown in, unknown out
395
+ const typeCheck: AssertType<typeof _result.data, unknown> = true;
396
+ expect(typeCheck).to.be.true;
397
+ });
398
+ });
399
+
400
+ describe('Type Inference', () => {
401
+ it('infers types from literal objects', () => {
402
+ const scrubber = new Scrubber({ fields: ['password'] });
403
+
404
+ // Type should be inferred from the literal
405
+ const result = scrubber.scrub({
406
+ name: 'John',
407
+ email: 'john@example.com',
408
+ password: 'secret',
409
+ });
410
+
411
+ // TypeScript infers the exact shape
412
+ expect(result.data).to.have.property('name');
413
+ expect(result.data).to.have.property('email');
414
+ expect(result.data).to.have.property('password');
415
+ });
416
+
417
+ it('works with const assertions', () => {
418
+ const scrubber = new Scrubber({ fields: ['password'] });
419
+
420
+ const data = {
421
+ name: 'John',
422
+ role: 'admin',
423
+ password: 'secret',
424
+ } as const;
425
+
426
+ // Type should preserve readonly properties from const assertion
427
+ const result = scrubber.scrub(data);
428
+
429
+ expect(result.data.name).to.equal('John');
430
+ expect(result.data.role).to.equal('admin');
431
+ });
432
+ });
433
+
434
+ describe('Edge Case Types', () => {
435
+ it('handles empty objects', () => {
436
+ const scrubber = new Scrubber({ fields: ['password'] });
437
+ const empty = {};
438
+
439
+ const result = scrubber.scrub(empty);
440
+
441
+ const typeCheck: AssertType<typeof result.data, typeof empty> = true;
442
+ expect(typeCheck).to.be.true;
443
+ expect(result.data).to.deep.equal({});
444
+ });
445
+
446
+ it('handles primitives directly', () => {
447
+ const scrubber = new Scrubber({ fields: ['password'] });
448
+
449
+ const string = 'test';
450
+ const number = 42;
451
+ const boolean = true;
452
+ const nullVal = null;
453
+
454
+ expect(scrubber.scrub(string).data).to.equal(string);
455
+ expect(scrubber.scrub(number).data).to.equal(number);
456
+ expect(scrubber.scrub(boolean).data).to.equal(boolean);
457
+ expect(scrubber.scrub(nullVal).data).to.equal(nullVal);
458
+ });
459
+
460
+ it('handles circular references with type preservation', () => {
461
+ interface Circular {
462
+ name: string;
463
+ password: string;
464
+ self?: Circular;
465
+ }
466
+
467
+ const scrubber = new Scrubber({ fields: ['password'] });
468
+ const obj: Circular = {
469
+ name: 'test',
470
+ password: 'secret',
471
+ };
472
+ obj.self = obj; // Circular reference
473
+
474
+ const result = scrubber.scrub(obj);
475
+
476
+ // Type is preserved even with circular reference
477
+ const typeCheck: AssertType<typeof result.data, Circular> = true;
478
+ expect(typeCheck).to.be.true;
479
+ expect(result.data.name).to.equal('test');
480
+ });
481
+ });
482
+
483
+ describe('Strict TypeScript Compliance', () => {
484
+ it('respects noUncheckedIndexedAccess', () => {
485
+ const scrubber = new Scrubber({ fields: ['password'] });
486
+ const data: Record<string, string> = {
487
+ user: 'john',
488
+ password: 'secret',
489
+ };
490
+
491
+ const result = scrubber.scrub(data);
492
+
493
+ // With noUncheckedIndexedAccess, indexed access returns string | undefined
494
+ const value = result.data['nonexistent'];
495
+ expect(value).to.be.undefined;
496
+ });
497
+
498
+ it('respects strictNullChecks', () => {
499
+ interface NullableData {
500
+ value: string | null;
501
+ optional?: string;
502
+ }
503
+
504
+ const scrubber = new Scrubber({ fields: ['value'] });
505
+ const data: NullableData = {
506
+ value: null,
507
+ };
508
+
509
+ const _result = scrubber.scrub(data);
510
+
511
+ // Compile-time assertion
512
+ const typeCheck: AssertType<typeof _result.data, NullableData> = true;
513
+ expect(typeCheck).to.be.true;
514
+ });
515
+ });
516
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Configuration for the Scrubber
3
+ *
4
+ * Defines how the scrubber should identify and replace sensitive data.
5
+ * Supports three complementary scrubbing strategies:
6
+ *
7
+ * 1. **Field-based scrubbing** (`fields`): Matches field names at any depth in the object tree
8
+ * 2. **Path-based scrubbing** (`paths`): Matches specific dot-notation paths
9
+ * 3. **Pattern-based scrubbing** (`patterns`): Matches regex patterns in string content
10
+ *
11
+ * All three strategies can be used together for comprehensive data scrubbing.
12
+ *
13
+ * @example Field-based configuration
14
+ * ```typescript
15
+ * const config: ScrubConfig = {
16
+ * fields: ['password', 'apiToken', /api[-_]?key/i], // Strings and regex patterns
17
+ * replacement: '[REDACTED]'
18
+ * };
19
+ * ```
20
+ *
21
+ * @example Path-based configuration
22
+ * ```typescript
23
+ * const config: ScrubConfig = {
24
+ * paths: [
25
+ * 'user.email',
26
+ * 'request.headers.authorization',
27
+ * 'items[0].password' // Array index notation
28
+ * ]
29
+ * };
30
+ * ```
31
+ *
32
+ * @example Pattern-based configuration
33
+ * ```typescript
34
+ * const config: ScrubConfig = {
35
+ * patterns: [
36
+ * /\b\d{3}-\d{2}-\d{4}\b/g, // SSN
37
+ * /\d{4}-\d{4}-\d{4}-\d{4}/g, // Credit card
38
+ * /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}/g // Email
39
+ * ]
40
+ * };
41
+ * ```
42
+ */
43
+ export interface ScrubConfig {
44
+ /**
45
+ * Field-based scrubbing: matches field names at any depth
46
+ *
47
+ * Supports both exact string matches and regular expressions for flexible matching.
48
+ * String matches are case-insensitive and use substring matching.
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * fields: [
53
+ * 'password', // Matches 'password', 'Password', 'old_password', etc.
54
+ * 'apiToken', // Matches 'apiToken', 'api_token', etc.
55
+ * /api[-_]?key/i, // Regex: matches 'api_key', 'api-key', 'apikey' (case insensitive)
56
+ * /^secret$/ // Exact match: only 'secret', not 'my_secret'
57
+ * ]
58
+ * ```
59
+ */
60
+ fields?: (string | RegExp)[];
61
+
62
+ /**
63
+ * Path-based scrubbing: matches specific dot-notation paths
64
+ *
65
+ * Use dot notation to target specific fields in nested objects.
66
+ * Supports array index notation (e.g., `items[0].password`).
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * paths: [
71
+ * 'user.email', // Scrubs obj.user.email
72
+ * 'request.headers.authorization', // Nested path
73
+ * 'items[0].secret', // Array index notation
74
+ * 'users[0]' // Scrubs entire array element
75
+ * ]
76
+ * ```
77
+ */
78
+ paths?: string[];
79
+
80
+ /**
81
+ * Pattern-based scrubbing: regex patterns for content scrubbing
82
+ *
83
+ * Scans string values and replaces content matching the patterns.
84
+ * Use the global flag (`/pattern/g`) to replace all matches in a string.
85
+ *
86
+ * **Note**: Patterns are applied to string values only, not to field names or paths.
87
+ *
88
+ * @example
89
+ * ```typescript
90
+ * patterns: [
91
+ * /\b\d{3}-\d{2}-\d{4}\b/g, // Social Security Number
92
+ * /\d{4}-\d{4}-\d{4}-\d{4}/g, // Credit Card
93
+ * /Bearer\s+[A-Za-z0-9._-]+/g // Bearer tokens
94
+ * ]
95
+ * ```
96
+ */
97
+ patterns?: RegExp[];
98
+
99
+ /**
100
+ * Replacement string for scrubbed values
101
+ *
102
+ * @default '[SCRUBBED]'
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * replacement: '[REDACTED]' // Custom replacement text
107
+ * replacement: '***' // Simple masking
108
+ * replacement: '' // Empty string (removes content)
109
+ * ```
110
+ */
111
+ replacement?: string;
112
+
113
+ /**
114
+ * Whether to recursively scrub nested objects
115
+ *
116
+ * When `true`, the scrubber traverses the entire object tree.
117
+ * When `false`, only top-level fields are scrubbed.
118
+ *
119
+ * @default true
120
+ *
121
+ * @example
122
+ * ```typescript
123
+ * recursive: false // Only scrub top-level fields
124
+ * ```
125
+ */
126
+ recursive?: boolean;
127
+ }
128
+
129
+ /**
130
+ * Result of a scrub operation
131
+ *
132
+ * Contains the scrubbed data along with metadata about what was scrubbed.
133
+ *
134
+ * @template T - The type of the scrubbed data (same as input type)
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * const scrubber = new Scrubber({ fields: ['password'] });
139
+ * const result = scrubber.scrub({ user: 'john', password: 'secret' });
140
+ *
141
+ * console.log(result.data); // { user: 'john', password: '[SCRUBBED]' }
142
+ * console.log(result.scrubbed); // true
143
+ * console.log(result.scrubbedPaths); // ['password']
144
+ * ```
145
+ */
146
+ export interface ScrubResult<T> {
147
+ /**
148
+ * The scrubbed data with sensitive values replaced
149
+ *
150
+ * This is a deep clone of the input with scrubbed values replaced.
151
+ * The original input is never mutated.
152
+ */
153
+ data: T;
154
+
155
+ /**
156
+ * Whether any scrubbing occurred
157
+ *
158
+ * `true` if at least one value was scrubbed, `false` if no sensitive data was found.
159
+ *
160
+ * Useful for logging or metrics to track scrubbing activity.
161
+ */
162
+ scrubbed: boolean;
163
+
164
+ /**
165
+ * Array of paths that were scrubbed
166
+ *
167
+ * Contains dot-notation paths for all fields that were scrubbed.
168
+ * Useful for debugging, auditing, or understanding what data was redacted.
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * ['password', 'user.email', 'request.headers.authorization']
173
+ * ```
174
+ */
175
+ scrubbedPaths: string[];
176
+ }