n8n-nodes-redactor 3.0.1

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 (61) hide show
  1. package/LICENSE +42 -0
  2. package/README.dev.md +153 -0
  3. package/README.md +443 -0
  4. package/README.npm.md +443 -0
  5. package/dist/nodes/PiiRedactor/PiiRedactor.node.d.ts +5 -0
  6. package/dist/nodes/PiiRedactor/PiiRedactor.node.js +1093 -0
  7. package/dist/nodes/PiiRedactor/__tests__/encryption.test.d.ts +1 -0
  8. package/dist/nodes/PiiRedactor/__tests__/encryption.test.js +200 -0
  9. package/dist/nodes/PiiRedactor/__tests__/engine.test.d.ts +1 -0
  10. package/dist/nodes/PiiRedactor/__tests__/engine.test.js +524 -0
  11. package/dist/nodes/PiiRedactor/__tests__/operations.test.d.ts +1 -0
  12. package/dist/nodes/PiiRedactor/__tests__/operations.test.js +316 -0
  13. package/dist/nodes/PiiRedactor/__tests__/patterns-global.test.d.ts +1 -0
  14. package/dist/nodes/PiiRedactor/__tests__/patterns-global.test.js +427 -0
  15. package/dist/nodes/PiiRedactor/__tests__/patterns.test.d.ts +1 -0
  16. package/dist/nodes/PiiRedactor/__tests__/patterns.test.js +481 -0
  17. package/dist/nodes/PiiRedactor/__tests__/phase1.test.d.ts +1 -0
  18. package/dist/nodes/PiiRedactor/__tests__/phase1.test.js +343 -0
  19. package/dist/nodes/PiiRedactor/__tests__/phase3.test.d.ts +1 -0
  20. package/dist/nodes/PiiRedactor/__tests__/phase3.test.js +275 -0
  21. package/dist/nodes/PiiRedactor/__tests__/phase4.test.d.ts +1 -0
  22. package/dist/nodes/PiiRedactor/__tests__/phase4.test.js +184 -0
  23. package/dist/nodes/PiiRedactor/__tests__/presidio.test.d.ts +1 -0
  24. package/dist/nodes/PiiRedactor/__tests__/presidio.test.js +170 -0
  25. package/dist/nodes/PiiRedactor/__tests__/security.test.d.ts +1 -0
  26. package/dist/nodes/PiiRedactor/__tests__/security.test.js +178 -0
  27. package/dist/nodes/PiiRedactor/__tests__/semantic.test.d.ts +1 -0
  28. package/dist/nodes/PiiRedactor/__tests__/semantic.test.js +319 -0
  29. package/dist/nodes/PiiRedactor/__tests__/vault.test.d.ts +1 -0
  30. package/dist/nodes/PiiRedactor/__tests__/vault.test.js +247 -0
  31. package/dist/nodes/PiiRedactor/audit.d.ts +48 -0
  32. package/dist/nodes/PiiRedactor/audit.js +192 -0
  33. package/dist/nodes/PiiRedactor/classification.d.ts +33 -0
  34. package/dist/nodes/PiiRedactor/classification.js +118 -0
  35. package/dist/nodes/PiiRedactor/context.d.ts +57 -0
  36. package/dist/nodes/PiiRedactor/context.js +260 -0
  37. package/dist/nodes/PiiRedactor/encryption.d.ts +45 -0
  38. package/dist/nodes/PiiRedactor/encryption.js +158 -0
  39. package/dist/nodes/PiiRedactor/engine.d.ts +23 -0
  40. package/dist/nodes/PiiRedactor/engine.js +888 -0
  41. package/dist/nodes/PiiRedactor/injection.d.ts +46 -0
  42. package/dist/nodes/PiiRedactor/injection.js +425 -0
  43. package/dist/nodes/PiiRedactor/names.d.ts +25 -0
  44. package/dist/nodes/PiiRedactor/names.js +188 -0
  45. package/dist/nodes/PiiRedactor/patterns.d.ts +17 -0
  46. package/dist/nodes/PiiRedactor/patterns.js +1742 -0
  47. package/dist/nodes/PiiRedactor/presidio.d.ts +77 -0
  48. package/dist/nodes/PiiRedactor/presidio.js +264 -0
  49. package/dist/nodes/PiiRedactor/profiles.d.ts +47 -0
  50. package/dist/nodes/PiiRedactor/profiles.js +139 -0
  51. package/dist/nodes/PiiRedactor/pseudonymize.d.ts +20 -0
  52. package/dist/nodes/PiiRedactor/pseudonymize.js +203 -0
  53. package/dist/nodes/PiiRedactor/redact.png +0 -0
  54. package/dist/nodes/PiiRedactor/redact.svg +3 -0
  55. package/dist/nodes/PiiRedactor/ropa.d.ts +63 -0
  56. package/dist/nodes/PiiRedactor/ropa.js +70 -0
  57. package/dist/nodes/PiiRedactor/types.d.ts +82 -0
  58. package/dist/nodes/PiiRedactor/types.js +3 -0
  59. package/dist/nodes/PiiRedactor/vault.d.ts +61 -0
  60. package/dist/nodes/PiiRedactor/vault.js +352 -0
  61. package/package.json +87 -0
