import { describe, it, expect } from 'vitest'; import { diffFields, maskSensitiveFields } from '@/lib/audit'; describe('diffFields', () => { it('returns empty array when records are identical', () => { const result = diffFields( { name: 'Alice', status: 'active' }, { name: 'Alice', status: 'active' }, ); expect(result).toEqual([]); }); it('detects a single field change with correct field/old/new', () => { const result = diffFields( { name: 'Alice', status: 'active' }, { name: 'Alice', status: 'inactive' }, ); expect(result).toHaveLength(1); expect(result[0]).toEqual({ field: 'status', oldValue: 'active', newValue: 'inactive' }); }); it('detects multiple field changes', () => { const result = diffFields( { name: 'Alice', status: 'active', count: 1 }, { name: 'Bob', status: 'inactive', count: 2 }, ); expect(result).toHaveLength(3); const fields = result.map((r) => r.field); expect(fields).toContain('name'); expect(fields).toContain('status'); expect(fields).toContain('count'); }); it('detects null-to-value change', () => { const result = diffFields({ note: null }, { note: 'hello' }); expect(result).toHaveLength(1); expect(result[0]).toEqual({ field: 'note', oldValue: null, newValue: 'hello' }); }); it('detects value-to-null change', () => { const result = diffFields({ note: 'hello' }, { note: null }); expect(result).toHaveLength(1); expect(result[0]).toEqual({ field: 'note', oldValue: 'hello', newValue: null }); }); it('uses JSON comparison for nested objects', () => { const old = { meta: { x: 1, y: 2 } }; const updated = { meta: { x: 1, y: 3 } }; const result = diffFields(old, updated); expect(result).toHaveLength(1); expect(result[0]!.field).toBe('meta'); }); it('no diff when nested objects are deeply equal', () => { const result = diffFields({ meta: { x: 1 } }, { meta: { x: 1 } }); expect(result).toHaveLength(0); }); it('only checks keys present in newRecord', () => { // 'extra' key in old is irrelevant const result = diffFields({ name: 'Alice', extra: 'ignored' }, { name: 'Alice' }); expect(result).toHaveLength(0); }); }); describe('maskSensitiveFields', () => { it('masks email field', () => { const result = maskSensitiveFields({ email: 'alice@example.com' }); expect(result?.email).not.toBe('alice@example.com'); expect(typeof result?.email).toBe('string'); expect(result?.email).toContain('***'); }); it('masks phone field', () => { const result = maskSensitiveFields({ phone: '+61400000000' }); expect(result?.phone).toContain('***'); }); it('masks password field', () => { const result = maskSensitiveFields({ password: 'mySecret123' }); expect(result?.password).toContain('***'); }); it('masks credentials_enc field', () => { const result = maskSensitiveFields({ credentials_enc: 'eyJpdiI6IjEyMzQ1' }); expect(result?.credentials_enc).toContain('***'); }); it('masks token field', () => { const result = maskSensitiveFields({ token: 'abc-def-ghi-jkl' }); expect(result?.token).toContain('***'); }); it('preserves non-sensitive fields unchanged', () => { const result = maskSensitiveFields({ name: 'Alice', status: 'active', count: 5 }); expect(result?.name).toBe('Alice'); expect(result?.status).toBe('active'); expect(result?.count).toBe(5); }); it('applies partial masking: first 2 + *** + last 2 chars for strings longer than 4', () => { const result = maskSensitiveFields({ email: 'alice@example.com' }); // 'alice@example.com' length > 4, so al***om expect(result?.email).toBe('al***om'); }); it('replaces short strings (<=4 chars) with just ***', () => { const result = maskSensitiveFields({ email: 'ab@c' }); // length 4 expect(result?.email).toBe('***'); }); it('replaces 1-char sensitive string with ***', () => { const result = maskSensitiveFields({ token: 'x' }); expect(result?.token).toBe('***'); }); it('handles undefined input by returning undefined', () => { expect(maskSensitiveFields(undefined)).toBeUndefined(); }); it('does not mutate the original object', () => { const original = { email: 'alice@example.com', name: 'Alice' }; maskSensitiveFields(original); expect(original.email).toBe('alice@example.com'); }); describe('camelCase + PII coverage (W2.14 fix)', () => { it.each([ ['firstName', 'Alice'], ['lastName', 'Smith'], ['fullName', 'Alice Smith'], ['dateOfBirth', '1990-01-01'], ['addressLine1', '10 Downing St'], ['addressLine2', 'Flat 3'], ['city', 'London'], ['postalCode', 'SW1A 2AA'], ['country', 'United Kingdom'], ['recipientEmail', 'bob@example.com'], ['phoneNumber', '+44 1234 567890'], ])('masks %s (camelCase PII key)', (key, value) => { const result = maskSensitiveFields({ [key]: value }); expect(result?.[key]).not.toBe(value); expect(typeof result?.[key]).toBe('string'); expect(result?.[key] as string).toMatch(/\*\*\*/); }); it('does not over-mask innocuous "name" fields without PII context', () => { // 'name' alone (port name, tag name, column name) — must NOT be redacted // unless it's part of first_name / last_name / full_name etc. const result = maskSensitiveFields({ port_name: 'Port Nimara', tag_name: 'VIP', column_name: 'created_at', }); expect(result?.port_name).toBe('Port Nimara'); expect(result?.tag_name).toBe('VIP'); expect(result?.column_name).toBe('created_at'); }); }); });