Coves frontend - a photon fork
1import { describe, it, expect } from 'vitest'
2import {
3 generateOAuthState,
4 validateOAuthState,
5 validateRequestOrigin,
6 isValidOAuthState,
7 asOAuthState,
8 tryAsOAuthState,
9 type OAuthState,
10} from './csrf'
11
12// ============================================================================
13// State Generation Tests
14// ============================================================================
15
16describe('generateOAuthState', () => {
17 it('produces a 64-character hex string', () => {
18 const state = generateOAuthState()
19
20 expect(state).toHaveLength(64)
21 expect(/^[a-f0-9]{64}$/.test(state)).toBe(true)
22 })
23
24 it('produces unique values on each call', () => {
25 const states = new Set<string>()
26
27 // Generate 100 states and verify uniqueness
28 for (let i = 0; i < 100; i++) {
29 states.add(generateOAuthState())
30 }
31
32 expect(states.size).toBe(100)
33 })
34
35 it('returns a branded OAuthState type', () => {
36 const state = generateOAuthState()
37
38 // Type check: state should be assignable to OAuthState
39 const typedState: OAuthState = state
40 expect(typedState).toBe(state)
41 })
42})
43
44// ============================================================================
45// State Validation Tests
46// ============================================================================
47
48describe('validateOAuthState', () => {
49 it('returns true for matching states', () => {
50 const state = generateOAuthState()
51
52 expect(validateOAuthState(state, state)).toBe(true)
53 })
54
55 it('returns true for identical manually created states', () => {
56 const state = 'a'.repeat(64)
57
58 expect(validateOAuthState(state, state)).toBe(true)
59 })
60
61 it('returns false for mismatched states', () => {
62 const state1 = generateOAuthState()
63 const state2 = generateOAuthState()
64
65 expect(validateOAuthState(state1, state2)).toBe(false)
66 })
67
68 it('returns false for states with different lengths', () => {
69 const state1 = generateOAuthState()
70 const state2 = state1.slice(0, 32) // Half length
71
72 expect(validateOAuthState(state1, state2)).toBe(false)
73 })
74
75 it('returns false for empty strings', () => {
76 const state = generateOAuthState()
77
78 expect(validateOAuthState(state, '')).toBe(false)
79 expect(validateOAuthState('', state)).toBe(false)
80 expect(validateOAuthState('', '')).toBe(true) // Both empty is technically equal
81 })
82
83 it('handles states that differ only in one character', () => {
84 const state1 = 'a'.repeat(64)
85 const state2 = 'a'.repeat(63) + 'b'
86
87 expect(validateOAuthState(state1, state2)).toBe(false)
88 })
89
90 it('uses timing-safe comparison (uses timingSafeEqual internally)', () => {
91 // We can verify this by checking the function imports crypto.timingSafeEqual
92 // The actual timing safety is guaranteed by Node's crypto module
93 const state1 = generateOAuthState()
94 const state2 = generateOAuthState()
95
96 // Multiple calls should have consistent performance regardless of where they differ
97 // This is a property test - the timing-safe comparison should work identically
98 const results: boolean[] = []
99 for (let i = 0; i < 10; i++) {
100 results.push(validateOAuthState(state1, state2))
101 }
102
103 // All results should be false (states are different)
104 expect(results.every((r) => r === false)).toBe(true)
105 })
106})
107
108// ============================================================================
109// OAuthState Type Validation Tests
110// ============================================================================
111
112describe('isValidOAuthState', () => {
113 it('validates correct 64-char hex strings', () => {
114 expect(isValidOAuthState('a'.repeat(64))).toBe(true)
115 expect(isValidOAuthState('0'.repeat(64))).toBe(true)
116 expect(isValidOAuthState('0123456789abcdef'.repeat(4))).toBe(true)
117 })
118
119 it('rejects non-hex characters', () => {
120 expect(isValidOAuthState('g'.repeat(64))).toBe(false)
121 expect(isValidOAuthState('A'.repeat(64))).toBe(false) // Uppercase not allowed
122 expect(isValidOAuthState('!'.repeat(64))).toBe(false)
123 })
124
125 it('rejects wrong length strings', () => {
126 expect(isValidOAuthState('a'.repeat(63))).toBe(false)
127 expect(isValidOAuthState('a'.repeat(65))).toBe(false)
128 expect(isValidOAuthState('')).toBe(false)
129 })
130})
131
132describe('asOAuthState', () => {
133 it('returns branded type for valid state', () => {
134 const validState = 'a'.repeat(64)
135 const branded = asOAuthState(validState)
136
137 expect(branded).toBe(validState)
138 })
139
140 it('throws for invalid state', () => {
141 expect(() => asOAuthState('invalid')).toThrow('Invalid OAuthState format')
142 expect(() => asOAuthState('a'.repeat(63))).toThrow('Invalid OAuthState format')
143 })
144})
145
146describe('tryAsOAuthState', () => {
147 it('returns branded type for valid state', () => {
148 const validState = 'a'.repeat(64)
149 const result = tryAsOAuthState(validState)
150
151 expect(result).toBe(validState)
152 })
153
154 it('returns null for invalid state', () => {
155 expect(tryAsOAuthState('invalid')).toBeNull()
156 expect(tryAsOAuthState('')).toBeNull()
157 })
158})
159
160// ============================================================================
161// Origin Validation Tests
162// ============================================================================
163
164describe('validateRequestOrigin', () => {
165 const expectedOrigin = 'https://example.com'
166
167 function createMockRequest(headers: Record<string, string>): Request {
168 return new Request('https://example.com/test', { headers })
169 }
170
171 it('accepts same-origin requests (Origin header)', () => {
172 const request = createMockRequest({ Origin: 'https://example.com' })
173 const result = validateRequestOrigin(request, expectedOrigin)
174
175 expect(result.valid).toBe(true)
176 expect(result.reason).toContain('Origin header matches')
177 })
178
179 it('rejects cross-origin requests (Origin header)', () => {
180 const request = createMockRequest({ Origin: 'https://evil.com' })
181 const result = validateRequestOrigin(request, expectedOrigin)
182
183 expect(result.valid).toBe(false)
184 expect(result.reason).toContain('Origin mismatch')
185 expect(result.reason).toContain('https://evil.com')
186 })
187
188 it('accepts same-origin requests via Referer header when Origin is missing', () => {
189 const request = createMockRequest({ Referer: 'https://example.com/some/path' })
190 const result = validateRequestOrigin(request, expectedOrigin)
191
192 expect(result.valid).toBe(true)
193 expect(result.reason).toContain('Referer origin matches')
194 })
195
196 it('rejects cross-origin requests via Referer header', () => {
197 const request = createMockRequest({ Referer: 'https://evil.com/attack' })
198 const result = validateRequestOrigin(request, expectedOrigin)
199
200 expect(result.valid).toBe(false)
201 expect(result.reason).toContain('Referer origin mismatch')
202 })
203
204 it('prefers Origin header over Referer header', () => {
205 // Even if Referer is cross-origin, if Origin matches, it should pass
206 const request = createMockRequest({
207 Origin: 'https://example.com',
208 Referer: 'https://evil.com/attack',
209 })
210 const result = validateRequestOrigin(request, expectedOrigin)
211
212 expect(result.valid).toBe(true)
213 expect(result.reason).toContain('Origin header matches')
214 })
215
216 it('accepts requests without Origin or Referer header', () => {
217 const request = createMockRequest({})
218 const result = validateRequestOrigin(request, expectedOrigin)
219
220 expect(result.valid).toBe(true)
221 expect(result.reason).toContain('No Origin or Referer header')
222 })
223
224 it('handles invalid Referer URL gracefully', () => {
225 const request = createMockRequest({ Referer: 'not-a-valid-url' })
226 const result = validateRequestOrigin(request, expectedOrigin)
227
228 expect(result.valid).toBe(false)
229 expect(result.reason).toContain('Invalid Referer URL')
230 })
231
232 it('handles port differences correctly', () => {
233 const request = createMockRequest({ Origin: 'https://example.com:443' })
234 const result = validateRequestOrigin(request, expectedOrigin)
235
236 // https://example.com:443 is NOT the same string as https://example.com
237 // Even though they're semantically equivalent, the string comparison fails
238 expect(result.valid).toBe(false)
239 })
240
241 it('handles protocol differences', () => {
242 const request = createMockRequest({ Origin: 'http://example.com' })
243 const result = validateRequestOrigin(request, expectedOrigin)
244
245 expect(result.valid).toBe(false)
246 expect(result.reason).toContain('Origin mismatch')
247 })
248
249 it('handles subdomain differences', () => {
250 const request = createMockRequest({ Origin: 'https://sub.example.com' })
251 const result = validateRequestOrigin(request, expectedOrigin)
252
253 expect(result.valid).toBe(false)
254 expect(result.reason).toContain('Origin mismatch')
255 })
256})