auth-users.test.ts120 lines · main
1import { describe, expect, test } from 'bun:test';
2
3import {
4 domainHintFromEmail,
5 nameInitialFrom,
6 redactUserRow,
7} from './auth-users.js';
8
9describe('auth-users — redaction (CLAUDE.md §5.1 + BUILD_PLAN.md §4)', () => {
10 // ─── domainHintFromEmail ────────────────────────────────────────────
11
12 test('extracts the domain portion of an email', () => {
13 expect(domainHintFromEmail('alice@gmail.com')).toBe('gmail.com');
14 expect(domainHintFromEmail('bob@mail.acme.co.uk')).toBe('mail.acme.co.uk');
15 });
16
17 test('lowercases the returned domain', () => {
18 expect(domainHintFromEmail('Bob@GMAIL.com')).toBe('gmail.com');
19 });
20
21 test('returns "?" for malformed input', () => {
22 expect(domainHintFromEmail('no-at-sign')).toBe('?');
23 expect(domainHintFromEmail('trailing@')).toBe('?');
24 });
25
26 test('does NOT include the local part — privacy boundary', () => {
27 const out = domainHintFromEmail('alice.particularly.sensitive@example.com');
28 expect(out).toBe('example.com');
29 expect(out).not.toContain('alice');
30 expect(out).not.toContain('particularly');
31 expect(out).not.toContain('sensitive');
32 });
33
34 // ─── nameInitialFrom ────────────────────────────────────────────────
35
36 test('returns the first character of name', () => {
37 expect(nameInitialFrom('Alice')).toBe('A');
38 expect(nameInitialFrom('jane doe')).toBe('j');
39 });
40
41 test('trims leading whitespace before extracting initial', () => {
42 expect(nameInitialFrom(' Bob')).toBe('B');
43 });
44
45 test('returns null for empty/null/whitespace-only names', () => {
46 expect(nameInitialFrom(null)).toBeNull();
47 expect(nameInitialFrom('')).toBeNull();
48 expect(nameInitialFrom(' ')).toBeNull();
49 });
50
51 test('handles unicode without splitting surrogate pairs', () => {
52 // emoji + multi-byte glyph: take the first grapheme cleanly
53 expect(nameInitialFrom('🦊 alice')).toBe('🦊');
54 expect(nameInitialFrom('Étienne')).toBe('É');
55 });
56
57 // ─── redactUserRow ──────────────────────────────────────────────────
58
59 test('redactUserRow strips email + name; keeps id + providers + timestamps', () => {
60 const out = redactUserRow({
61 id: 'u_01HZABC',
62 email: 'alice@gmail.com',
63 name: 'Alice',
64 createdAt: '2026-05-19T10:00:00.000Z',
65 lastSeenAt: '2026-05-19T11:00:00.000Z',
66 providerIds: ['google', 'passkey'],
67 });
68 expect(out).toEqual({
69 id: 'u_01HZABC',
70 emailDomainHint: 'gmail.com',
71 nameInitial: 'A',
72 providerIds: ['google', 'passkey'],
73 lastSeenAt: '2026-05-19T11:00:00.000Z',
74 createdAt: '2026-05-19T10:00:00.000Z',
75 });
76 });
77
78 test('redactUserRow output never contains the original email local part', () => {
79 const out = redactUserRow({
80 id: 'u_x',
81 email: 'super.secret.recipient@example.com',
82 name: 'Super Secret',
83 createdAt: '2026-05-19T10:00:00.000Z',
84 lastSeenAt: null,
85 providerIds: [],
86 });
87 const serialised = JSON.stringify(out);
88 expect(serialised).not.toContain('super.secret');
89 expect(serialised).not.toContain('recipient');
90 expect(serialised).toContain('example.com');
91 });
92
93 test('redactUserRow output never contains the original full name (only initial)', () => {
94 const out = redactUserRow({
95 id: 'u_x',
96 email: 'a@b.test',
97 name: 'Veronica Mars',
98 createdAt: '2026-05-19T10:00:00.000Z',
99 lastSeenAt: null,
100 providerIds: [],
101 });
102 const serialised = JSON.stringify(out);
103 expect(serialised).toContain('"nameInitial":"V"');
104 expect(serialised).not.toContain('Mars');
105 expect(serialised).not.toContain('Veronica');
106 });
107
108 test('redactUserRow passes lastSeenAt null through unchanged', () => {
109 const out = redactUserRow({
110 id: 'u_x',
111 email: 'a@b.test',
112 name: null,
113 createdAt: '2026-05-19T10:00:00.000Z',
114 lastSeenAt: null,
115 providerIds: [],
116 });
117 expect(out.lastSeenAt).toBeNull();
118 expect(out.nameInitial).toBeNull();
119 });
120});