diff --git a/.github/instructions/unit-tests.instructions.md b/.github/instructions/unit-tests.instructions.md index 56177c8..a0c01ad 100644 --- a/.github/instructions/unit-tests.instructions.md +++ b/.github/instructions/unit-tests.instructions.md @@ -8,7 +8,8 @@ applyTo: '**/*.test.js,**/*.test.ts,**/*.test.jsx,**/*.test.tsx' ## Core Principles -1. Use AAAR (Arrange, Act, Assert, Revert) comments for test clarity +1. Use Arrange, Act, and Assert comments for non-trivial tests; add Revert only + when the test performs cleanup 2. Aim for 100% coverage; use `/* istanbul ignore next */` with justification 3. BDD format: `describe()` blocks + `it()` (never use `test()`) 4. Test titles should start with `should` and describe behavior @@ -84,14 +85,18 @@ describe('Calculator', () => { }); ``` -## AAAR Comments +## AAA and Revert Comments Use in every non-trivial test: - `// Arrange` — setup/given - `// Act` — when - `// Assert` — then -- `// Revert` — cleanup (or use `afterEach`) + +Use only when cleanup is actually performed: + +- `// Revert` — cleanup local test state, mocks, timers, files, or other side + effects not handled by `afterEach` Simple tests with obvious steps may omit these. @@ -145,6 +150,7 @@ describe('labResults.js', () => { - [ ] Tests use `describe()` and `it()` - [ ] Test titles start with `should` -- [ ] AAAR comments are present in non-trivial tests +- [ ] Arrange, Act, and Assert comments are present in non-trivial tests +- [ ] Revert comments are present only when the test performs cleanup - [ ] Coverage exceptions include justification comments - [ ] Modified tests are run and passing diff --git a/docs/plans/005-hardening-regression-tests.md b/docs/plans/005-hardening-regression-tests.md new file mode 100644 index 0000000..cfa7d0a --- /dev/null +++ b/docs/plans/005-hardening-regression-tests.md @@ -0,0 +1,54 @@ +# Hardening Regression Tests + +## Approach + +Add behavior-preserving regression coverage for edge cases identified in the +post-v1 roadmap before changing matcher or traversal internals. This branch +documents the current v1 behavior for sensitive Unicode values, deeper object +graphs, repeated sensitive keys, large arrays, non-plain objects, and symbol +keys. + +## Pre-implementation + +Create branch `test/hardening-regression-coverage` from `main` after merging the +README release-polish branch. + +## Steps + +1. `docs/plans/005-hardening-regression-tests.md` - add this plan for the + hardening regression coverage work. +2. `test/replacers.test.ts` - add object and string replacer edge-case coverage + for recursion, repeated sensitive keys, arrays, symbols, non-plain objects, + and Unicode sensitive values. + +## Relevant Files + +- `docs/plans/005-hardening-regression-tests.md` - new plan for this test-only + hardening slice. +- `test/replacers.test.ts` - updated replacer regression coverage. + +## Verification + +1. Run `yarn test`. +2. Run `yarn test:coverage`. +3. Run `yarn lint`. +4. Run `yarn format:check`. +5. Re-run workspace diagnostics for touched test and plan files. + +## Decisions + +**Test-only branch** - this work documents existing v1 behavior and avoids +runtime implementation changes so later hardening or matcher cleanup work has a +clear safety net. + +**Prefer observable behavior over internals** - tests should assert public +sanitization results and documented helper behavior rather than private +implementation details. + +**No new API options** - depth limits, non-plain object traversal changes, and +structured string parsing remain future implementation work after the current +behavior is covered. + +**Existing coverage boundaries** - current public API and matcher tests already +cover custom matcher error wrapping and JSON removal edge positions, so this +branch keeps new assertions at the replacer boundary. diff --git a/test/replacers.test.ts b/test/replacers.test.ts index ea9033f..4f876c7 100644 --- a/test/replacers.test.ts +++ b/test/replacers.test.ts @@ -131,6 +131,21 @@ describe('DataSanitizationReplacers', () => { expect(result.username).toEqual('bar'); }); + it('should mask unicode sensitive values', () => { + // Arrange + const testData = JSON.stringify({ + password: 'paß🔐word', + username: 'márk', + }); + + // Act + const result = JSON.parse(stringReplacer(testData) as string); + + // Assert + expect(result.password).toEqual(DEFAULT_PATTERN_MASK); + expect(result.username).toEqual('márk'); + }); + it('should fully mask form values containing non-delimiter punctuation', () => { // Arrange const testData = @@ -323,8 +338,116 @@ describe('DataSanitizationReplacers', () => { expect(result.api_key).toEqual(DEFAULT_PATTERN_MASK); expect(result.apikey).toEqual(DEFAULT_PATTERN_MASK); expect(result.username).toEqual('safe'); + }); + + it('should mask repeated sensitive keys at multiple depths', () => { + // Arrange + const testData = { + password: 'top-level', + profile: { + password: 'nested', + sessions: [ + { token: 'session-token', username: 'mark' }, + { metadata: { api_key: 'nested-key' } }, + ], + }, + }; + + // Act + const result = objectReplacer(testData) as Record; + + // Assert + expect(result).toEqual({ + password: DEFAULT_PATTERN_MASK, + profile: { + password: DEFAULT_PATTERN_MASK, + sessions: [ + { token: DEFAULT_PATTERN_MASK, username: 'mark' }, + { metadata: { api_key: DEFAULT_PATTERN_MASK } }, + ], + }, + }); + }); + + it('should mask sensitive keys in deeply nested objects', () => { + // Arrange + const testData = { + level1: { + level2: { + level3: { + level4: { + level5: { + level6: { + password: 'deep-secret', + visible: 'safe', + }, + }, + }, + }, + }, + }, + }; + + // Act + const result = objectReplacer(testData) as Record; - // Revert: no cleanup required + // Assert + expect(result).toEqual({ + level1: { + level2: { + level3: { + level4: { + level5: { + level6: { + password: DEFAULT_PATTERN_MASK, + visible: 'safe', + }, + }, + }, + }, + }, + }, + }); + }); + + it('should mask sensitive keys in larger arrays', () => { + // Arrange + const testData = Array.from({ length: 150 }, (_unused, index) => ({ + index, + token: `token-${index}`, + username: `user-${index}`, + })); + + // Act + const result = objectReplacer(testData) as Record[]; + + // Assert + expect(result).toHaveLength(150); + expect(result[0]).toEqual({ + index: 0, + token: DEFAULT_PATTERN_MASK, + username: 'user-0', + }); + expect(result[149]).toEqual({ + index: 149, + token: DEFAULT_PATTERN_MASK, + username: 'user-149', + }); + }); + + it('should mask unicode sensitive object values', () => { + // Arrange + const testData = { + password: 'paß🔐word', + username: 'márk', + }; + + // Act + const result = objectReplacer(testData) as Record; + + // Assert + expect(result.password).toEqual(DEFAULT_PATTERN_MASK); + expect(result.username).toEqual('márk'); }); }); @@ -347,8 +470,6 @@ describe('DataSanitizationReplacers', () => { // Assert expect(result).toEqual({ username: 'safe' }); - - // Revert: no cleanup required }); }); @@ -365,8 +486,6 @@ describe('DataSanitizationReplacers', () => { // Assert expect(result).toBe(nonObjectInput); - - // Revert: no cleanup required }); it('should support custom patterns when default patterns are disabled', () => { @@ -390,8 +509,6 @@ describe('DataSanitizationReplacers', () => { ssn: '[MASKED]', username: 'safe', }); - - // Revert: no cleanup required }); it('should preserve non-plain objects without corrupting their type', () => { @@ -410,8 +527,48 @@ describe('DataSanitizationReplacers', () => { expect(result.createdAt).toBe(date); expect(result.password).toEqual(DEFAULT_PATTERN_MASK); expect(result.username).toEqual('mark'); + }); + + it('should preserve nested non-plain object instances', () => { + // Arrange + class SessionRecord { + token = 'class-token'; + } - // Revert: no cleanup required + const permissions = new Map([['token', 'map-token']]); + const labels = new Set(['secret-label']); + const session = new SessionRecord(); + const testData = { + permissions, + labels, + session, + password: 'plain-secret', + }; + + // Act + const result = objectReplacer(testData) as Record; + + // Assert + expect(result.permissions).toBe(permissions); + expect(result.labels).toBe(labels); + expect(result.session).toBe(session); + expect(result.password).toEqual(DEFAULT_PATTERN_MASK); + }); + + it('should omit symbol-keyed properties from sanitized object clones', () => { + // Arrange + const tokenSymbol = Symbol('token'); + const testData = { + username: 'mark', + [tokenSymbol]: 'symbol-secret', + }; + + // Act + const result = objectReplacer(testData) as Record; + + // Assert + expect(result).toEqual({ username: 'mark' }); + expect(Object.getOwnPropertySymbols(result)).toHaveLength(0); }); }); });