@@ -0,0 +1,524 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vault_1 = require("../vault");
4
+ const engine_1 = require("../engine");
5
+ function createContext(overrides = {}) {
6
+ return {
7
+ enabledPatterns: ['email', 'phone', 'personName', 'ssn', 'creditCard', 'iban', 'ipv4'],
8
+ customPatterns: [],
9
+ mode: 'token',
10
+ dedup: true,
11
+ fieldRules: [],
12
+ fieldMode: 'all',
13
+ ...overrides,
14
+ };
15
+ }
16
+ // ═══════════════════════════════════════════════════════
17
+ // BASIC REDACTION
18
+ // ═══════════════════════════════════════════════════════
19
+ describe('Redaction engine — basic', () => {
20
+ let vault;
21
+ beforeEach(() => {
22
+ vault = new vault_1.MemoryVault();
23
+ });
24
+ test('redacts email in simple string field', () => {
25
+ vault.getOrCreateSession('s1', 0);
26
+ const hits = [];
27
+ const result = (0, engine_1.redactValue)({ message: 'Contact me at john@example.com' }, createContext(), vault, 's1', hits, 0);
28
+ expect(result.message).toBe('Contact me at [EMAIL_0]');
29
+ expect(hits).toHaveLength(1);
30
+ expect(hits[0].patternLabel).toBe('EMAIL');
31
+ expect(hits[0].original).toBe('***'); // SECURITY: originals never exposed in reports
32
+ });
33
+ test('redacts multiple PII types in one field', () => {
34
+ vault.getOrCreateSession('s2', 0);
35
+ const hits = [];
36
+ const result = (0, engine_1.redactValue)({ info: 'Mr. John Smith, email: john@test.com, SSN: 123-45-6789' }, createContext(), vault, 's2', hits, 0);
37
+ expect(result.info).not.toContain('john@test.com');
38
+ expect(result.info).not.toContain('123-45-6789');
39
+ expect(result.info).not.toContain('Mr. John Smith');
40
+ expect(hits.length).toBeGreaterThanOrEqual(3);
41
+ });
42
+ test('redacts nested JSON objects', () => {
43
+ vault.getOrCreateSession('s3', 0);
44
+ const hits = [];
45
+ const data = {
46
+ user: {
47
+ profile: {
48
+ email: 'deep@nested.com',
49
+ phone: '+49 30 1234-5678',
50
+ },
51
+ },
52
+ };
53
+ const result = (0, engine_1.redactValue)(data, createContext(), vault, 's3', hits, 0);
54
+ expect(result.user.profile.email).toContain('[EMAIL_');
55
+ expect(result.user.profile.phone).toContain('[PHONE_');
56
+ expect(hits).toHaveLength(2);
57
+ });
58
+ test('redacts arrays', () => {
59
+ vault.getOrCreateSession('s4', 0);
60
+ const hits = [];
61
+ const data = {
62
+ emails: ['alice@foo.com', 'bob@bar.com'],
63
+ };
64
+ const result = (0, engine_1.redactValue)(data, createContext(), vault, 's4', hits, 0);
65
+ expect(result.emails[0]).toContain('[EMAIL_');
66
+ expect(result.emails[1]).toContain('[EMAIL_');
67
+ expect(hits).toHaveLength(2);
68
+ });
69
+ test('preserves non-string values', () => {
70
+ vault.getOrCreateSession('s5', 0);
71
+ const hits = [];
72
+ const data = {
73
+ count: 42,
74
+ active: true,
75
+ tags: null,
76
+ score: 3.14,
77
+ };
78
+ const result = (0, engine_1.redactValue)(data, createContext(), vault, 's5', hits, 0);
79
+ expect(result.count).toBe(42);
80
+ expect(result.active).toBe(true);
81
+ expect(result.tags).toBeNull();
82
+ expect(result.score).toBe(3.14);
83
+ expect(hits).toHaveLength(0);
84
+ });
85
+ test('handles empty strings', () => {
86
+ vault.getOrCreateSession('s6', 0);
87
+ const hits = [];
88
+ const result = (0, engine_1.redactValue)({ field: '' }, createContext(), vault, 's6', hits, 0);
89
+ expect(result.field).toBe('');
90
+ expect(hits).toHaveLength(0);
91
+ });
92
+ test('handles deeply nested arrays of objects', () => {
93
+ vault.getOrCreateSession('s7', 0);
94
+ const hits = [];
95
+ const data = {
96
+ contacts: [
97
+ { name: 'Mr. Alice Smith', email: 'alice@test.com' },
98
+ { name: 'Dr. Bob Jones', email: 'bob@test.com' },
99
+ ],
100
+ };
101
+ const result = (0, engine_1.redactValue)(data, createContext(), vault, 's7', hits, 0);
102
+ expect(result.contacts[0].email).toContain('[EMAIL_');
103
+ expect(result.contacts[1].email).toContain('[EMAIL_');
104
+ expect(result.contacts[0].name).toContain('[PERSON_');
105
+ expect(hits.length).toBeGreaterThanOrEqual(4);
106
+ });
107
+ });
108
+ // ═══════════════════════════════════════════════════════
109
+ // DEDUPLICATION
110
+ // ═══════════════════════════════════════════════════════
111
+ describe('Deduplication', () => {
112
+ let vault;
113
+ beforeEach(() => {
114
+ vault = new vault_1.MemoryVault();
115
+ });
116
+ test('same email gets same token when dedup=true', () => {
117
+ vault.getOrCreateSession('dedup-1', 0);
118
+ const hits = [];
119
+ const data = {
120
+ primary: 'Contact: same@email.com',
121
+ secondary: 'Also: same@email.com',
122
+ };
123
+ const result = (0, engine_1.redactValue)(data, createContext({ dedup: true }), vault, 'dedup-1', hits, 0);
124
+ // Both fields should have the SAME token
125
+ const token1 = result.primary.match(/\[EMAIL_\d+\]/)?.[0];
126
+ const token2 = result.secondary.match(/\[EMAIL_\d+\]/)?.[0];
127
+ expect(token1).toBe(token2);
128
+ });
129
+ test('same email gets different tokens when dedup=false', () => {
130
+ vault.getOrCreateSession('dedup-2', 0);
131
+ const hits = [];
132
+ const data = {
133
+ primary: 'Contact: same@email.com',
134
+ secondary: 'Also: same@email.com',
135
+ };
136
+ const result = (0, engine_1.redactValue)(data, createContext({ dedup: false }), vault, 'dedup-2', hits, 0);
137
+ const token1 = result.primary.match(/\[EMAIL_\d+\]/)?.[0];
138
+ const token2 = result.secondary.match(/\[EMAIL_\d+\]/)?.[0];
139
+ expect(token1).not.toBe(token2);
140
+ });
141
+ });
142
+ // ═══════════════════════════════════════════════════════
143
+ // REDACTION MODES
144
+ // ═══════════════════════════════════════════════════════
145
+ describe('Redaction modes', () => {
146
+ let vault;
147
+ beforeEach(() => {
148
+ vault = new vault_1.MemoryVault();
149
+ });
150
+ test('token mode produces [LABEL_N] format', () => {
151
+ vault.getOrCreateSession('mode-token', 0);
152
+ const hits = [];
153
+ const result = (0, engine_1.redactValue)({ email: 'test@example.com' }, createContext({ mode: 'token' }), vault, 'mode-token', hits, 0);
154
+ expect(result.email).toMatch(/^\[EMAIL_\d+\]$/);
155
+ });
156
+ test('mask mode partially hides email', () => {
157
+ vault.getOrCreateSession('mode-mask', 0);
158
+ const hits = [];
159
+ const result = (0, engine_1.redactValue)({ email: 'john@example.com' }, createContext({ mode: 'mask' }), vault, 'mode-mask', hits, 0);
160
+ expect(result.email).toContain('***');
161
+ expect(result.email).toContain('@');
162
+ expect(result.email).not.toBe('john@example.com');
163
+ });
164
+ test('hash mode produces [LABEL:hash] format', () => {
165
+ vault.getOrCreateSession('mode-hash', 0);
166
+ const hits = [];
167
+ const result = (0, engine_1.redactValue)({ email: 'test@example.com' }, createContext({ mode: 'hash' }), vault, 'mode-hash', hits, 0);
168
+ expect(result.email).toMatch(/^\[EMAIL:[a-f0-9]{12}\]$/);
169
+ });
170
+ test('hash mode is deterministic', () => {
171
+ vault.getOrCreateSession('mode-hash-det', 0);
172
+ const hits1 = [];
173
+ const hits2 = [];
174
+ const r1 = (0, engine_1.redactValue)({ email: 'test@example.com' }, createContext({ mode: 'hash' }), vault, 'mode-hash-det', hits1, 0);
175
+ const r2 = (0, engine_1.redactValue)({ email: 'test@example.com' }, createContext({ mode: 'hash' }), vault, 'mode-hash-det', hits2, 0);
176
+ expect(r1.email).toBe(r2.email);
177
+ });
178
+ test('redact mode produces [REDACTED]', () => {
179
+ vault.getOrCreateSession('mode-redact', 0);
180
+ const hits = [];
181
+ const result = (0, engine_1.redactValue)({ email: 'test@example.com' }, createContext({ mode: 'redact' }), vault, 'mode-redact', hits, 0);
182
+ expect(result.email).toContain('[REDACTED]');
183
+ });
184
+ test('mask mode for credit card shows last 4', () => {
185
+ vault.getOrCreateSession('mode-mask-cc', 0);
186
+ const hits = [];
187
+ const result = (0, engine_1.redactValue)({ card: '4532 0151 1283 0366' }, createContext({ mode: 'mask', enabledPatterns: ['creditCard'] }), vault, 'mode-mask-cc', hits, 0);
188
+ expect(result.card).toContain('0366');
189
+ expect(result.card).toContain('****');
190
+ });
191
+ });
192
+ // ═══════════════════════════════════════════════════════
193
+ // FIELD TARGETING
194
+ // ═══════════════════════════════════════════════════════
195
+ describe('Field targeting', () => {
196
+ let vault;
197
+ beforeEach(() => {
198
+ vault = new vault_1.MemoryVault();
199
+ });
200
+ test('allowlist — only scans specified fields', () => {
201
+ vault.getOrCreateSession('field-allow', 0);
202
+ const hits = [];
203
+ const data = {
204
+ email: 'scan@me.com',
205
+ notes: 'skip@me.com',
206
+ };
207
+ const result = (0, engine_1.redactValue)(data, createContext({
208
+ fieldMode: 'allowlist',
209
+ fieldRules: [{ field: 'email', mode: 'include' }],
210
+ }), vault, 'field-allow', hits, 0);
211
+ expect(result.email).toContain('[EMAIL_');
212
+ expect(result.notes).toBe('skip@me.com'); // Not redacted
213
+ });
214
+ test('denylist — skips specified fields', () => {
215
+ vault.getOrCreateSession('field-deny', 0);
216
+ const hits = [];
217
+ const data = {
218
+ email: 'scan@me.com',
219
+ internalId: 'skip@me.com',
220
+ };
221
+ const result = (0, engine_1.redactValue)(data, createContext({
222
+ fieldMode: 'denylist',
223
+ fieldRules: [{ field: 'internalId', mode: 'exclude' }],
224
+ }), vault, 'field-deny', hits, 0);
225
+ expect(result.email).toContain('[EMAIL_');
226
+ expect(result.internalId).toBe('skip@me.com');
227
+ });
228
+ test('wildcard field matching with *.field', () => {
229
+ vault.getOrCreateSession('field-wild', 0);
230
+ const hits = [];
231
+ const data = {
232
+ user: { email: 'user@test.com' },
233
+ admin: { email: 'admin@test.com' },
234
+ };
235
+ const result = (0, engine_1.redactValue)(data, createContext({
236
+ fieldMode: 'allowlist',
237
+ fieldRules: [{ field: '*.email', mode: 'include' }],
238
+ }), vault, 'field-wild', hits, 0);
239
+ expect(result.user.email).toContain('[EMAIL_');
240
+ expect(result.admin.email).toContain('[EMAIL_');
241
+ });
242
+ test('all mode scans everything', () => {
243
+ vault.getOrCreateSession('field-all', 0);
244
+ const hits = [];
245
+ const data = {
246
+ a: 'a@test.com',
247
+ b: { c: 'b@test.com' },
248
+ };
249
+ const result = (0, engine_1.redactValue)(data, createContext({ fieldMode: 'all' }), vault, 'field-all', hits, 0);
250
+ expect(result.a).toContain('[EMAIL_');
251
+ expect(result.b.c).toContain('[EMAIL_');
252
+ });
253
+ });
254
+ // ═══════════════════════════════════════════════════════
255
+ // CUSTOM PATTERNS
256
+ // ═══════════════════════════════════════════════════════
257
+ describe('Custom patterns', () => {
258
+ let vault;
259
+ beforeEach(() => {
260
+ vault = new vault_1.MemoryVault();
261
+ });
262
+ test('matches custom regex pattern', () => {
263
+ vault.getOrCreateSession('custom-1', 0);
264
+ const hits = [];
265
+ const result = (0, engine_1.redactValue)({ order: 'Your order ORD-123456 is confirmed' }, createContext({
266
+ enabledPatterns: [],
267
+ customPatterns: [{ label: 'ORDER_ID', regex: 'ORD-\\d{6}' }],
268
+ }), vault, 'custom-1', hits, 0);
269
+ expect(result.order).toContain('[ORDER_ID_');
270
+ expect(result.order).not.toContain('ORD-123456');
271
+ expect(hits).toHaveLength(1);
272
+ });
273
+ test('skips invalid regex gracefully', () => {
274
+ vault.getOrCreateSession('custom-bad', 0);
275
+ const hits = [];
276
+ const result = (0, engine_1.redactValue)({ text: 'hello world' }, createContext({
277
+ enabledPatterns: [],
278
+ customPatterns: [{ label: 'BAD', regex: '[invalid(' }],
279
+ }), vault, 'custom-bad', hits, 0);
280
+ // Should not crash
281
+ expect(result.text).toBe('hello world');
282
+ expect(hits).toHaveLength(0);
283
+ });
284
+ test('custom pattern with category', () => {
285
+ vault.getOrCreateSession('custom-cat', 0);
286
+ const hits = [];
287
+ (0, engine_1.redactValue)({ ticket: 'TICKET-999' }, createContext({
288
+ enabledPatterns: [],
289
+ customPatterns: [{ label: 'TICKET', regex: 'TICKET-\\d+', category: 'identity' }],
290
+ }), vault, 'custom-cat', hits, 0);
291
+ expect(hits[0].category).toBe('identity');
292
+ });
293
+ });
294
+ // ═══════════════════════════════════════════════════════
295
+ // RESTORE
296
+ // ═══════════════════════════════════════════════════════
297
+ describe('Restore engine', () => {
298
+ let vault;
299
+ beforeEach(() => {
300
+ vault = new vault_1.MemoryVault();
301
+ });
302
+ test('full round-trip: redact then restore', () => {
303
+ vault.getOrCreateSession('rt-1', 0);
304
+ const original = {
305
+ message: 'Contact Mr. John Smith at john@example.com or 555-123-4567',
306
+ meta: { ssn: '123-45-6789' },
307
+ };
308
+ const hits = [];
309
+ const redacted = (0, engine_1.redactValue)(original, createContext(), vault, 'rt-1', hits, 0);
310
+ // Verify redaction happened
311
+ expect(JSON.stringify(redacted)).not.toContain('john@example.com');
312
+ expect(JSON.stringify(redacted)).not.toContain('123-45-6789');
313
+ // Restore
314
+ const restored = (0, engine_1.restoreValue)(redacted, vault, 'rt-1');
315
+ expect(restored.message).toContain('john@example.com');
316
+ expect(restored.meta.ssn).toBe('123-45-6789');
317
+ });
318
+ test('restore handles arrays', () => {
319
+ vault.getOrCreateSession('rt-arr', 0);
320
+ const original = { emails: ['alice@test.com', 'bob@test.com'] };
321
+ const hits = [];
322
+ const redacted = (0, engine_1.redactValue)(original, createContext(), vault, 'rt-arr', hits, 0);
323
+ const restored = (0, engine_1.restoreValue)(redacted, vault, 'rt-arr');
324
+ expect(restored.emails).toContain('alice@test.com');
325
+ expect(restored.emails).toContain('bob@test.com');
326
+ });
327
+ test('restore preserves non-string values', () => {
328
+ vault.getOrCreateSession('rt-nonstr', 0);
329
+ const data = { count: 42, active: true };
330
+ const restored = (0, engine_1.restoreValue)(data, vault, 'rt-nonstr');
331
+ expect(restored.count).toBe(42);
332
+ expect(restored.active).toBe(true);
333
+ });
334
+ test('restore returns data as-is if session missing', () => {
335
+ const data = { message: 'no session here [EMAIL_0]' };
336
+ const result = (0, engine_1.restoreValue)(data, vault, 'nonexistent');
337
+ expect(result.message).toBe('no session here [EMAIL_0]');
338
+ });
339
+ test('restore works when LLM rephrases around tokens', () => {
340
+ vault.getOrCreateSession('rt-llm', 0);
341
+ const original = { text: 'Email me at alice@company.com' };
342
+ const hits = [];
343
+ (0, engine_1.redactValue)(original, createContext(), vault, 'rt-llm', hits, 0);
344
+ // Simulate LLM response that uses the token in a different sentence
345
+ const llmResponse = { text: 'I will send the report to [EMAIL_0] by tomorrow.' };
346
+ const restored = (0, engine_1.restoreValue)(llmResponse, vault, 'rt-llm');
347
+ expect(restored.text).toBe('I will send the report to alice@company.com by tomorrow.');
348
+ });
349
+ test('restore handles multiple tokens in one field', () => {
350
+ vault.getOrCreateSession('rt-multi', 0);
351
+ const original = { text: 'From alice@a.com to bob@b.com' };
352
+ const hits = [];
353
+ (0, engine_1.redactValue)(original, createContext(), vault, 'rt-multi', hits, 0);
354
+ // Simulate LLM using both tokens
355
+ const session = vault.getSession('rt-multi');
356
+ const tokens = Object.keys(session.entries);
357
+ const llmResponse = { text: `Forwarded from ${tokens[0]} to ${tokens[1]}` };
358
+ const restored = (0, engine_1.restoreValue)(llmResponse, vault, 'rt-multi');
359
+ expect(restored.text).toContain('alice@a.com');
360
+ expect(restored.text).toContain('bob@b.com');
361
+ });
362
+ test('restore with deduplication — same token appears twice', () => {
363
+ vault.getOrCreateSession('rt-dedup', 0);
364
+ const original = {
365
+ field1: 'Contact: same@email.com',
366
+ field2: 'CC: same@email.com',
367
+ };
368
+ const hits = [];
369
+ const redacted = (0, engine_1.redactValue)(original, createContext({ dedup: true }), vault, 'rt-dedup', hits, 0);
370
+ // Both should have same token
371
+ const token = redacted.field1.match(/\[EMAIL_\d+\]/)[0];
372
+ // LLM uses the token once
373
+ const llmResponse = { reply: `I sent it to ${token}` };
374
+ const restored = (0, engine_1.restoreValue)(llmResponse, vault, 'rt-dedup');
375
+ expect(restored.reply).toBe('I sent it to same@email.com');
376
+ });
377
+ });
378
+ // ═══════════════════════════════════════════════════════
379
+ // AUDIT REPORT
380
+ // ═══════════════════════════════════════════════════════
381
+ describe('Audit report', () => {
382
+ test('buildReport creates accurate summary', () => {
383
+ const hits = [
384
+ { token: '[EMAIL_0]', original: 'a@b.com', patternName: 'email', patternLabel: 'EMAIL', category: 'contact', field: 'email', itemIndex: 0, confidence: 0.90 },
385
+ { token: '[PHONE_1]', original: '555-1234', patternName: 'phone', patternLabel: 'PHONE', category: 'contact', field: 'phone', itemIndex: 0, confidence: 0.70 },
386
+ { token: '[SSN_2]', original: '123-45-6789', patternName: 'ssn', patternLabel: 'SSN', category: 'identity', field: 'ssn', itemIndex: 0, confidence: 0.85 },
387
+ { token: '[EMAIL_3]', original: 'c@d.com', patternName: 'email', patternLabel: 'EMAIL', category: 'contact', field: 'email2', itemIndex: 1, confidence: 0.90 },
388
+ ];
389
+ const report = (0, engine_1.buildReport)('test-session', hits);
390
+ expect(report.sessionId).toBe('test-session');
391
+ expect(report.totalHits).toBe(4);
392
+ expect(report.hitsByCategory.contact).toBe(3);
393
+ expect(report.hitsByCategory.identity).toBe(1);
394
+ expect(report.hitsByPattern.EMAIL).toBe(2);
395
+ expect(report.hitsByPattern.PHONE).toBe(1);
396
+ expect(report.hitsByPattern.SSN).toBe(1);
397
+ expect(report.hits).toHaveLength(4);
398
+ expect(report.timestamp).toBeDefined();
399
+ });
400
+ test('buildReport handles empty hits', () => {
401
+ const report = (0, engine_1.buildReport)('empty', []);
402
+ expect(report.totalHits).toBe(0);
403
+ expect(report.hits).toHaveLength(0);
404
+ expect(report.hitsByCategory).toEqual({});
405
+ expect(report.hitsByPattern).toEqual({});
406
+ });
407
+ });
408
+ // ═══════════════════════════════════════════════════════
409
+ // EDGE CASES & STRESS TESTS
410
+ // ═══════════════════════════════════════════════════════
411
+ describe('Edge cases', () => {
412
+ let vault;
413
+ beforeEach(() => {
414
+ vault = new vault_1.MemoryVault();
415
+ });
416
+ test('does not re-redact already-tokenized text', () => {
417
+ vault.getOrCreateSession('no-re-redact', 0);
418
+ const hits = [];
419
+ const data = { text: 'This is [EMAIL_0] already tokenized' };
420
+ const result = (0, engine_1.redactValue)(data, createContext(), vault, 'no-re-redact', hits, 0);
421
+ expect(result.text).toBe('This is [EMAIL_0] already tokenized');
422
+ });
423
+ test('handles long strings', () => {
424
+ vault.getOrCreateSession('long-str', 0);
425
+ const hits = [];
426
+ const longText = 'x'.repeat(5000) + ' john@test.com ' + 'y'.repeat(5000);
427
+ const result = (0, engine_1.redactValue)({ text: longText }, createContext(), vault, 'long-str', hits, 0);
428
+ expect(result.text).toContain('[EMAIL_');
429
+ expect(result.text).not.toContain('john@test.com');
430
+ expect(hits).toHaveLength(1);
431
+ });
432
+ test('handles data with many PII items', () => {
433
+ vault.getOrCreateSession('many-pii', 0);
434
+ const hits = [];
435
+ const emails = {};
436
+ for (let i = 0; i < 100; i++) {
437
+ emails[`email_${i}`] = `user${i}@domain${i}.com`;
438
+ }
439
+ const result = (0, engine_1.redactValue)(emails, createContext(), vault, 'many-pii', hits, 0);
440
+ expect(hits).toHaveLength(100);
441
+ for (let i = 0; i < 100; i++) {
442
+ expect(result[`email_${i}`]).toContain('[EMAIL_');
443
+ }
444
+ });
445
+ test('handles Unicode/emoji in surrounding text', () => {
446
+ vault.getOrCreateSession('unicode', 0);
447
+ const hits = [];
448
+ const data = { text: 'Send to test@example.com please' };
449
+ const result = (0, engine_1.redactValue)(data, createContext(), vault, 'unicode', hits, 0);
450
+ expect(result.text).toContain('[EMAIL_');
451
+ expect(result.text).not.toContain('test@example.com');
452
+ });
453
+ test('mixed PII in one sentence — all detected', () => {
454
+ vault.getOrCreateSession('mixed', 0);
455
+ const hits = [];
456
+ const data = {
457
+ text: 'Mr. John Doe, SSN 123-45-6789, email john@test.com, IP 192.168.1.100',
458
+ };
459
+ const result = (0, engine_1.redactValue)(data, createContext({ enabledPatterns: ['personName', 'ssn', 'email', 'ipv4'] }), vault, 'mixed', hits, 0);
460
+ expect(result.text).not.toContain('Mr. John Doe');
461
+ expect(result.text).not.toContain('123-45-6789');
462
+ expect(result.text).not.toContain('john@test.com');
463
+ expect(result.text).not.toContain('192.168.1.100');
464
+ expect(hits.length).toBeGreaterThanOrEqual(4);
465
+ });
466
+ test('empty object returns empty object', () => {
467
+ vault.getOrCreateSession('empty-obj', 0);
468
+ const hits = [];
469
+ const result = (0, engine_1.redactValue)({}, createContext(), vault, 'empty-obj', hits, 0);
470
+ expect(result).toEqual({});
471
+ expect(hits).toHaveLength(0);
472
+ });
473
+ test('null values are preserved', () => {
474
+ vault.getOrCreateSession('null-val', 0);
475
+ const hits = [];
476
+ const result = (0, engine_1.redactValue)(null, createContext(), vault, 'null-val', hits, 0);
477
+ expect(result).toBeNull();
478
+ });
479
+ test('credit card Luhn validation prevents false positives', () => {
480
+ vault.getOrCreateSession('luhn-fp', 0);
481
+ const hits = [];
482
+ // 1234567890123456 fails Luhn — should NOT be redacted
483
+ const result = (0, engine_1.redactValue)({ text: 'Code: 1234 5678 9012 3456' }, createContext({ enabledPatterns: ['creditCard'] }), vault, 'luhn-fp', hits, 0);
484
+ expect(hits).toHaveLength(0);
485
+ expect(result.text).toBe('Code: 1234 5678 9012 3456');
486
+ });
487
+ test('IBAN checksum validation prevents false positives', () => {
488
+ vault.getOrCreateSession('iban-fp', 0);
489
+ const hits = [];
490
+ // DE00... is an invalid checksum
491
+ const result = (0, engine_1.redactValue)({ text: 'IBAN: DE00370400440532013000' }, createContext({ enabledPatterns: ['iban'] }), vault, 'iban-fp', hits, 0);
492
+ expect(hits).toHaveLength(0);
493
+ });
494
+ test('full lifecycle with file-like data structure', () => {
495
+ vault.getOrCreateSession('lifecycle', 0);
496
+ const hits = [];
497
+ const customerTicket = {
498
+ id: 'TICKET-001',
499
+ customer: {
500
+ name: 'Mrs. Sarah Johnson',
501
+ email: 'sarah.johnson@bigcorp.com',
502
+ phone: '+1-555-987-6543',
503
+ address: {
504
+ street: '123 Main St',
505
+ zip: '90210',
506
+ },
507
+ },
508
+ issue: 'Customer Mrs. Sarah Johnson reported billing error. SSN on file: 987-65-4321. Card ending 0366.',
509
+ internal: {
510
+ agentNotes: 'Called back at +1-555-987-6543, confirmed identity via SSN 987-65-4321.',
511
+ },
512
+ };
513
+ const redacted = (0, engine_1.redactValue)(customerTicket, createContext({ enabledPatterns: ['email', 'phone', 'personName', 'ssn'] }), vault, 'lifecycle', hits, 0);
514
+ // Verify redaction
515
+ const json = JSON.stringify(redacted);
516
+ expect(json).not.toContain('sarah.johnson@bigcorp.com');
517
+ expect(json).not.toContain('987-65-4321');
518
+ expect(json).not.toContain('Mrs. Sarah Johnson');
519
+ // Verify restore
520
+ const restored = (0, engine_1.restoreValue)(redacted, vault, 'lifecycle');
521
+ expect(restored.customer.email).toBe('sarah.johnson@bigcorp.com');
522
+ expect(restored.internal.agentNotes).toContain('987-65-4321');
523
+ });
524
+ });