@jasperoosthoek/zustand-auth-registry 0.0.1

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,608 @@
1
+ import { createAuthStore } from '../authStore';
2
+ import { validateAuthConfig } from '../authConfig';
3
+ import { TestUser, createMockAxios, createMockStorage, resetAllMocks } from './testHelpers';
4
+ import { testConfigs, createStorageQuotaError } from './testUtils';
5
+
6
+ describe('createAuthStore', () => {
7
+ let mockAxios: any;
8
+
9
+ beforeEach(() => {
10
+ resetAllMocks();
11
+ mockAxios = createMockAxios();
12
+ });
13
+
14
+ describe('initial state', () => {
15
+ it('should create store with empty initial state when no persistence', () => {
16
+ const config = validateAuthConfig({
17
+ ...testConfigs.withoutPersistence,
18
+ axios: mockAxios
19
+ });
20
+
21
+ const store = createAuthStore(config);
22
+ const state = store.getState();
23
+
24
+ expect(state.user).toBeNull();
25
+ expect(state.token).toBe('');
26
+ expect(state.isAuthenticated).toBe(false);
27
+ });
28
+
29
+ it('should restore state from localStorage when available', () => {
30
+ const mockStorage = window.localStorage as jest.Mocked<Storage>;
31
+ mockStorage.getItem.mockImplementation((key: string) => {
32
+ if (key === 'token') return 'stored-token';
33
+ if (key === 'user') return JSON.stringify({ id: 1, email: 'test@example.com', name: 'Test User' });
34
+ return null;
35
+ });
36
+
37
+ const config = validateAuthConfig({
38
+ axios: mockAxios,
39
+ loginUrl: '/login',
40
+ logoutUrl: '/logout',
41
+ extractToken: (data: any) => data.token
42
+ });
43
+
44
+ const store = createAuthStore(config);
45
+ const state = store.getState();
46
+
47
+ expect(state.token).toBe('stored-token');
48
+ expect(state.user).toEqual({ id: 1, email: 'test@example.com', name: 'Test User' });
49
+ expect(state.isAuthenticated).toBe(true);
50
+ });
51
+
52
+ it('should handle corrupted user data in storage', () => {
53
+ const mockStorage = window.localStorage as jest.Mocked<Storage>;
54
+ mockStorage.getItem.mockImplementation((key: string) => {
55
+ if (key === 'token') return 'stored-token';
56
+ if (key === 'user') return 'invalid-json';
57
+ return null;
58
+ });
59
+
60
+ const config = validateAuthConfig({
61
+ axios: mockAxios,
62
+ loginUrl: '/login',
63
+ logoutUrl: '/logout',
64
+ extractToken: (data: any) => data.token
65
+ });
66
+
67
+ const store = createAuthStore(config);
68
+ const state = store.getState();
69
+
70
+ expect(state.token).toBe('stored-token');
71
+ expect(state.user).toBeNull();
72
+ expect(state.isAuthenticated).toBe(true); // Token exists, so authenticated
73
+ });
74
+
75
+ it('should work with custom storage keys', () => {
76
+ const mockStorage = window.sessionStorage as jest.Mocked<Storage>;
77
+ mockStorage.getItem.mockImplementation((key: string) => {
78
+ if (key === 'custom_token') return 'stored-token';
79
+ if (key === 'custom_user') return JSON.stringify({ id: 1, email: 'test@example.com', name: 'Test User' });
80
+ return null;
81
+ });
82
+
83
+ const config = validateAuthConfig({
84
+ ...testConfigs.withCustomStorage,
85
+ axios: mockAxios
86
+ });
87
+
88
+ const store = createAuthStore(config);
89
+ const state = store.getState();
90
+
91
+ expect(state.token).toBe('stored-token');
92
+ expect(state.user).toEqual({ id: 1, email: 'test@example.com', name: 'Test User' });
93
+ });
94
+ });
95
+
96
+ describe('setToken', () => {
97
+ it('should update token and storage', () => {
98
+ const config = validateAuthConfig({
99
+ axios: mockAxios,
100
+ loginUrl: '/login',
101
+ logoutUrl: '/logout',
102
+ extractToken: (data: any) => data.token
103
+ });
104
+
105
+ const store = createAuthStore(config);
106
+ const { setToken } = store.getState();
107
+
108
+ setToken('new-token');
109
+
110
+ const state = store.getState();
111
+ expect(state.token).toBe('new-token');
112
+ expect(state.isAuthenticated).toBe(true);
113
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('token', 'new-token');
114
+ });
115
+
116
+ it('should handle storage errors gracefully', () => {
117
+ const onError = jest.fn();
118
+ const config = validateAuthConfig({
119
+ axios: mockAxios,
120
+ loginUrl: '/login',
121
+ logoutUrl: '/logout',
122
+ extractToken: (data: any) => data.token,
123
+ onError
124
+ });
125
+
126
+ const mockStorage = window.localStorage as jest.Mocked<Storage>;
127
+ mockStorage.setItem.mockImplementation(() => {
128
+ throw createStorageQuotaError();
129
+ });
130
+
131
+ const store = createAuthStore(config);
132
+ const { setToken } = store.getState();
133
+
134
+ setToken('new-token');
135
+
136
+ const state = store.getState();
137
+ expect(state.token).toBe('new-token');
138
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
139
+ });
140
+
141
+ it('should not write to storage when persistence disabled', () => {
142
+ const config = validateAuthConfig({
143
+ ...testConfigs.withoutPersistence,
144
+ axios: mockAxios
145
+ });
146
+
147
+ const store = createAuthStore(config);
148
+ const { setToken } = store.getState();
149
+
150
+ setToken('new-token');
151
+
152
+ expect(window.localStorage.setItem).not.toHaveBeenCalled();
153
+ expect(store.getState().token).toBe('new-token');
154
+ });
155
+ });
156
+
157
+ describe('setUser', () => {
158
+ it('should update user and storage', () => {
159
+ const config = validateAuthConfig({
160
+ axios: mockAxios,
161
+ loginUrl: '/login',
162
+ logoutUrl: '/logout',
163
+ extractToken: (data: any) => data.token
164
+ });
165
+
166
+ const store = createAuthStore(config);
167
+ const { setUser } = store.getState();
168
+
169
+ const user: TestUser = { id: 1, email: 'test@example.com', name: 'Test User' };
170
+ setUser(user);
171
+
172
+ const state = store.getState();
173
+ expect(state.user).toEqual(user);
174
+ expect(state.isAuthenticated).toBe(true);
175
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('user', JSON.stringify(user));
176
+ });
177
+
178
+ it('should handle storage errors for user data', () => {
179
+ const onError = jest.fn();
180
+ const config = validateAuthConfig({
181
+ axios: mockAxios,
182
+ loginUrl: '/login',
183
+ logoutUrl: '/logout',
184
+ extractToken: (data: any) => data.token,
185
+ onError
186
+ });
187
+
188
+ const mockStorage = window.localStorage as jest.Mocked<Storage>;
189
+ mockStorage.setItem.mockImplementation((key: string) => {
190
+ if (key === 'user') throw createStorageQuotaError();
191
+ });
192
+
193
+ const store = createAuthStore(config);
194
+ const { setUser } = store.getState();
195
+
196
+ const user: TestUser = { id: 1, email: 'test@example.com', name: 'Test User' };
197
+ setUser(user);
198
+
199
+ expect(store.getState().user).toEqual(user);
200
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
201
+ });
202
+ });
203
+
204
+ describe('unsetUser', () => {
205
+ it('should clear user and token from state and storage', () => {
206
+ const config = validateAuthConfig({
207
+ axios: mockAxios,
208
+ loginUrl: '/login',
209
+ logoutUrl: '/logout',
210
+ extractToken: (data: any) => data.token
211
+ });
212
+
213
+ const store = createAuthStore(config);
214
+ const { setToken, setUser, unsetUser } = store.getState();
215
+
216
+ // Set initial data
217
+ setToken('token');
218
+ setUser({ id: 1, email: 'test@example.com', name: 'Test User' });
219
+
220
+ // Clear data
221
+ unsetUser();
222
+
223
+ const state = store.getState();
224
+ expect(state.user).toBeNull();
225
+ expect(state.token).toBe('');
226
+ expect(state.isAuthenticated).toBe(false);
227
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith('token');
228
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith('user');
229
+ });
230
+
231
+ it('should handle storage removal errors', () => {
232
+ const onError = jest.fn();
233
+ const config = validateAuthConfig({
234
+ axios: mockAxios,
235
+ loginUrl: '/login',
236
+ logoutUrl: '/logout',
237
+ extractToken: (data: any) => data.token,
238
+ onError
239
+ });
240
+
241
+ const mockStorage = window.localStorage as jest.Mocked<Storage>;
242
+ mockStorage.removeItem.mockImplementation(() => {
243
+ throw new Error('Storage error');
244
+ });
245
+
246
+ const store = createAuthStore(config);
247
+ const { unsetUser } = store.getState();
248
+
249
+ unsetUser();
250
+
251
+ const state = store.getState();
252
+ expect(state.user).toBeNull();
253
+ expect(state.isAuthenticated).toBe(false);
254
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
255
+ });
256
+ });
257
+
258
+ describe('persistence configurations', () => {
259
+ it('should work with sessionStorage', () => {
260
+ const mockSessionStorage = window.sessionStorage as jest.Mocked<Storage>;
261
+ mockSessionStorage.getItem.mockImplementation((key: string) => {
262
+ if (key === 'token') return 'session-token';
263
+ return null;
264
+ });
265
+
266
+ const config = validateAuthConfig({
267
+ axios: mockAxios,
268
+ loginUrl: '/login',
269
+ logoutUrl: '/logout',
270
+ extractToken: (data: any) => data.token,
271
+ persistence: {
272
+ storage: window.sessionStorage
273
+ }
274
+ });
275
+
276
+ const store = createAuthStore(config);
277
+ expect(store.getState().token).toBe('session-token');
278
+
279
+ const { setToken } = store.getState();
280
+ setToken('new-session-token');
281
+
282
+ expect(mockSessionStorage.setItem).toHaveBeenCalledWith('token', 'new-session-token');
283
+ });
284
+
285
+ it('should work with custom storage implementation', () => {
286
+ const customStorage = createMockStorage();
287
+ customStorage.getItem.mockReturnValue('custom-token');
288
+
289
+ const config = validateAuthConfig({
290
+ axios: mockAxios,
291
+ loginUrl: '/login',
292
+ logoutUrl: '/logout',
293
+ extractToken: (data: any) => data.token,
294
+ persistence: {
295
+ storage: customStorage as Storage
296
+ }
297
+ });
298
+
299
+ const store = createAuthStore(config);
300
+ expect(store.getState().token).toBe('custom-token');
301
+
302
+ const { setToken } = store.getState();
303
+ setToken('new-custom-token');
304
+
305
+ expect(customStorage.setItem).toHaveBeenCalledWith('token', 'new-custom-token');
306
+ });
307
+ });
308
+
309
+ describe('SSR compatibility', () => {
310
+ it('should work when localStorage is not available', () => {
311
+ const config = validateAuthConfig({
312
+ axios: mockAxios,
313
+ loginUrl: '/login',
314
+ logoutUrl: '/logout',
315
+ extractToken: (data: any) => data.token,
316
+ persistence: {
317
+ storage: {} as Storage
318
+ }
319
+ });
320
+
321
+ const store = createAuthStore(config);
322
+ const { setToken, setUser, unsetUser } = store.getState();
323
+
324
+ // Should not throw errors
325
+ expect(() => {
326
+ setToken('token');
327
+ setUser({ id: 1, email: 'test@example.com', name: 'Test User' });
328
+ unsetUser();
329
+ }).not.toThrow();
330
+ });
331
+ });
332
+
333
+ describe('authentication state logic', () => {
334
+ it('should set isAuthenticated to true when both token and user exist', () => {
335
+ const config = validateAuthConfig({
336
+ axios: mockAxios,
337
+ loginUrl: '/login',
338
+ logoutUrl: '/logout',
339
+ extractToken: (data: any) => data.token
340
+ });
341
+
342
+ const store = createAuthStore(config);
343
+ const { setToken, setUser } = store.getState();
344
+
345
+ setToken('token');
346
+ expect(store.getState().isAuthenticated).toBe(true);
347
+
348
+ setUser({ id: 1, email: 'test@example.com', name: 'Test User' });
349
+ expect(store.getState().isAuthenticated).toBe(true);
350
+ });
351
+
352
+ it('should set isAuthenticated to false when token is empty', () => {
353
+ const config = validateAuthConfig({
354
+ axios: mockAxios,
355
+ loginUrl: '/login',
356
+ logoutUrl: '/logout',
357
+ extractToken: (data: any) => data.token
358
+ });
359
+
360
+ const store = createAuthStore(config);
361
+ const { setToken, setUser } = store.getState();
362
+
363
+ setUser({ id: 1, email: 'test@example.com', name: 'Test User' });
364
+ setToken('');
365
+
366
+ expect(store.getState().isAuthenticated).toBe(false);
367
+ });
368
+
369
+ it('should set isAuthenticated to false when user is null', () => {
370
+ const config = validateAuthConfig({
371
+ axios: mockAxios,
372
+ loginUrl: '/login',
373
+ logoutUrl: '/logout',
374
+ extractToken: (data: any) => data.token
375
+ });
376
+
377
+ const store = createAuthStore(config);
378
+ const { setToken, setUser } = store.getState();
379
+
380
+ setToken('token');
381
+ setUser(null as any);
382
+
383
+ expect(store.getState().isAuthenticated).toBe(true); // Still authenticated because token exists
384
+ });
385
+ });
386
+
387
+ describe('OAuth storage operations', () => {
388
+ it('should handle optional refresh token removal', () => {
389
+ const config = validateAuthConfig({
390
+ axios: mockAxios,
391
+ tokenUrl: '/oauth/token',
392
+ persistence: {
393
+ enabled: true,
394
+ storage: window.localStorage
395
+ }
396
+ });
397
+
398
+ const store = createAuthStore(config);
399
+
400
+ // Clear previous test state
401
+ (window.localStorage.setItem as jest.Mock).mockClear();
402
+ (window.localStorage.removeItem as jest.Mock).mockClear();
403
+
404
+ // Set tokens with refresh token
405
+ store.getState().setTokens({
406
+ accessToken: 'access-token',
407
+ refreshToken: 'refresh-token',
408
+ tokenType: 'Bearer'
409
+ });
410
+
411
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('refresh_token', 'refresh-token');
412
+
413
+ // Update tokens without refresh token
414
+ store.getState().setTokens({
415
+ accessToken: 'new-access-token',
416
+ tokenType: 'Bearer'
417
+ });
418
+
419
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith('refresh_token');
420
+ });
421
+
422
+ it('should handle optional expiry removal', () => {
423
+ const config = validateAuthConfig({
424
+ axios: mockAxios,
425
+ tokenUrl: '/oauth/token',
426
+ persistence: {
427
+ enabled: true,
428
+ storage: window.localStorage
429
+ }
430
+ });
431
+
432
+ const store = createAuthStore(config);
433
+
434
+ // First, set tokens with both refresh token and expiry
435
+ const expiresAt = Date.now() + 3600000;
436
+ store.getState().setTokens({
437
+ accessToken: 'access-token',
438
+ refreshToken: 'refresh-token',
439
+ tokenType: 'Bearer',
440
+ expiresAt
441
+ });
442
+
443
+ // Clear mocks after initial setup
444
+ (window.localStorage.setItem as jest.Mock).mockClear();
445
+ (window.localStorage.removeItem as jest.Mock).mockClear();
446
+
447
+ // Now update tokens with refresh token but without expiry
448
+ store.getState().setTokens({
449
+ accessToken: 'new-access-token',
450
+ refreshToken: 'refresh-token', // Keep refresh token
451
+ tokenType: 'Bearer'
452
+ // No expiresAt - this should trigger removal
453
+ });
454
+
455
+ // Verify that expires_at was removed when not provided
456
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith('expires_at');
457
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('token', 'new-access-token');
458
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('refresh_token', 'refresh-token');
459
+ });
460
+
461
+ it('should handle storage errors during refresh token operations', () => {
462
+ const onError = jest.fn();
463
+ const config = validateAuthConfig({
464
+ axios: mockAxios,
465
+ tokenUrl: '/oauth/token',
466
+ onError,
467
+ persistence: {
468
+ enabled: true,
469
+ storage: window.localStorage
470
+ }
471
+ });
472
+
473
+ const store = createAuthStore(config);
474
+ const error = new Error('Storage quota exceeded');
475
+
476
+ // Mock storage error for removeItem
477
+ (window.localStorage.removeItem as jest.Mock).mockImplementation(() => {
478
+ throw error;
479
+ });
480
+
481
+ // Set tokens without refresh token (should trigger removeItem)
482
+ store.getState().setTokens({
483
+ accessToken: 'access-token',
484
+ tokenType: 'Bearer'
485
+ });
486
+
487
+ expect(onError).toHaveBeenCalledWith(error);
488
+ });
489
+
490
+ it('should handle storage errors during expiry operations', () => {
491
+ const onError = jest.fn();
492
+ const config = validateAuthConfig({
493
+ axios: mockAxios,
494
+ tokenUrl: '/oauth/token',
495
+ onError,
496
+ persistence: {
497
+ enabled: true,
498
+ storage: window.localStorage
499
+ }
500
+ });
501
+
502
+ const store = createAuthStore(config);
503
+ const error = new Error('Storage quota exceeded');
504
+
505
+ // Mock storage error for removeItem
506
+ (window.localStorage.removeItem as jest.Mock).mockImplementation(() => {
507
+ throw error;
508
+ });
509
+
510
+ // Set tokens without expiry (should trigger removeItem)
511
+ store.getState().setTokens({
512
+ accessToken: 'access-token',
513
+ tokenType: 'Bearer'
514
+ });
515
+
516
+ expect(onError).toHaveBeenCalledWith(error);
517
+ });
518
+
519
+ it('should handle tokens without expiry information', () => {
520
+ const config = validateAuthConfig({
521
+ axios: mockAxios,
522
+ tokenUrl: '/oauth/token'
523
+ });
524
+
525
+ const store = createAuthStore(config);
526
+
527
+ // Set tokens without expiry
528
+ store.getState().setTokens({
529
+ accessToken: 'access-token',
530
+ tokenType: 'Bearer'
531
+ });
532
+
533
+ const isExpired = store.getState().isTokenExpired();
534
+ expect(isExpired).toBe(false); // No expiry info means no expiration
535
+ });
536
+
537
+ it('should return false when no expiration timestamp available', () => {
538
+ const config = validateAuthConfig({
539
+ axios: mockAxios,
540
+ tokenUrl: '/oauth/token'
541
+ });
542
+
543
+ const store = createAuthStore(config);
544
+
545
+ // No tokens set at all
546
+ const isExpired = store.getState().isTokenExpired();
547
+ expect(isExpired).toBe(false);
548
+ });
549
+
550
+ it('should handle tokens with expiry set to undefined', () => {
551
+ const config = validateAuthConfig({
552
+ axios: mockAxios,
553
+ tokenUrl: '/oauth/token'
554
+ });
555
+
556
+ const store = createAuthStore(config);
557
+
558
+ store.getState().setTokens({
559
+ accessToken: 'access-token',
560
+ tokenType: 'Bearer',
561
+ expiresAt: undefined
562
+ });
563
+
564
+ const isExpired = store.getState().isTokenExpired();
565
+ expect(isExpired).toBe(false);
566
+ });
567
+
568
+ it('should correctly identify expired tokens', () => {
569
+ const config = validateAuthConfig({
570
+ axios: mockAxios,
571
+ tokenUrl: '/oauth/token'
572
+ });
573
+
574
+ const store = createAuthStore(config);
575
+
576
+ // Set expired tokens
577
+ const expiredTime = Date.now() - 1000; // 1 second ago
578
+ store.getState().setTokens({
579
+ accessToken: 'expired-token',
580
+ tokenType: 'Bearer',
581
+ expiresAt: expiredTime
582
+ });
583
+
584
+ const isExpired = store.getState().isTokenExpired();
585
+ expect(isExpired).toBe(true);
586
+ });
587
+
588
+ it('should correctly identify valid tokens', () => {
589
+ const config = validateAuthConfig({
590
+ axios: mockAxios,
591
+ tokenUrl: '/oauth/token'
592
+ });
593
+
594
+ const store = createAuthStore(config);
595
+
596
+ // Set future expiry
597
+ const futureTime = Date.now() + 3600000; // 1 hour from now
598
+ store.getState().setTokens({
599
+ accessToken: 'valid-token',
600
+ tokenType: 'Bearer',
601
+ expiresAt: futureTime
602
+ });
603
+
604
+ const isExpired = store.getState().isTokenExpired();
605
+ expect(isExpired).toBe(false);
606
+ });
607
+ });
608
+ });