sanitize.test.ts162 lines · main
| 1 | import { describe, expect, it } from 'vitest' |
| 2 | |
| 3 | import { sanitizeArrayOfObjects } from './sanitize' |
| 4 | |
| 5 | describe('sanitizeArrayOfObjects', () => { |
| 6 | it('redacts sensitive keys case-insensitively', () => { |
| 7 | const input = [{ Password: 'hunter2', username: 'alice' }] |
| 8 | |
| 9 | const result = sanitizeArrayOfObjects(input) as Array<Record<string, unknown>> |
| 10 | |
| 11 | expect(result).toEqual([{ Password: '[REDACTED]', username: 'alice' }]) |
| 12 | }) |
| 13 | |
| 14 | it('honors custom redaction and extra sensitive keys', () => { |
| 15 | const input = [ |
| 16 | { |
| 17 | customSensitive: 'value', |
| 18 | token: 'should hide', |
| 19 | nested: { customSensitive: 'also hide' }, |
| 20 | }, |
| 21 | ] |
| 22 | |
| 23 | const result = sanitizeArrayOfObjects(input, { |
| 24 | redaction: '<removed>', |
| 25 | sensitiveKeys: ['customSensitive'], |
| 26 | }) as Array<any> |
| 27 | |
| 28 | expect(result[0].customSensitive).toBe('<removed>') |
| 29 | expect(result[0].token).toBe('<removed>') |
| 30 | expect(result[0].nested).toEqual({ customSensitive: '<removed>' }) |
| 31 | expect(input[0].nested.customSensitive).toBe('also hide') |
| 32 | }) |
| 33 | |
| 34 | it('redacts known secret patterns in strings', () => { |
| 35 | const samples = [ |
| 36 | { value: '192.168.0.1' }, |
| 37 | { value: '2001:0db8:85a3:0000:0000:8a2e:0370:7334' }, |
| 38 | { value: 'AKIAIOSFODNN7EXAMPLE' }, |
| 39 | { value: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY' }, |
| 40 | { value: 'Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789' }, |
| 41 | { |
| 42 | value: |
| 43 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', |
| 44 | }, |
| 45 | { value: 'A'.repeat(32) }, |
| 46 | ] |
| 47 | |
| 48 | const result = sanitizeArrayOfObjects(samples) as Array<{ value: string }> |
| 49 | |
| 50 | for (const item of result) { |
| 51 | expect(item.value).toBe('[REDACTED]') |
| 52 | } |
| 53 | }) |
| 54 | |
| 55 | it('limits recursion depth and uses truncation notice', () => { |
| 56 | const input = [ |
| 57 | { |
| 58 | level1: { |
| 59 | level2: { |
| 60 | level3: { |
| 61 | password: 'secret', |
| 62 | }, |
| 63 | }, |
| 64 | }, |
| 65 | }, |
| 66 | ] |
| 67 | |
| 68 | const [result] = sanitizeArrayOfObjects(input, { |
| 69 | maxDepth: 2, |
| 70 | truncationNotice: '<truncated>', |
| 71 | }) as Array<any> |
| 72 | |
| 73 | expect(result.level1.level2).toBe('<truncated>') |
| 74 | expect(result.level1).not.toBe(input[0].level1) |
| 75 | expect(input[0].level1.level2.level3.password).toBe('secret') |
| 76 | }) |
| 77 | |
| 78 | it('handles circular references without crashing', () => { |
| 79 | const obj: any = { name: 'loop' } |
| 80 | obj.self = obj |
| 81 | |
| 82 | const [result] = sanitizeArrayOfObjects([obj]) as Array<any> |
| 83 | |
| 84 | expect(result.self).toBe('[Circular]') |
| 85 | expect(result.name).toBe('loop') |
| 86 | }) |
| 87 | |
| 88 | it('sanitizes complex types consistently', () => { |
| 89 | const date = new Date('2024-01-01T00:00:00.000Z') |
| 90 | const regex = /abc/gi |
| 91 | const fn = () => {} |
| 92 | const arrayBuffer = new ArrayBuffer(8) |
| 93 | const typedArray = new Uint8Array([1, 2, 3]) |
| 94 | const map = new Map<any, any>() |
| 95 | map.set('password', 'hunter2') |
| 96 | map.set('public', date) |
| 97 | const set = new Set<any>([1, date]) |
| 98 | const url = new URL('https://example.com/path') |
| 99 | const error = new Error('Token is Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789') |
| 100 | const custom = new (class Custom { |
| 101 | toString() { |
| 102 | return 'custom-instance' |
| 103 | } |
| 104 | })() |
| 105 | |
| 106 | const [result] = sanitizeArrayOfObjects([ |
| 107 | { |
| 108 | date, |
| 109 | regex, |
| 110 | fn, |
| 111 | arrayBuffer, |
| 112 | typedArray, |
| 113 | map, |
| 114 | set, |
| 115 | url, |
| 116 | error, |
| 117 | custom, |
| 118 | }, |
| 119 | ]) as Array<any> |
| 120 | |
| 121 | expect(result.date).toBe('2024-01-01T00:00:00.000Z') |
| 122 | expect(result.regex).toBe('/abc/gi') |
| 123 | expect(result.fn).toBe('[Function]') |
| 124 | expect(result.arrayBuffer).toBe('[ArrayBuffer byteLength=8]') |
| 125 | expect(result.typedArray).toBe('[TypedArray byteLength=3]') |
| 126 | expect(result.map).toEqual([ |
| 127 | ['[REDACTED]', '[REDACTED]'], |
| 128 | ['public', '2024-01-01T00:00:00.000Z'], |
| 129 | ]) |
| 130 | expect(result.set).toEqual([1, '2024-01-01T00:00:00.000Z']) |
| 131 | expect(result.url).toBe('https://example.com/path') |
| 132 | expect(result.error).toEqual({ |
| 133 | name: 'Error', |
| 134 | message: 'Token is [REDACTED]', |
| 135 | stack: '[REDACTED: max depth reached]', |
| 136 | }) |
| 137 | expect(result.custom).toBe('custom-instance') |
| 138 | }) |
| 139 | |
| 140 | it('sanitizes primitive array entries', () => { |
| 141 | const [redacted, number] = sanitizeArrayOfObjects([ |
| 142 | 'Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789', |
| 143 | 42, |
| 144 | ]) as Array<any> |
| 145 | |
| 146 | expect(redacted).toBe('[REDACTED]') |
| 147 | expect(number).toBe(42) |
| 148 | }) |
| 149 | |
| 150 | it('applies maxDepth=0 to top-level entries', () => { |
| 151 | const result = sanitizeArrayOfObjects( |
| 152 | [{ password: 'secret', nested: { value: 'test' } }, 'visible'], |
| 153 | { |
| 154 | maxDepth: 0, |
| 155 | truncationNotice: '<blocked>', |
| 156 | } |
| 157 | ) as Array<any> |
| 158 | |
| 159 | expect(result[0]).toBe('<blocked>') |
| 160 | expect(result[1]).toBe('visible') |
| 161 | }) |
| 162 | }) |