email.test.ts173 lines · main
1import { describe, expect, test } from 'bun:test';
2import { createHmac } from 'node:crypto';
3
4import { redactEmail, verifySignature } from './email.js';
5
6const SECRET = 'whsec_test_20ef8a6f8c5166ae0872f6a4847b67782e44e3da2ef1e840277020cb42c26a4c';
7
8function sign(secret: string, tsMs: string, body: string): string {
9 return `v1=${createHmac('sha256', secret).update(`${tsMs}.${body}`).digest('hex')}`;
10}
11
12describe('verifySignature', () => {
13 const NOW = 1_747_000_000_000; // milliseconds
14 const body = '{"type":"email.delivered","messageId":"m_1"}';
15 const validSig = sign(SECRET, String(NOW), body);
16
17 test('accepts a freshly-signed payload within the tolerance window', () => {
18 expect(
19 verifySignature({
20 secret: SECRET,
21 signatureHeader: validSig,
22 timestampHeader: String(NOW),
23 body,
24 nowMs: NOW,
25 }),
26 ).toBe(true);
27 // 4 minutes 50 seconds drift — still under the 5 min default
28 expect(
29 verifySignature({
30 secret: SECRET,
31 signatureHeader: validSig,
32 timestampHeader: String(NOW),
33 body,
34 nowMs: NOW + 290_000,
35 }),
36 ).toBe(true);
37 });
38
39 test('rejects a payload outside the tolerance window (replay defence)', () => {
40 expect(
41 verifySignature({
42 secret: SECRET,
43 signatureHeader: validSig,
44 timestampHeader: String(NOW),
45 body,
46 nowMs: NOW + 301_000,
47 }),
48 ).toBe(false);
49 expect(
50 verifySignature({
51 secret: SECRET,
52 signatureHeader: validSig,
53 timestampHeader: String(NOW),
54 body,
55 nowMs: NOW - 301_000,
56 }),
57 ).toBe(false);
58 });
59
60 test('rejects when the body has been tampered with', () => {
61 expect(
62 verifySignature({
63 secret: SECRET,
64 signatureHeader: validSig,
65 timestampHeader: String(NOW),
66 body: '{"type":"email.delivered","messageId":"m_2"}',
67 nowMs: NOW,
68 }),
69 ).toBe(false);
70 });
71
72 test('rejects when the signature byte does not match', () => {
73 const flipped = validSig.endsWith('a')
74 ? validSig.replace(/a$/, 'b')
75 : validSig.replace(/.$/, 'a');
76 expect(
77 verifySignature({
78 secret: SECRET,
79 signatureHeader: flipped,
80 timestampHeader: String(NOW),
81 body,
82 nowMs: NOW,
83 }),
84 ).toBe(false);
85 });
86
87 test('rejects when the secret is wrong', () => {
88 expect(
89 verifySignature({
90 secret: `${SECRET}_different`,
91 signatureHeader: validSig,
92 timestampHeader: String(NOW),
93 body,
94 nowMs: NOW,
95 }),
96 ).toBe(false);
97 });
98
99 test('rejects malformed or missing headers', () => {
100 expect(
101 verifySignature({ secret: SECRET, signatureHeader: null, timestampHeader: String(NOW), body, nowMs: NOW }),
102 ).toBe(false);
103 expect(
104 verifySignature({ secret: SECRET, signatureHeader: validSig, timestampHeader: null, body, nowMs: NOW }),
105 ).toBe(false);
106 expect(
107 verifySignature({
108 secret: SECRET,
109 // Missing the v1= prefix → reject.
110 signatureHeader: validSig.replace('v1=', ''),
111 timestampHeader: String(NOW),
112 body,
113 nowMs: NOW,
114 }),
115 ).toBe(false);
116 expect(
117 verifySignature({
118 secret: SECRET,
119 signatureHeader: validSig,
120 timestampHeader: 'not-a-number',
121 body,
122 nowMs: NOW,
123 }),
124 ).toBe(false);
125 });
126
127 test('signature length must match exactly (no truncation attacks)', () => {
128 expect(
129 verifySignature({
130 secret: SECRET,
131 signatureHeader: validSig.slice(0, validSig.length / 2),
132 timestampHeader: String(NOW),
133 body,
134 nowMs: NOW,
135 }),
136 ).toBe(false);
137 });
138
139 test('custom tolerance window', () => {
140 expect(
141 verifySignature({
142 secret: SECRET,
143 signatureHeader: validSig,
144 timestampHeader: String(NOW),
145 body,
146 nowMs: NOW + 60_000,
147 toleranceMs: 30_000,
148 }),
149 ).toBe(false);
150 });
151});
152
153describe('redactEmail', () => {
154 test('redacts a typical address', () => {
155 expect(redactEmail('flandriendev@hotmail.com')).toBe('f•••v@h•••m');
156 });
157
158 test('handles a two-letter local part', () => {
159 expect(redactEmail('jo@example.com')).toBe('j•••o@e•••m');
160 });
161
162 test('handles a single-letter local part', () => {
163 expect(redactEmail('a@example.com')).toBe('a@e•••m');
164 });
165
166 test('returns a safe sentinel on a malformed input (no @)', () => {
167 expect(redactEmail('not-an-email')).toBe('•••');
168 });
169
170 test('handles an empty local part (edge case)', () => {
171 expect(redactEmail('@example.com')).toBe('@e•••m');
172 });
173});