@zerodev/wallet-react 0.0.1-alpha.10 → 0.0.1-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,445 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ buildBackendOAuthUrl,
4
+ generateOAuthNonce,
5
+ handleOAuthCallback,
6
+ listenForOAuthMessage,
7
+ OAUTH_PROVIDERS,
8
+ openOAuthPopup,
9
+ } from './oauth.js'
10
+
11
+ describe('OAuth utilities', () => {
12
+ describe('OAUTH_PROVIDERS', () => {
13
+ it('has google provider', () => {
14
+ expect(OAUTH_PROVIDERS.GOOGLE).toBe('google')
15
+ })
16
+ })
17
+
18
+ describe('generateOAuthNonce', () => {
19
+ it('generates consistent nonce from public key', () => {
20
+ const publicKey = '0x1234567890abcdef'
21
+
22
+ const nonce1 = generateOAuthNonce(publicKey)
23
+ const nonce2 = generateOAuthNonce(publicKey)
24
+
25
+ expect(nonce1).toBe(nonce2)
26
+ })
27
+
28
+ it('returns nonce without 0x prefix', () => {
29
+ const publicKey =
30
+ '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
31
+
32
+ const nonce = generateOAuthNonce(publicKey)
33
+
34
+ expect(nonce).not.toMatch(/^0x/)
35
+ })
36
+
37
+ it('generates different nonces for different keys', () => {
38
+ const key1 =
39
+ '0x1111111111111111111111111111111111111111111111111111111111111111'
40
+ const key2 =
41
+ '0x2222222222222222222222222222222222222222222222222222222222222222'
42
+
43
+ const nonce1 = generateOAuthNonce(key1)
44
+ const nonce2 = generateOAuthNonce(key2)
45
+
46
+ expect(nonce1).not.toBe(nonce2)
47
+ })
48
+ })
49
+
50
+ describe('buildBackendOAuthUrl', () => {
51
+ it('builds correct URL for Google OAuth', () => {
52
+ const url = buildBackendOAuthUrl({
53
+ provider: 'google',
54
+ backendUrl: 'https://api.example.com',
55
+ projectId: 'proj-123',
56
+ publicKey: '0xabcdef1234567890',
57
+ returnTo: 'https://app.example.com/callback',
58
+ })
59
+
60
+ const parsedUrl = new URL(url)
61
+ expect(parsedUrl.origin).toBe('https://api.example.com')
62
+ expect(parsedUrl.pathname).toBe('/oauth/google/login')
63
+ expect(parsedUrl.searchParams.get('project_id')).toBe('proj-123')
64
+ expect(parsedUrl.searchParams.get('pub_key')).toBe('abcdef1234567890') // 0x stripped
65
+ expect(parsedUrl.searchParams.get('return_to')).toBe(
66
+ 'https://app.example.com/callback',
67
+ )
68
+ })
69
+
70
+ it('strips 0x prefix from public key', () => {
71
+ const url = buildBackendOAuthUrl({
72
+ provider: 'google',
73
+ backendUrl: 'https://api.example.com',
74
+ projectId: 'proj-123',
75
+ publicKey: '0x0123456789',
76
+ returnTo: 'https://app.example.com',
77
+ })
78
+
79
+ const parsedUrl = new URL(url)
80
+ expect(parsedUrl.searchParams.get('pub_key')).toBe('0123456789')
81
+ })
82
+
83
+ it('handles public key without 0x prefix', () => {
84
+ const url = buildBackendOAuthUrl({
85
+ provider: 'google',
86
+ backendUrl: 'https://api.example.com',
87
+ projectId: 'proj-123',
88
+ publicKey: 'abcdef123456',
89
+ returnTo: 'https://app.example.com',
90
+ })
91
+
92
+ const parsedUrl = new URL(url)
93
+ expect(parsedUrl.searchParams.get('pub_key')).toBe('abcdef123456')
94
+ })
95
+
96
+ it('throws on unsupported provider', () => {
97
+ expect(() =>
98
+ buildBackendOAuthUrl({
99
+ provider: 'facebook' as 'google',
100
+ backendUrl: 'https://api.example.com',
101
+ projectId: 'proj-123',
102
+ publicKey: '0xabcdef',
103
+ returnTo: 'https://app.example.com',
104
+ }),
105
+ ).toThrow('Unsupported OAuth provider: facebook')
106
+ })
107
+
108
+ it('encodes special characters in return URL', () => {
109
+ const url = buildBackendOAuthUrl({
110
+ provider: 'google',
111
+ backendUrl: 'https://api.example.com',
112
+ projectId: 'proj-123',
113
+ publicKey: '0xabcdef',
114
+ returnTo: 'https://app.example.com?foo=bar&baz=qux',
115
+ })
116
+
117
+ const parsedUrl = new URL(url)
118
+ expect(parsedUrl.searchParams.get('return_to')).toBe(
119
+ 'https://app.example.com?foo=bar&baz=qux',
120
+ )
121
+ })
122
+ })
123
+
124
+ describe('openOAuthPopup', () => {
125
+ const originalOpen = window.open
126
+
127
+ beforeEach(() => {
128
+ window.open = vi.fn()
129
+ })
130
+
131
+ afterEach(() => {
132
+ window.open = originalOpen
133
+ })
134
+
135
+ it('opens a popup with about:blank initially', () => {
136
+ const mockWindow = { location: { href: '' } }
137
+ vi.mocked(window.open).mockReturnValue(mockWindow as Window)
138
+
139
+ openOAuthPopup('https://oauth.example.com')
140
+
141
+ expect(window.open).toHaveBeenCalledWith(
142
+ 'about:blank',
143
+ '_blank',
144
+ expect.stringContaining('width=500,height=600'),
145
+ )
146
+ })
147
+
148
+ it('sets the URL after opening', () => {
149
+ const mockWindow = { location: { href: '' } }
150
+ vi.mocked(window.open).mockReturnValue(mockWindow as Window)
151
+
152
+ openOAuthPopup('https://oauth.example.com/login')
153
+
154
+ expect(mockWindow.location.href).toBe('https://oauth.example.com/login')
155
+ })
156
+
157
+ it('returns the window object', () => {
158
+ const mockWindow = { location: { href: '' } }
159
+ vi.mocked(window.open).mockReturnValue(mockWindow as Window)
160
+
161
+ const result = openOAuthPopup('https://oauth.example.com')
162
+
163
+ expect(result).toBe(mockWindow)
164
+ })
165
+
166
+ it('returns null if popup was blocked', () => {
167
+ vi.mocked(window.open).mockReturnValue(null)
168
+
169
+ const result = openOAuthPopup('https://oauth.example.com')
170
+
171
+ expect(result).toBeNull()
172
+ })
173
+ })
174
+
175
+ describe('listenForOAuthMessage', () => {
176
+ let mockWindow: { closed: boolean }
177
+ let onSuccessMock: ReturnType<typeof vi.fn>
178
+ let onErrorMock: ReturnType<typeof vi.fn>
179
+
180
+ beforeEach(() => {
181
+ vi.useFakeTimers()
182
+ mockWindow = { closed: false }
183
+ onSuccessMock = vi.fn()
184
+ onErrorMock = vi.fn()
185
+ })
186
+
187
+ afterEach(() => {
188
+ vi.useRealTimers()
189
+ })
190
+
191
+ it('calls onSuccess when oauth_success message received', () => {
192
+ const cleanup = listenForOAuthMessage(
193
+ mockWindow as Window,
194
+ 'https://app.example.com',
195
+ onSuccessMock as () => void,
196
+ onErrorMock as (error: Error) => void,
197
+ )
198
+
199
+ // Simulate receiving a message
200
+ const event = new MessageEvent('message', {
201
+ data: { type: 'oauth_success' },
202
+ origin: 'https://app.example.com',
203
+ })
204
+ window.dispatchEvent(event)
205
+
206
+ expect(onSuccessMock).toHaveBeenCalledOnce()
207
+ expect(onErrorMock).not.toHaveBeenCalled()
208
+ cleanup()
209
+ })
210
+
211
+ it('calls onError when oauth_error message received', () => {
212
+ const cleanup = listenForOAuthMessage(
213
+ mockWindow as Window,
214
+ 'https://app.example.com',
215
+ onSuccessMock as () => void,
216
+ onErrorMock as (error: Error) => void,
217
+ )
218
+
219
+ const event = new MessageEvent('message', {
220
+ data: { type: 'oauth_error', error: 'Access denied' },
221
+ origin: 'https://app.example.com',
222
+ })
223
+ window.dispatchEvent(event)
224
+
225
+ expect(onErrorMock).toHaveBeenCalledOnce()
226
+ expect(onErrorMock).toHaveBeenCalledWith(new Error('Access denied'))
227
+ expect(onSuccessMock).not.toHaveBeenCalled()
228
+ cleanup()
229
+ })
230
+
231
+ it('ignores messages from wrong origin', () => {
232
+ const cleanup = listenForOAuthMessage(
233
+ mockWindow as Window,
234
+ 'https://app.example.com',
235
+ onSuccessMock as () => void,
236
+ onErrorMock as (error: Error) => void,
237
+ )
238
+
239
+ const event = new MessageEvent('message', {
240
+ data: { type: 'oauth_success' },
241
+ origin: 'https://malicious.example.com',
242
+ })
243
+ window.dispatchEvent(event)
244
+
245
+ expect(onSuccessMock).not.toHaveBeenCalled()
246
+ expect(onErrorMock).not.toHaveBeenCalled()
247
+ cleanup()
248
+ })
249
+
250
+ it('calls onError when window is closed', () => {
251
+ const cleanup = listenForOAuthMessage(
252
+ mockWindow as Window,
253
+ 'https://app.example.com',
254
+ onSuccessMock as () => void,
255
+ onErrorMock as (error: Error) => void,
256
+ )
257
+
258
+ mockWindow.closed = true
259
+ vi.advanceTimersByTime(600)
260
+
261
+ expect(onErrorMock).toHaveBeenCalledWith(
262
+ new Error('Authentication window was closed'),
263
+ )
264
+ cleanup()
265
+ })
266
+
267
+ it('uses fallback error message when oauth_error has no error field', () => {
268
+ const cleanup = listenForOAuthMessage(
269
+ mockWindow as Window,
270
+ 'https://app.example.com',
271
+ onSuccessMock as () => void,
272
+ onErrorMock as (error: Error) => void,
273
+ )
274
+
275
+ const event = new MessageEvent('message', {
276
+ data: { type: 'oauth_error' },
277
+ origin: 'https://app.example.com',
278
+ })
279
+ window.dispatchEvent(event)
280
+
281
+ expect(onErrorMock).toHaveBeenCalledWith(
282
+ new Error('OAuth authentication failed'),
283
+ )
284
+ cleanup()
285
+ })
286
+
287
+ it('ignores messages with non-object data', () => {
288
+ const cleanup = listenForOAuthMessage(
289
+ mockWindow as Window,
290
+ 'https://app.example.com',
291
+ onSuccessMock as () => void,
292
+ onErrorMock as (error: Error) => void,
293
+ )
294
+
295
+ // null data
296
+ window.dispatchEvent(
297
+ new MessageEvent('message', {
298
+ data: null,
299
+ origin: 'https://app.example.com',
300
+ }),
301
+ )
302
+ // string data
303
+ window.dispatchEvent(
304
+ new MessageEvent('message', {
305
+ data: 'some-string',
306
+ origin: 'https://app.example.com',
307
+ }),
308
+ )
309
+ // number data
310
+ window.dispatchEvent(
311
+ new MessageEvent('message', {
312
+ data: 42,
313
+ origin: 'https://app.example.com',
314
+ }),
315
+ )
316
+
317
+ expect(onSuccessMock).not.toHaveBeenCalled()
318
+ expect(onErrorMock).not.toHaveBeenCalled()
319
+ cleanup()
320
+ })
321
+
322
+ it('calling cleanup multiple times is safe', () => {
323
+ const cleanup = listenForOAuthMessage(
324
+ mockWindow as Window,
325
+ 'https://app.example.com',
326
+ onSuccessMock as () => void,
327
+ onErrorMock as (error: Error) => void,
328
+ )
329
+
330
+ cleanup()
331
+ cleanup()
332
+ cleanup()
333
+
334
+ // Should not throw and subsequent messages should be ignored
335
+ const event = new MessageEvent('message', {
336
+ data: { type: 'oauth_success' },
337
+ origin: 'https://app.example.com',
338
+ })
339
+ window.dispatchEvent(event)
340
+
341
+ expect(onSuccessMock).not.toHaveBeenCalled()
342
+ })
343
+
344
+ it('returns cleanup function that stops listening', () => {
345
+ const cleanup = listenForOAuthMessage(
346
+ mockWindow as Window,
347
+ 'https://app.example.com',
348
+ onSuccessMock as () => void,
349
+ onErrorMock as (error: Error) => void,
350
+ )
351
+
352
+ cleanup()
353
+
354
+ // Message after cleanup should be ignored
355
+ const event = new MessageEvent('message', {
356
+ data: { type: 'oauth_success' },
357
+ origin: 'https://app.example.com',
358
+ })
359
+ window.dispatchEvent(event)
360
+
361
+ expect(onSuccessMock).not.toHaveBeenCalled()
362
+ })
363
+ })
364
+
365
+ describe('handleOAuthCallback', () => {
366
+ const originalLocation = window.location
367
+ const originalOpener = window.opener
368
+ const originalClose = window.close
369
+
370
+ beforeEach(() => {
371
+ // @ts-expect-error - Mocking location
372
+ delete window.location
373
+ // @ts-expect-error - Mocking location with partial properties
374
+ window.location = {
375
+ ...originalLocation,
376
+ search: '',
377
+ origin: 'https://app.example.com',
378
+ } as Location
379
+
380
+ window.opener = {
381
+ postMessage: vi.fn(),
382
+ } as unknown as Window
383
+
384
+ window.close = vi.fn()
385
+ })
386
+
387
+ afterEach(() => {
388
+ // @ts-expect-error - Restoring original location
389
+ window.location = originalLocation
390
+ window.opener = originalOpener
391
+ window.close = originalClose
392
+ })
393
+
394
+ it('returns true and posts success message on oauth_success=true', () => {
395
+ window.location.search = '?oauth_success=true'
396
+
397
+ const result = handleOAuthCallback()
398
+
399
+ expect(result).toBe(true)
400
+ expect(window.opener?.postMessage).toHaveBeenCalledWith(
401
+ { type: 'oauth_success' },
402
+ 'https://app.example.com',
403
+ )
404
+ expect(window.close).toHaveBeenCalled()
405
+ })
406
+
407
+ it('returns false and posts error message on error param', () => {
408
+ window.location.search = '?error=access_denied'
409
+
410
+ const result = handleOAuthCallback()
411
+
412
+ expect(result).toBe(false)
413
+ expect(window.opener?.postMessage).toHaveBeenCalledWith(
414
+ { type: 'oauth_error', error: 'access_denied' },
415
+ 'https://app.example.com',
416
+ )
417
+ expect(window.close).toHaveBeenCalled()
418
+ })
419
+
420
+ it('supports custom success param name', () => {
421
+ window.location.search = '?custom_param=true'
422
+
423
+ const result = handleOAuthCallback('custom_param')
424
+
425
+ expect(result).toBe(true)
426
+ })
427
+
428
+ it('returns false when no opener', () => {
429
+ window.opener = null
430
+ window.location.search = '?oauth_success=true'
431
+
432
+ const result = handleOAuthCallback()
433
+
434
+ expect(result).toBe(false)
435
+ })
436
+
437
+ it('returns false when no relevant params', () => {
438
+ window.location.search = '?some_other_param=value'
439
+
440
+ const result = handleOAuthCallback()
441
+
442
+ expect(result).toBe(false)
443
+ })
444
+ })
445
+ })