Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .github/instructions/unit-tests.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
54 changes: 54 additions & 0 deletions docs/plans/005-hardening-regression-tests.md
Original file line number Diff line number Diff line change
@@ -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.
173 changes: 165 additions & 8 deletions test/replacers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Comment thread
ioncache marked this conversation as resolved.

it('should fully mask form values containing non-delimiter punctuation', () => {
// Arrange
const testData =
Expand Down Expand Up @@ -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<string, unknown>;

// 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<string, unknown>;

// 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<string, unknown>[];

// 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<string, unknown>;

// Assert
expect(result.password).toEqual(DEFAULT_PATTERN_MASK);
expect(result.username).toEqual('márk');
});
});

Expand All @@ -347,8 +470,6 @@ describe('DataSanitizationReplacers', () => {

// Assert
expect(result).toEqual({ username: 'safe' });

// Revert: no cleanup required
});
});

Expand All @@ -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', () => {
Expand All @@ -390,8 +509,6 @@ describe('DataSanitizationReplacers', () => {
ssn: '[MASKED]',
username: 'safe',
});

// Revert: no cleanup required
});

it('should preserve non-plain objects without corrupting their type', () => {
Expand All @@ -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<string, unknown>;

// 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<string, unknown>;

// Assert
expect(result).toEqual({ username: 'mark' });
expect(Object.getOwnPropertySymbols(result)).toHaveLength(0);
});
});
});
Expand Down
Loading