auth-mailer.test.ts216 lines · main
| 1 | import { describe, expect, test } from 'bun:test'; |
| 2 | |
| 3 | import { |
| 4 | escapeHtml, |
| 5 | renderEmailVerify, |
| 6 | renderMagicLink, |
| 7 | renderNewDeviceLogin, |
| 8 | renderOtpCode, |
| 9 | renderPasswordReset, |
| 10 | resolveFallbackFromAddress, |
| 11 | resolveFromAddress, |
| 12 | type RenderContext, |
| 13 | } from './auth-mailer.js'; |
| 14 | import { DEFAULT_AUTH_CONFIG, type AuthConfig } from './tenant-config-store.js'; |
| 15 | |
| 16 | const ctx: RenderContext = { |
| 17 | primaryColor: '#00e87a', |
| 18 | senderName: 'acme auth', |
| 19 | }; |
| 20 | |
| 21 | describe('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 | '<script>alert("x");</script>', |
| 27 | ); |
| 28 | expect(escapeHtml("o'reilly & sons")).toBe('o'reilly & 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('<bad>'); |
| 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 `>` cannot land. We assert |
| 143 | // the safer property: the literal escape sequence is present. |
| 144 | expect(r.html.includes('<bad>')).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('<script>alert(1)</script>'); |
| 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 | }); |