auth-mailer.test.ts216 lines · main
1import { describe, expect, test } from 'bun:test';
2
3import {
4 escapeHtml,
5 renderEmailVerify,
6 renderMagicLink,
7 renderNewDeviceLogin,
8 renderOtpCode,
9 renderPasswordReset,
10 resolveFallbackFromAddress,
11 resolveFromAddress,
12 type RenderContext,
13} from './auth-mailer.js';
14import { DEFAULT_AUTH_CONFIG, type AuthConfig } from './tenant-config-store.js';
15
16const ctx: RenderContext = {
17 primaryColor: '#00e87a',
18 senderName: 'acme auth',
19};
20
21describe('auth-mailer — pure helpers (BUILD_PLAN.md §8)', () => {
22 // ─── escapeHtml ─────────────────────────────────────────────────────
23
24 test('escapeHtml replaces the five XSS-relevant characters', () => {
25 expect(escapeHtml('<script>alert("x");</script>')).toBe(
26 '&lt;script&gt;alert(&quot;x&quot;);&lt;/script&gt;',
27 );
28 expect(escapeHtml("o'reilly & sons")).toBe('o&#39;reilly &amp; sons');
29 });
30
31 test('escapeHtml is a no-op on safe text', () => {
32 expect(escapeHtml('hello world')).toBe('hello world');
33 });
34
35 // ─── resolveFromAddress ──────────────────────────────────────────────
36
37 test('resolveFromAddress uses verified senderDomain when present', () => {
38 const config: AuthConfig = {
39 ...DEFAULT_AUTH_CONFIG,
40 branding: {
41 ...DEFAULT_AUTH_CONFIG.branding,
42 senderName: 'acme',
43 senderDomain: 'mail.acme.com',
44 },
45 };
46 expect(resolveFromAddress(config)).toBe('acme <noreply@mail.acme.com>');
47 });
48
49 test('resolveFromAddress falls back to the briven domain (never auth.*) without a custom domain', () => {
50 const from = resolveFromAddress(DEFAULT_AUTH_CONFIG);
51 // Env-dependent domain (BRIVEN_DOMAIN ?? briven.tech) — assert the shape
52 // and the hard rule: the fallback is NEVER an auth. subdomain.
53 expect(from).toMatch(/^"briven auth" <noreply@[a-z0-9.-]+>$/);
54 expect(from).not.toContain('noreply@auth.');
55 });
56
57 // ─── resolveFallbackFromAddress ──────────────────────────────────────
58
59 test('resolveFallbackFromAddress ignores the custom senderDomain', () => {
60 const cfg: AuthConfig = {
61 ...DEFAULT_AUTH_CONFIG,
62 branding: {
63 ...DEFAULT_AUTH_CONFIG.branding,
64 senderName: 'acme',
65 senderDomain: 'mail.acme.com',
66 },
67 };
68 const from = resolveFallbackFromAddress(cfg);
69 expect(from).not.toContain('mail.acme.com');
70 expect(from).toMatch(/^acme <noreply@[a-z0-9.-]+>$/);
71 expect(from).not.toContain('noreply@auth.');
72 });
73
74 test('resolveFallbackFromAddress keeps display-name quoting rules', () => {
75 const cfg: AuthConfig = {
76 ...DEFAULT_AUTH_CONFIG,
77 branding: {
78 ...DEFAULT_AUTH_CONFIG.branding,
79 senderName: 'acme auth',
80 senderDomain: 'mail.acme.com',
81 },
82 };
83 expect(resolveFallbackFromAddress(cfg)).toMatch(/^"acme auth" <noreply@/);
84 });
85
86 test('resolveFromAddress quotes display names with whitespace or specials', () => {
87 const cfg: AuthConfig = {
88 ...DEFAULT_AUTH_CONFIG,
89 branding: {
90 ...DEFAULT_AUTH_CONFIG.branding,
91 senderName: 'acme auth',
92 senderDomain: 'mail.acme.com',
93 },
94 };
95 expect(resolveFromAddress(cfg)).toBe('"acme auth" <noreply@mail.acme.com>');
96 });
97
98 test('resolveFromAddress escapes embedded double-quotes in display name', () => {
99 const cfg: AuthConfig = {
100 ...DEFAULT_AUTH_CONFIG,
101 branding: {
102 ...DEFAULT_AUTH_CONFIG.branding,
103 senderName: 'evil"corp',
104 senderDomain: 'mail.acme.com',
105 },
106 };
107 // Embedded quote → backslash-escaped per RFC 5322 mailbox grammar.
108 expect(resolveFromAddress(cfg)).toBe('"evil\\"corp" <noreply@mail.acme.com>');
109 });
110
111 // ─── renderMagicLink ────────────────────────────────────────────────
112
113 test('renderMagicLink includes the URL + expiry + sender name', () => {
114 const r = renderMagicLink(ctx, { url: 'https://example.com/x', expiryMinutes: 15 });
115 expect(r.subject).toBe('your sign-in link to acme auth');
116 expect(r.html).toContain('https://example.com/x');
117 expect(r.html).toContain('15 minutes');
118 expect(r.text).toContain('https://example.com/x');
119 expect(r.text).toContain('15 minutes');
120 });
121
122 test('renderMagicLink html paints the primary-color accent', () => {
123 const r = renderMagicLink(ctx, { url: 'https://x.test/a', expiryMinutes: 15 });
124 expect(r.html.toLowerCase()).toContain('#00e87a');
125 });
126
127 // ─── renderOtpCode ──────────────────────────────────────────────────
128
129 test('renderOtpCode shows the code prominently + in the subject', () => {
130 const r = renderOtpCode(ctx, { code: '482915', expiryMinutes: 5 });
131 expect(r.subject).toContain('482915');
132 expect(r.html).toContain('482915');
133 expect(r.text).toContain('482915');
134 expect(r.text).toContain('5 minutes');
135 });
136
137 test('renderOtpCode escapes the code in html (defence in depth)', () => {
138 const r = renderOtpCode(ctx, { code: '<bad>', expiryMinutes: 5 });
139 expect(r.html).toContain('&lt;bad&gt;');
140 // The raw `<bad>` substring will appear inside `<bad>` once html-escaped
141 // text is wrapped in other HTML — what matters is that an unescaped
142 // `<bad>` followed by anything other than `&gt;` cannot land. We assert
143 // the safer property: the literal escape sequence is present.
144 expect(r.html.includes('&lt;bad&gt;')).toBe(true);
145 });
146
147 // ─── renderEmailVerify ──────────────────────────────────────────────
148
149 test('renderEmailVerify embeds the verification URL', () => {
150 const r = renderEmailVerify(ctx, { url: 'https://acme.test/verify?token=abc' });
151 expect(r.subject).toBe('verify your email for acme auth');
152 expect(r.html).toContain('https://acme.test/verify?token=abc');
153 expect(r.text).toContain('https://acme.test/verify?token=abc');
154 });
155
156 // ─── renderPasswordReset ────────────────────────────────────────────
157
158 test('renderPasswordReset includes "secure your account" disclaimer', () => {
159 const r = renderPasswordReset(ctx, { url: 'https://acme.test/reset?token=xyz' });
160 expect(r.html.toLowerCase()).toContain('secure your account');
161 expect(r.text.toLowerCase()).toContain('secure your account');
162 expect(r.html).toContain('https://acme.test/reset?token=xyz');
163 });
164
165 // ─── renderNewDeviceLogin ───────────────────────────────────────────
166
167 test('renderNewDeviceLogin escapes deviceHint to block XSS via UA string', () => {
168 const r = renderNewDeviceLogin(ctx, {
169 deviceHint: '<script>alert(1)</script>',
170 whenIso: '2026-05-19T12:34:56Z',
171 manageUrl: 'https://acme.test/account/sessions',
172 });
173 expect(r.html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
174 expect(r.html).not.toContain('<script>alert(1)</script>');
175 // Timestamp + manage url present + uncorrupted.
176 expect(r.html).toContain('2026-05-19T12:34:56Z');
177 expect(r.html).toContain('https://acme.test/account/sessions');
178 });
179
180 // ─── cross-render invariants ────────────────────────────────────────
181
182 test('every template renders sender name into the footer', () => {
183 const outs = [
184 renderMagicLink(ctx, { url: 'https://x.test', expiryMinutes: 5 }),
185 renderOtpCode(ctx, { code: '111', expiryMinutes: 5 }),
186 renderEmailVerify(ctx, { url: 'https://x.test' }),
187 renderPasswordReset(ctx, { url: 'https://x.test' }),
188 renderNewDeviceLogin(ctx, {
189 deviceHint: 'd',
190 whenIso: 't',
191 manageUrl: 'https://x.test',
192 }),
193 ];
194 for (const out of outs) {
195 expect(out.html).toContain('acme auth');
196 }
197 });
198
199 test('subject lines never contain the customer accent color (no styling in subject)', () => {
200 const outs = [
201 renderMagicLink(ctx, { url: 'https://x.test', expiryMinutes: 15 }),
202 renderOtpCode(ctx, { code: '123456', expiryMinutes: 5 }),
203 renderEmailVerify(ctx, { url: 'https://x.test' }),
204 renderPasswordReset(ctx, { url: 'https://x.test' }),
205 renderNewDeviceLogin(ctx, {
206 deviceHint: 'd',
207 whenIso: 't',
208 manageUrl: 'https://x.test',
209 }),
210 ];
211 for (const out of outs) {
212 expect(out.subject).not.toContain('#00e87a');
213 expect(out.subject).not.toContain('<');
214 }
215 });
216});