import { randomBytes } from 'node:crypto'; import { HashUserIdService } from './hash-user-id.service'; const STRONG_SALT = randomBytes(32).toString('base64url'); function withSalt(salt: string, fn: () => T): T { const original = process.env['LOG_USER_ID_SALT']; process.env['LOG_USER_ID_SALT'] = salt; try { return fn(); } finally { if (original === undefined) { delete process.env['LOG_USER_ID_SALT']; } else { process.env['LOG_USER_ID_SALT'] = original; } } } describe('HashUserIdService', () => { it('hashes the same input to the same output (stable join key)', () => { withSalt(STRONG_SALT, () => { const service = new HashUserIdService(); expect(service.hash('user-oid-42')).toBe(service.hash('user-oid-42')); }); }); it('returns a 16-hex-char digest', () => { withSalt(STRONG_SALT, () => { const service = new HashUserIdService(); const hash = service.hash('user-oid-42'); expect(hash).toMatch(/^[0-9a-f]{16}$/); }); }); it('produces different outputs for different inputs', () => { withSalt(STRONG_SALT, () => { const service = new HashUserIdService(); expect(service.hash('alice')).not.toBe(service.hash('bob')); }); }); it('produces different outputs across salts (rotation invalidates the join key)', () => { const a = withSalt(STRONG_SALT, () => new HashUserIdService().hash('alice')); const b = withSalt(randomBytes(32).toString('base64url'), () => new HashUserIdService().hash('alice'), ); expect(a).not.toBe(b); }); it('refuses to construct when LOG_USER_ID_SALT is unset', () => { const original = process.env['LOG_USER_ID_SALT']; delete process.env['LOG_USER_ID_SALT']; try { expect(() => new HashUserIdService()).toThrow(/LOG_USER_ID_SALT is not set/); } finally { if (original !== undefined) { process.env['LOG_USER_ID_SALT'] = original; } } }); });