Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
150 lines
5.5 KiB
TypeScript
150 lines
5.5 KiB
TypeScript
/**
|
|
* Security: Sensitive Data Masking
|
|
*
|
|
* Verifies the maskSensitiveFields() function from @/lib/audit correctly
|
|
* redacts PII and secrets from audit log payloads.
|
|
*
|
|
* Sensitive fields per SECURITY-GUIDELINES.md §5.2:
|
|
* email, phone, password, credentials_enc, token
|
|
*
|
|
* Masking format:
|
|
* - len > 4 → first 2 chars + "***" + last 2 chars (e.g. "al***om")
|
|
* - len ≤ 4 → "***"
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { maskSensitiveFields } from '@/lib/audit';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Sensitive data masking - field detection', () => {
|
|
it('masks "email" field', () => {
|
|
const result = maskSensitiveFields({ email: 'user@example.com' });
|
|
expect(result?.email).not.toBe('user@example.com');
|
|
expect(result?.email).toContain('***');
|
|
});
|
|
|
|
it('masks "phone" field', () => {
|
|
const result = maskSensitiveFields({ phone: '+61400000000' });
|
|
expect(result?.phone).not.toBe('+61400000000');
|
|
expect(result?.phone).toContain('***');
|
|
});
|
|
|
|
it('masks "password" field', () => {
|
|
const result = maskSensitiveFields({ password: 'MySecretPassword123' });
|
|
expect(result?.password).not.toBe('MySecretPassword123');
|
|
expect(result?.password).toContain('***');
|
|
});
|
|
|
|
it('masks "credentials_enc" field', () => {
|
|
const result = maskSensitiveFields({ credentials_enc: 'encrypted-secret-data' });
|
|
expect(result?.credentials_enc).not.toBe('encrypted-secret-data');
|
|
expect(result?.credentials_enc).toContain('***');
|
|
});
|
|
|
|
it('masks "token" field', () => {
|
|
const result = maskSensitiveFields({ token: 'eyJhbGciOiJIUzI1NiJ9.test' });
|
|
expect(result?.token).not.toBe('eyJhbGciOiJIUzI1NiJ9.test');
|
|
expect(result?.token).toContain('***');
|
|
});
|
|
});
|
|
|
|
describe('Sensitive data masking - masking format', () => {
|
|
it('long email (len > 4) uses partial mask: first 2 + *** + last 2', () => {
|
|
// 'user@example.com' → 'us***om'
|
|
const result = maskSensitiveFields({ email: 'user@example.com' });
|
|
expect(result?.email).toBe('us***om');
|
|
});
|
|
|
|
it('short sensitive value (len ≤ 4) is fully replaced with ***', () => {
|
|
const result = maskSensitiveFields({ email: 'ab' });
|
|
expect(result?.email).toBe('***');
|
|
});
|
|
|
|
it('exactly 4-char sensitive value is fully masked', () => {
|
|
const result = maskSensitiveFields({ email: 'abcd' });
|
|
expect(result?.email).toBe('***');
|
|
});
|
|
|
|
it('5-char sensitive value uses partial mask', () => {
|
|
// 'abcde' → 'ab***de'
|
|
const result = maskSensitiveFields({ password: 'abcde' });
|
|
expect(result?.password).toBe('ab***de');
|
|
});
|
|
|
|
it('single char sensitive value becomes ***', () => {
|
|
const result = maskSensitiveFields({ token: 'x' });
|
|
expect(result?.token).toBe('***');
|
|
});
|
|
|
|
it('partial mask exposes only 2 leading and 2 trailing characters', () => {
|
|
const result = maskSensitiveFields({ password: 'SuperSecret2025!' });
|
|
const masked = result?.password as string;
|
|
// 'SuperSecret2025!' → first 2 = 'Su', last 2 = '5!', mask = 'Su***5!'
|
|
expect(masked).toMatch(/^Su\*{3}5!$/);
|
|
});
|
|
});
|
|
|
|
describe('Sensitive data masking - non-sensitive fields', () => {
|
|
it('preserves string non-sensitive fields unchanged', () => {
|
|
const result = maskSensitiveFields({ name: 'John Smith', status: 'active' });
|
|
expect(result?.name).toBe('John Smith');
|
|
expect(result?.status).toBe('active');
|
|
});
|
|
|
|
it('preserves numeric non-sensitive fields unchanged', () => {
|
|
const result = maskSensitiveFields({ count: 42, score: 9.5 });
|
|
expect(result?.count).toBe(42);
|
|
expect(result?.score).toBe(9.5);
|
|
});
|
|
|
|
it('preserves boolean non-sensitive fields unchanged', () => {
|
|
const result = maskSensitiveFields({ isProxy: true, isActive: false });
|
|
expect(result?.isProxy).toBe(true);
|
|
expect(result?.isActive).toBe(false);
|
|
});
|
|
|
|
it('preserves null non-sensitive fields unchanged', () => {
|
|
const result = maskSensitiveFields({ companyName: null });
|
|
expect(result?.companyName).toBeNull();
|
|
});
|
|
|
|
it('mixed record: masks sensitive, preserves non-sensitive', () => {
|
|
const result = maskSensitiveFields({
|
|
name: 'John',
|
|
email: 'john@example.com',
|
|
status: 'active',
|
|
password: 'hunter2',
|
|
});
|
|
expect(result?.name).toBe('John');
|
|
expect(result?.status).toBe('active');
|
|
expect(result?.email).toContain('***');
|
|
expect(result?.password).toContain('***');
|
|
});
|
|
});
|
|
|
|
describe('Sensitive data masking - edge cases', () => {
|
|
it('returns undefined for undefined input', () => {
|
|
expect(maskSensitiveFields(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('returns empty object for empty object input', () => {
|
|
const result = maskSensitiveFields({});
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('does not mutate the original object', () => {
|
|
const original = { email: 'alice@example.com', name: 'Alice' };
|
|
const originalEmail = original.email;
|
|
maskSensitiveFields(original);
|
|
expect(original.email).toBe(originalEmail);
|
|
});
|
|
|
|
it('only masks string values - non-string sensitive fields are left as-is', () => {
|
|
// e.g. if someone stores a number in an "email" field (type error upstream),
|
|
// the masking logic gracefully skips it (typeof check)
|
|
const result = maskSensitiveFields({ email: 12345 as unknown as string });
|
|
// The implementation only masks if typeof === 'string', so a number stays
|
|
expect(result?.email).toBe(12345);
|
|
});
|
|
});
|