gameglue 4.0.0 → 4.0.2

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.
Files changed (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +275 -275
  3. package/babel.config.cjs +5 -5
  4. package/coverage/auth.js.html +525 -525
  5. package/coverage/base.css +224 -224
  6. package/coverage/block-navigation.js +87 -87
  7. package/coverage/favicon.png +0 -0
  8. package/coverage/index.html +175 -175
  9. package/coverage/index.js.html +309 -309
  10. package/coverage/lcov-report/auth.js.html +525 -525
  11. package/coverage/lcov-report/base.css +224 -224
  12. package/coverage/lcov-report/block-navigation.js +87 -87
  13. package/coverage/lcov-report/favicon.png +0 -0
  14. package/coverage/lcov-report/index.html +175 -175
  15. package/coverage/lcov-report/index.js.html +309 -309
  16. package/coverage/lcov-report/listener.js.html +528 -528
  17. package/coverage/lcov-report/prettify.css +1 -1
  18. package/coverage/lcov-report/prettify.js +2 -2
  19. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  20. package/coverage/lcov-report/sorter.js +210 -210
  21. package/coverage/lcov-report/user.js.html +117 -117
  22. package/coverage/lcov-report/utils.js.html +117 -117
  23. package/coverage/lcov.info +391 -391
  24. package/coverage/listener.js.html +528 -528
  25. package/coverage/prettify.css +1 -1
  26. package/coverage/prettify.js +2 -2
  27. package/coverage/sort-arrow-sprite.png +0 -0
  28. package/coverage/sorter.js +210 -210
  29. package/coverage/user.js.html +117 -117
  30. package/coverage/utils.js.html +117 -117
  31. package/dist/gg.cjs.js +1 -1
  32. package/dist/gg.cjs.js.map +1 -1
  33. package/dist/gg.esm.js +1 -1
  34. package/dist/gg.esm.js.map +1 -1
  35. package/dist/gg.umd.js +1 -1
  36. package/dist/gg.umd.js.map +1 -1
  37. package/examples/certs/cert.pem +19 -19
  38. package/examples/certs/key.pem +28 -28
  39. package/examples/flight-dashboard.html +431 -431
  40. package/examples/server.js +99 -99
  41. package/examples/telemetry-validator.html +1410 -1410
  42. package/jest.config.cjs +33 -33
  43. package/package.json +56 -56
  44. package/rollup.config.js +57 -57
  45. package/src/auth.js +255 -255
  46. package/src/auth.spec.js +481 -481
  47. package/src/index.js +168 -168
  48. package/src/listener.js +196 -193
  49. package/src/listener.spec.js +598 -598
  50. package/src/presence_listener.js +112 -112
  51. package/src/test/fixtures.js +106 -106
  52. package/src/test/setup.js +51 -51
  53. package/src/utils.js +63 -63
  54. package/src/utils.spec.js +78 -78
  55. package/types/index.d.ts +338 -338
  56. package/webpack.config.js +15 -15
package/src/auth.spec.js CHANGED
@@ -1,481 +1,481 @@
1
- const { GameGlueAuth } = require('./auth');
2
- const { storage } = require('./utils');
3
- const {
4
- mockConfig,
5
- validAccessToken,
6
- expiredAccessToken,
7
- validRefreshToken,
8
- createMockJwt
9
- } = require('./test/fixtures');
10
-
11
- // Mock the oidc-client-ts module
12
- jest.mock('oidc-client-ts', () => ({
13
- OidcClient: jest.fn().mockImplementation(() => ({
14
- createSigninRequest: jest.fn().mockResolvedValue({ url: 'https://auth.example.com/login' }),
15
- processSigninResponse: jest.fn().mockResolvedValue({
16
- access_token: 'new-access-token',
17
- refresh_token: 'new-refresh-token'
18
- })
19
- }))
20
- }));
21
-
22
- // Mock fetch for token refresh
23
- global.fetch = jest.fn();
24
-
25
- // Suppress console output during tests
26
- const originalConsoleLog = console.log;
27
- const originalConsoleError = console.error;
28
-
29
- describe('GameGlueAuth', () => {
30
- let auth;
31
-
32
- beforeEach(() => {
33
- auth = new GameGlueAuth(mockConfig);
34
- jest.clearAllMocks();
35
- jest.useFakeTimers();
36
- // Suppress console output
37
- console.log = jest.fn();
38
- console.error = jest.fn();
39
- });
40
-
41
- afterEach(() => {
42
- // Restore console
43
- console.log = originalConsoleLog;
44
- console.error = originalConsoleError;
45
- jest.useRealTimers();
46
- // Clear storage
47
- storage.remove('gg-auth-token');
48
- storage.remove('gg-refresh-token');
49
- });
50
-
51
- describe('constructor', () => {
52
- it('should initialize with correct OIDC settings', () => {
53
- expect(auth._oidcSettings).toMatchObject({
54
- authority: 'https://auth.gameglue.gg/realms/GameGlue',
55
- client_id: 'test-client-id',
56
- redirect_uri: 'http://localhost:3000/callback',
57
- response_type: 'code',
58
- scope: 'openid msfs:read gg:broadcast',
59
- response_mode: 'fragment',
60
- filterProtocolClaims: true
61
- });
62
- // post_logout_redirect_uri is based on window.location.href which varies by environment
63
- expect(auth._oidcSettings.post_logout_redirect_uri).toBeDefined();
64
- });
65
-
66
- it('should use window.location.href as default redirect_uri', () => {
67
- const configNoRedirect = { clientId: 'test', scopes: [] };
68
- const authNoRedirect = new GameGlueAuth(configNoRedirect);
69
-
70
- // The redirect_uri should be based on window.location.href (varies by environment)
71
- expect(authNoRedirect._oidcSettings.redirect_uri).toMatch(/^http:\/\/localhost/);
72
- });
73
-
74
- it('should handle empty scopes array', () => {
75
- const configEmptyScopes = { clientId: 'test', scopes: [] };
76
- const authEmptyScopes = new GameGlueAuth(configEmptyScopes);
77
-
78
- expect(authEmptyScopes._oidcSettings.scope).toBe('openid ');
79
- });
80
-
81
- it('should handle undefined scopes', () => {
82
- const configNoScopes = { clientId: 'test' };
83
- const authNoScopes = new GameGlueAuth(configNoScopes);
84
-
85
- expect(authNoScopes._oidcSettings.scope).toBe('openid ');
86
- });
87
-
88
- it('should remove trailing slashes from redirect_uri', () => {
89
- const configWithSlash = {
90
- clientId: 'test',
91
- redirect_uri: 'http://localhost:3000/callback/'
92
- };
93
- const authWithSlash = new GameGlueAuth(configWithSlash);
94
-
95
- expect(authWithSlash._oidcSettings.redirect_uri).toBe('http://localhost:3000/callback');
96
- });
97
- });
98
-
99
- describe('_setAccessToken / getAccessToken', () => {
100
- it('should store and retrieve access token', () => {
101
- auth._setAccessToken(validAccessToken);
102
- const retrieved = auth.getAccessToken();
103
-
104
- expect(retrieved).toBe(validAccessToken);
105
- });
106
-
107
- it('should set up token refresh timeout when setting token', () => {
108
- auth._setAccessToken(validAccessToken);
109
-
110
- // Check that a timeout was scheduled
111
- expect(jest.getTimerCount()).toBeGreaterThan(0);
112
- });
113
-
114
- it('should not set timeout for null token', () => {
115
- auth._setAccessToken(null);
116
-
117
- expect(jest.getTimerCount()).toBe(0);
118
- });
119
- });
120
-
121
- describe('_setRefreshToken / _getRefreshToken', () => {
122
- it('should store and retrieve refresh token', () => {
123
- auth._setRefreshToken(validRefreshToken);
124
- const retrieved = auth._getRefreshToken();
125
-
126
- expect(retrieved).toBe(validRefreshToken);
127
- });
128
- });
129
-
130
- describe('getUser', () => {
131
- it('should extract user ID from JWT', () => {
132
- auth._setAccessToken(validAccessToken);
133
-
134
- const userId = auth.getUser();
135
-
136
- expect(userId).toBe('user-123');
137
- });
138
-
139
- it('should throw error when not authenticated', () => {
140
- expect(() => auth.getUser()).toThrow('Not authenticated');
141
- });
142
- });
143
-
144
- describe('_hasValidTokens', () => {
145
- it('should return false when no token exists', () => {
146
- const result = auth._hasValidTokens();
147
-
148
- expect(result).toBe(false);
149
- });
150
-
151
- it('should return true for valid non-expired token', () => {
152
- auth._setAccessToken(validAccessToken);
153
-
154
- const result = auth._hasValidTokens();
155
-
156
- expect(result).toBe(true);
157
- });
158
-
159
- it('should return false for expired token', () => {
160
- auth._setAccessToken(expiredAccessToken);
161
-
162
- const result = auth._hasValidTokens();
163
-
164
- expect(result).toBe(false);
165
- });
166
- });
167
-
168
- describe('isAuthenticated', () => {
169
- it('should return false when no token exists', async () => {
170
- const result = await auth.isAuthenticated();
171
-
172
- expect(result).toBe(false);
173
- });
174
-
175
- it('should return true for valid non-expired token', async () => {
176
- auth._setAccessToken(validAccessToken);
177
-
178
- const result = await auth.isAuthenticated();
179
-
180
- expect(result).toBe(true);
181
- });
182
-
183
- it('should return false for expired token', async () => {
184
- auth._setAccessToken(expiredAccessToken);
185
-
186
- const result = await auth.isAuthenticated();
187
-
188
- expect(result).toBe(false);
189
- });
190
-
191
- it('should process callback params if present in URL', async () => {
192
- window.location.hash = '#state=abc123&code=xyz789';
193
-
194
- const { OidcClient } = require('oidc-client-ts');
195
- OidcClient.mockImplementation(() => ({
196
- createSigninRequest: jest.fn(),
197
- processSigninResponse: jest.fn().mockResolvedValue({
198
- access_token: validAccessToken,
199
- refresh_token: validRefreshToken
200
- })
201
- }));
202
-
203
- const authNew = new GameGlueAuth(mockConfig);
204
- const result = await authNew.isAuthenticated();
205
-
206
- expect(result).toBe(true);
207
- // URL should be cleared
208
- expect(window.history.replaceState).toHaveBeenCalled();
209
- });
210
- });
211
-
212
- describe('login', () => {
213
- it('should call createSigninRequest', async () => {
214
- const createSigninRequest = jest.fn().mockResolvedValue({ url: 'https://auth.example.com/login' });
215
- const { OidcClient } = require('oidc-client-ts');
216
- OidcClient.mockImplementation(() => ({
217
- createSigninRequest,
218
- processSigninResponse: jest.fn()
219
- }));
220
-
221
- const authNew = new GameGlueAuth(mockConfig);
222
- authNew.login();
223
-
224
- // Wait for promise to resolve
225
- await Promise.resolve();
226
-
227
- expect(createSigninRequest).toHaveBeenCalledWith({ state: { bar: 15 } });
228
- });
229
- });
230
-
231
- describe('logout', () => {
232
- it('should clear tokens from storage', () => {
233
- auth._setAccessToken(validAccessToken);
234
- auth._setRefreshToken(validRefreshToken);
235
-
236
- auth.logout({ redirect: false });
237
-
238
- // After remove, storage.get returns undefined in Node environment (in-memory map)
239
- expect(auth.getAccessToken()).toBeUndefined();
240
- expect(auth._getRefreshToken()).toBeUndefined();
241
- });
242
-
243
- it('should not clear tokens when redirect: false not passed', () => {
244
- // We can't easily test the redirect behavior in jsdom, but we can verify
245
- // the method accepts options and clears tokens regardless
246
- auth._setAccessToken(validAccessToken);
247
- auth._setRefreshToken(validRefreshToken);
248
-
249
- // logout() clears tokens first, then redirects
250
- auth.logout({ redirect: false });
251
-
252
- expect(auth.getAccessToken()).toBeUndefined();
253
- });
254
-
255
- it('should accept options object', () => {
256
- auth._setAccessToken(validAccessToken);
257
-
258
- // Should not throw when called with different options
259
- expect(() => auth.logout({ redirect: false })).not.toThrow();
260
- });
261
- });
262
-
263
- describe('_attemptRefresh', () => {
264
- it('should call token endpoint with correct parameters', async () => {
265
- auth._setRefreshToken(validRefreshToken);
266
-
267
- global.fetch.mockResolvedValueOnce({
268
- status: 200,
269
- json: () =>
270
- Promise.resolve({
271
- access_token: validAccessToken,
272
- refresh_token: validRefreshToken
273
- })
274
- });
275
-
276
- await auth._attemptRefresh();
277
-
278
- expect(global.fetch).toHaveBeenCalledWith(
279
- 'https://auth.gameglue.gg/realms/GameGlue/protocol/openid-connect/token',
280
- expect.objectContaining({
281
- method: 'POST',
282
- headers: {
283
- 'Content-Type': 'application/x-www-form-urlencoded'
284
- }
285
- })
286
- );
287
- });
288
-
289
- it('should update tokens on successful refresh', async () => {
290
- auth._setRefreshToken(validRefreshToken);
291
-
292
- const newAccessToken = createMockJwt({ sub: 'user-456' }, 3600);
293
- const newRefreshToken = createMockJwt({ sub: 'user-456', type: 'refresh' }, 86400);
294
-
295
- global.fetch.mockResolvedValueOnce({
296
- status: 200,
297
- json: () =>
298
- Promise.resolve({
299
- access_token: newAccessToken,
300
- refresh_token: newRefreshToken
301
- })
302
- });
303
-
304
- await auth._attemptRefresh();
305
-
306
- expect(auth.getAccessToken()).toBe(newAccessToken);
307
- expect(auth._getRefreshToken()).toBe(newRefreshToken);
308
- });
309
-
310
- it('should call refresh callback with access token on successful refresh', async () => {
311
- const callback = jest.fn();
312
- auth.onTokenRefreshed(callback);
313
- auth._setRefreshToken(validRefreshToken);
314
-
315
- const responseData = {
316
- access_token: validAccessToken,
317
- refresh_token: validRefreshToken
318
- };
319
-
320
- global.fetch.mockResolvedValueOnce({
321
- status: 200,
322
- json: () => Promise.resolve(responseData)
323
- });
324
-
325
- await auth._attemptRefresh();
326
-
327
- expect(callback).toHaveBeenCalledWith(validAccessToken);
328
- });
329
-
330
- it('should handle network errors gracefully', async () => {
331
- auth._setRefreshToken(validRefreshToken);
332
- global.fetch.mockRejectedValueOnce(new Error('Network error'));
333
-
334
- // Should not throw
335
- await expect(auth._attemptRefresh()).resolves.toBeUndefined();
336
- });
337
-
338
- it('should not update tokens on non-200 response', async () => {
339
- auth._setRefreshToken(validRefreshToken);
340
- const originalAccessToken = auth.getAccessToken();
341
-
342
- global.fetch.mockResolvedValueOnce({
343
- status: 400,
344
- json: () => Promise.resolve({ error: 'invalid_grant' })
345
- });
346
-
347
- await auth._attemptRefresh();
348
-
349
- // Access token should remain unchanged (null in this case)
350
- expect(auth.getAccessToken()).toBe(originalAccessToken);
351
- });
352
- });
353
-
354
- describe('onTokenRefreshed', () => {
355
- it('should register callback for token refresh', () => {
356
- const callback = jest.fn();
357
- auth.onTokenRefreshed(callback);
358
-
359
- expect(auth._refreshCallback).toBe(callback);
360
- });
361
- });
362
-
363
- describe('_setTokenRefreshTimeout', () => {
364
- it('should schedule refresh before token expiration', () => {
365
- // Token expires in 1 hour (3600 seconds)
366
- auth._setTokenRefreshTimeout(validAccessToken);
367
-
368
- // Should have scheduled a timeout
369
- expect(jest.getTimerCount()).toBe(1);
370
- });
371
-
372
- it('should clear previous timeout when setting new one', () => {
373
- auth._setTokenRefreshTimeout(validAccessToken);
374
- auth._setTokenRefreshTimeout(validAccessToken);
375
-
376
- // Should only have one active timeout
377
- expect(jest.getTimerCount()).toBe(1);
378
- });
379
-
380
- it('should not schedule timeout for expired token', () => {
381
- auth._setTokenRefreshTimeout(expiredAccessToken);
382
-
383
- // Timer count may be 0 since the timeUntilExp is negative
384
- // The important thing is it doesn't throw
385
- });
386
- });
387
-
388
- describe('_hasCallbackParams', () => {
389
- it('should return true when hash contains state and code', () => {
390
- window.location.hash = '#state=abc123&code=xyz789';
391
-
392
- expect(auth._hasCallbackParams()).toBe(true);
393
- });
394
-
395
- it('should return true when hash contains state and error', () => {
396
- window.location.hash = '#state=abc123&error=access_denied';
397
-
398
- expect(auth._hasCallbackParams()).toBe(true);
399
- });
400
-
401
- it('should return false when hash does not contain state', () => {
402
- window.location.hash = '#code=xyz789';
403
-
404
- expect(auth._hasCallbackParams()).toBe(false);
405
- });
406
-
407
- it('should return false when hash is empty', () => {
408
- window.location.hash = '';
409
-
410
- expect(auth._hasCallbackParams()).toBe(false);
411
- });
412
- });
413
-
414
- describe('_processCallback', () => {
415
- it('should process signin response and store tokens', async () => {
416
- const { OidcClient } = require('oidc-client-ts');
417
- OidcClient.mockImplementation(() => ({
418
- processSigninResponse: jest.fn().mockResolvedValue({
419
- access_token: validAccessToken,
420
- refresh_token: validRefreshToken
421
- })
422
- }));
423
-
424
- const authNew = new GameGlueAuth(mockConfig);
425
- await authNew._processCallback();
426
-
427
- expect(authNew.getAccessToken()).toBe(validAccessToken);
428
- expect(authNew._getRefreshToken()).toBe(validRefreshToken);
429
- expect(window.history.replaceState).toHaveBeenCalled();
430
- });
431
-
432
- it('should throw on error response', async () => {
433
- const { OidcClient } = require('oidc-client-ts');
434
- OidcClient.mockImplementation(() => ({
435
- processSigninResponse: jest.fn().mockResolvedValue({
436
- error: 'access_denied',
437
- access_token: null
438
- })
439
- }));
440
-
441
- const authNew = new GameGlueAuth(mockConfig);
442
-
443
- await expect(authNew._processCallback()).rejects.toThrow('access_denied');
444
- });
445
-
446
- it('should throw when no access token received', async () => {
447
- const { OidcClient } = require('oidc-client-ts');
448
- OidcClient.mockImplementation(() => ({
449
- processSigninResponse: jest.fn().mockResolvedValue({
450
- access_token: null
451
- })
452
- }));
453
-
454
- const authNew = new GameGlueAuth(mockConfig);
455
-
456
- await expect(authNew._processCallback()).rejects.toThrow('No access token received');
457
- });
458
-
459
- it('should handle concurrent calls (only process once)', async () => {
460
- const { OidcClient } = require('oidc-client-ts');
461
- const processSigninResponse = jest.fn().mockResolvedValue({
462
- access_token: validAccessToken,
463
- refresh_token: validRefreshToken
464
- });
465
- OidcClient.mockImplementation(() => ({
466
- processSigninResponse
467
- }));
468
-
469
- const authNew = new GameGlueAuth(mockConfig);
470
-
471
- // Call twice concurrently
472
- await Promise.all([
473
- authNew._processCallback(),
474
- authNew._processCallback()
475
- ]);
476
-
477
- // Should only have been called once due to locking
478
- expect(processSigninResponse).toHaveBeenCalledTimes(1);
479
- });
480
- });
481
- });
1
+ const { GameGlueAuth } = require('./auth');
2
+ const { storage } = require('./utils');
3
+ const {
4
+ mockConfig,
5
+ validAccessToken,
6
+ expiredAccessToken,
7
+ validRefreshToken,
8
+ createMockJwt
9
+ } = require('./test/fixtures');
10
+
11
+ // Mock the oidc-client-ts module
12
+ jest.mock('oidc-client-ts', () => ({
13
+ OidcClient: jest.fn().mockImplementation(() => ({
14
+ createSigninRequest: jest.fn().mockResolvedValue({ url: 'https://auth.example.com/login' }),
15
+ processSigninResponse: jest.fn().mockResolvedValue({
16
+ access_token: 'new-access-token',
17
+ refresh_token: 'new-refresh-token'
18
+ })
19
+ }))
20
+ }));
21
+
22
+ // Mock fetch for token refresh
23
+ global.fetch = jest.fn();
24
+
25
+ // Suppress console output during tests
26
+ const originalConsoleLog = console.log;
27
+ const originalConsoleError = console.error;
28
+
29
+ describe('GameGlueAuth', () => {
30
+ let auth;
31
+
32
+ beforeEach(() => {
33
+ auth = new GameGlueAuth(mockConfig);
34
+ jest.clearAllMocks();
35
+ jest.useFakeTimers();
36
+ // Suppress console output
37
+ console.log = jest.fn();
38
+ console.error = jest.fn();
39
+ });
40
+
41
+ afterEach(() => {
42
+ // Restore console
43
+ console.log = originalConsoleLog;
44
+ console.error = originalConsoleError;
45
+ jest.useRealTimers();
46
+ // Clear storage
47
+ storage.remove('gg-auth-token');
48
+ storage.remove('gg-refresh-token');
49
+ });
50
+
51
+ describe('constructor', () => {
52
+ it('should initialize with correct OIDC settings', () => {
53
+ expect(auth._oidcSettings).toMatchObject({
54
+ authority: 'https://auth.gameglue.gg/realms/GameGlue',
55
+ client_id: 'test-client-id',
56
+ redirect_uri: 'http://localhost:3000/callback',
57
+ response_type: 'code',
58
+ scope: 'openid msfs:read gg:broadcast',
59
+ response_mode: 'fragment',
60
+ filterProtocolClaims: true
61
+ });
62
+ // post_logout_redirect_uri is based on window.location.href which varies by environment
63
+ expect(auth._oidcSettings.post_logout_redirect_uri).toBeDefined();
64
+ });
65
+
66
+ it('should use window.location.href as default redirect_uri', () => {
67
+ const configNoRedirect = { clientId: 'test', scopes: [] };
68
+ const authNoRedirect = new GameGlueAuth(configNoRedirect);
69
+
70
+ // The redirect_uri should be based on window.location.href (varies by environment)
71
+ expect(authNoRedirect._oidcSettings.redirect_uri).toMatch(/^http:\/\/localhost/);
72
+ });
73
+
74
+ it('should handle empty scopes array', () => {
75
+ const configEmptyScopes = { clientId: 'test', scopes: [] };
76
+ const authEmptyScopes = new GameGlueAuth(configEmptyScopes);
77
+
78
+ expect(authEmptyScopes._oidcSettings.scope).toBe('openid ');
79
+ });
80
+
81
+ it('should handle undefined scopes', () => {
82
+ const configNoScopes = { clientId: 'test' };
83
+ const authNoScopes = new GameGlueAuth(configNoScopes);
84
+
85
+ expect(authNoScopes._oidcSettings.scope).toBe('openid ');
86
+ });
87
+
88
+ it('should remove trailing slashes from redirect_uri', () => {
89
+ const configWithSlash = {
90
+ clientId: 'test',
91
+ redirect_uri: 'http://localhost:3000/callback/'
92
+ };
93
+ const authWithSlash = new GameGlueAuth(configWithSlash);
94
+
95
+ expect(authWithSlash._oidcSettings.redirect_uri).toBe('http://localhost:3000/callback');
96
+ });
97
+ });
98
+
99
+ describe('_setAccessToken / getAccessToken', () => {
100
+ it('should store and retrieve access token', () => {
101
+ auth._setAccessToken(validAccessToken);
102
+ const retrieved = auth.getAccessToken();
103
+
104
+ expect(retrieved).toBe(validAccessToken);
105
+ });
106
+
107
+ it('should set up token refresh timeout when setting token', () => {
108
+ auth._setAccessToken(validAccessToken);
109
+
110
+ // Check that a timeout was scheduled
111
+ expect(jest.getTimerCount()).toBeGreaterThan(0);
112
+ });
113
+
114
+ it('should not set timeout for null token', () => {
115
+ auth._setAccessToken(null);
116
+
117
+ expect(jest.getTimerCount()).toBe(0);
118
+ });
119
+ });
120
+
121
+ describe('_setRefreshToken / _getRefreshToken', () => {
122
+ it('should store and retrieve refresh token', () => {
123
+ auth._setRefreshToken(validRefreshToken);
124
+ const retrieved = auth._getRefreshToken();
125
+
126
+ expect(retrieved).toBe(validRefreshToken);
127
+ });
128
+ });
129
+
130
+ describe('getUser', () => {
131
+ it('should extract user ID from JWT', () => {
132
+ auth._setAccessToken(validAccessToken);
133
+
134
+ const userId = auth.getUser();
135
+
136
+ expect(userId).toBe('user-123');
137
+ });
138
+
139
+ it('should throw error when not authenticated', () => {
140
+ expect(() => auth.getUser()).toThrow('Not authenticated');
141
+ });
142
+ });
143
+
144
+ describe('_hasValidTokens', () => {
145
+ it('should return false when no token exists', () => {
146
+ const result = auth._hasValidTokens();
147
+
148
+ expect(result).toBe(false);
149
+ });
150
+
151
+ it('should return true for valid non-expired token', () => {
152
+ auth._setAccessToken(validAccessToken);
153
+
154
+ const result = auth._hasValidTokens();
155
+
156
+ expect(result).toBe(true);
157
+ });
158
+
159
+ it('should return false for expired token', () => {
160
+ auth._setAccessToken(expiredAccessToken);
161
+
162
+ const result = auth._hasValidTokens();
163
+
164
+ expect(result).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe('isAuthenticated', () => {
169
+ it('should return false when no token exists', async () => {
170
+ const result = await auth.isAuthenticated();
171
+
172
+ expect(result).toBe(false);
173
+ });
174
+
175
+ it('should return true for valid non-expired token', async () => {
176
+ auth._setAccessToken(validAccessToken);
177
+
178
+ const result = await auth.isAuthenticated();
179
+
180
+ expect(result).toBe(true);
181
+ });
182
+
183
+ it('should return false for expired token', async () => {
184
+ auth._setAccessToken(expiredAccessToken);
185
+
186
+ const result = await auth.isAuthenticated();
187
+
188
+ expect(result).toBe(false);
189
+ });
190
+
191
+ it('should process callback params if present in URL', async () => {
192
+ window.location.hash = '#state=abc123&code=xyz789';
193
+
194
+ const { OidcClient } = require('oidc-client-ts');
195
+ OidcClient.mockImplementation(() => ({
196
+ createSigninRequest: jest.fn(),
197
+ processSigninResponse: jest.fn().mockResolvedValue({
198
+ access_token: validAccessToken,
199
+ refresh_token: validRefreshToken
200
+ })
201
+ }));
202
+
203
+ const authNew = new GameGlueAuth(mockConfig);
204
+ const result = await authNew.isAuthenticated();
205
+
206
+ expect(result).toBe(true);
207
+ // URL should be cleared
208
+ expect(window.history.replaceState).toHaveBeenCalled();
209
+ });
210
+ });
211
+
212
+ describe('login', () => {
213
+ it('should call createSigninRequest', async () => {
214
+ const createSigninRequest = jest.fn().mockResolvedValue({ url: 'https://auth.example.com/login' });
215
+ const { OidcClient } = require('oidc-client-ts');
216
+ OidcClient.mockImplementation(() => ({
217
+ createSigninRequest,
218
+ processSigninResponse: jest.fn()
219
+ }));
220
+
221
+ const authNew = new GameGlueAuth(mockConfig);
222
+ authNew.login();
223
+
224
+ // Wait for promise to resolve
225
+ await Promise.resolve();
226
+
227
+ expect(createSigninRequest).toHaveBeenCalledWith({ state: { bar: 15 } });
228
+ });
229
+ });
230
+
231
+ describe('logout', () => {
232
+ it('should clear tokens from storage', () => {
233
+ auth._setAccessToken(validAccessToken);
234
+ auth._setRefreshToken(validRefreshToken);
235
+
236
+ auth.logout({ redirect: false });
237
+
238
+ // After remove, storage.get returns undefined in Node environment (in-memory map)
239
+ expect(auth.getAccessToken()).toBeUndefined();
240
+ expect(auth._getRefreshToken()).toBeUndefined();
241
+ });
242
+
243
+ it('should not clear tokens when redirect: false not passed', () => {
244
+ // We can't easily test the redirect behavior in jsdom, but we can verify
245
+ // the method accepts options and clears tokens regardless
246
+ auth._setAccessToken(validAccessToken);
247
+ auth._setRefreshToken(validRefreshToken);
248
+
249
+ // logout() clears tokens first, then redirects
250
+ auth.logout({ redirect: false });
251
+
252
+ expect(auth.getAccessToken()).toBeUndefined();
253
+ });
254
+
255
+ it('should accept options object', () => {
256
+ auth._setAccessToken(validAccessToken);
257
+
258
+ // Should not throw when called with different options
259
+ expect(() => auth.logout({ redirect: false })).not.toThrow();
260
+ });
261
+ });
262
+
263
+ describe('_attemptRefresh', () => {
264
+ it('should call token endpoint with correct parameters', async () => {
265
+ auth._setRefreshToken(validRefreshToken);
266
+
267
+ global.fetch.mockResolvedValueOnce({
268
+ status: 200,
269
+ json: () =>
270
+ Promise.resolve({
271
+ access_token: validAccessToken,
272
+ refresh_token: validRefreshToken
273
+ })
274
+ });
275
+
276
+ await auth._attemptRefresh();
277
+
278
+ expect(global.fetch).toHaveBeenCalledWith(
279
+ 'https://auth.gameglue.gg/realms/GameGlue/protocol/openid-connect/token',
280
+ expect.objectContaining({
281
+ method: 'POST',
282
+ headers: {
283
+ 'Content-Type': 'application/x-www-form-urlencoded'
284
+ }
285
+ })
286
+ );
287
+ });
288
+
289
+ it('should update tokens on successful refresh', async () => {
290
+ auth._setRefreshToken(validRefreshToken);
291
+
292
+ const newAccessToken = createMockJwt({ sub: 'user-456' }, 3600);
293
+ const newRefreshToken = createMockJwt({ sub: 'user-456', type: 'refresh' }, 86400);
294
+
295
+ global.fetch.mockResolvedValueOnce({
296
+ status: 200,
297
+ json: () =>
298
+ Promise.resolve({
299
+ access_token: newAccessToken,
300
+ refresh_token: newRefreshToken
301
+ })
302
+ });
303
+
304
+ await auth._attemptRefresh();
305
+
306
+ expect(auth.getAccessToken()).toBe(newAccessToken);
307
+ expect(auth._getRefreshToken()).toBe(newRefreshToken);
308
+ });
309
+
310
+ it('should call refresh callback with access token on successful refresh', async () => {
311
+ const callback = jest.fn();
312
+ auth.onTokenRefreshed(callback);
313
+ auth._setRefreshToken(validRefreshToken);
314
+
315
+ const responseData = {
316
+ access_token: validAccessToken,
317
+ refresh_token: validRefreshToken
318
+ };
319
+
320
+ global.fetch.mockResolvedValueOnce({
321
+ status: 200,
322
+ json: () => Promise.resolve(responseData)
323
+ });
324
+
325
+ await auth._attemptRefresh();
326
+
327
+ expect(callback).toHaveBeenCalledWith(validAccessToken);
328
+ });
329
+
330
+ it('should handle network errors gracefully', async () => {
331
+ auth._setRefreshToken(validRefreshToken);
332
+ global.fetch.mockRejectedValueOnce(new Error('Network error'));
333
+
334
+ // Should not throw
335
+ await expect(auth._attemptRefresh()).resolves.toBeUndefined();
336
+ });
337
+
338
+ it('should not update tokens on non-200 response', async () => {
339
+ auth._setRefreshToken(validRefreshToken);
340
+ const originalAccessToken = auth.getAccessToken();
341
+
342
+ global.fetch.mockResolvedValueOnce({
343
+ status: 400,
344
+ json: () => Promise.resolve({ error: 'invalid_grant' })
345
+ });
346
+
347
+ await auth._attemptRefresh();
348
+
349
+ // Access token should remain unchanged (null in this case)
350
+ expect(auth.getAccessToken()).toBe(originalAccessToken);
351
+ });
352
+ });
353
+
354
+ describe('onTokenRefreshed', () => {
355
+ it('should register callback for token refresh', () => {
356
+ const callback = jest.fn();
357
+ auth.onTokenRefreshed(callback);
358
+
359
+ expect(auth._refreshCallback).toBe(callback);
360
+ });
361
+ });
362
+
363
+ describe('_setTokenRefreshTimeout', () => {
364
+ it('should schedule refresh before token expiration', () => {
365
+ // Token expires in 1 hour (3600 seconds)
366
+ auth._setTokenRefreshTimeout(validAccessToken);
367
+
368
+ // Should have scheduled a timeout
369
+ expect(jest.getTimerCount()).toBe(1);
370
+ });
371
+
372
+ it('should clear previous timeout when setting new one', () => {
373
+ auth._setTokenRefreshTimeout(validAccessToken);
374
+ auth._setTokenRefreshTimeout(validAccessToken);
375
+
376
+ // Should only have one active timeout
377
+ expect(jest.getTimerCount()).toBe(1);
378
+ });
379
+
380
+ it('should not schedule timeout for expired token', () => {
381
+ auth._setTokenRefreshTimeout(expiredAccessToken);
382
+
383
+ // Timer count may be 0 since the timeUntilExp is negative
384
+ // The important thing is it doesn't throw
385
+ });
386
+ });
387
+
388
+ describe('_hasCallbackParams', () => {
389
+ it('should return true when hash contains state and code', () => {
390
+ window.location.hash = '#state=abc123&code=xyz789';
391
+
392
+ expect(auth._hasCallbackParams()).toBe(true);
393
+ });
394
+
395
+ it('should return true when hash contains state and error', () => {
396
+ window.location.hash = '#state=abc123&error=access_denied';
397
+
398
+ expect(auth._hasCallbackParams()).toBe(true);
399
+ });
400
+
401
+ it('should return false when hash does not contain state', () => {
402
+ window.location.hash = '#code=xyz789';
403
+
404
+ expect(auth._hasCallbackParams()).toBe(false);
405
+ });
406
+
407
+ it('should return false when hash is empty', () => {
408
+ window.location.hash = '';
409
+
410
+ expect(auth._hasCallbackParams()).toBe(false);
411
+ });
412
+ });
413
+
414
+ describe('_processCallback', () => {
415
+ it('should process signin response and store tokens', async () => {
416
+ const { OidcClient } = require('oidc-client-ts');
417
+ OidcClient.mockImplementation(() => ({
418
+ processSigninResponse: jest.fn().mockResolvedValue({
419
+ access_token: validAccessToken,
420
+ refresh_token: validRefreshToken
421
+ })
422
+ }));
423
+
424
+ const authNew = new GameGlueAuth(mockConfig);
425
+ await authNew._processCallback();
426
+
427
+ expect(authNew.getAccessToken()).toBe(validAccessToken);
428
+ expect(authNew._getRefreshToken()).toBe(validRefreshToken);
429
+ expect(window.history.replaceState).toHaveBeenCalled();
430
+ });
431
+
432
+ it('should throw on error response', async () => {
433
+ const { OidcClient } = require('oidc-client-ts');
434
+ OidcClient.mockImplementation(() => ({
435
+ processSigninResponse: jest.fn().mockResolvedValue({
436
+ error: 'access_denied',
437
+ access_token: null
438
+ })
439
+ }));
440
+
441
+ const authNew = new GameGlueAuth(mockConfig);
442
+
443
+ await expect(authNew._processCallback()).rejects.toThrow('access_denied');
444
+ });
445
+
446
+ it('should throw when no access token received', async () => {
447
+ const { OidcClient } = require('oidc-client-ts');
448
+ OidcClient.mockImplementation(() => ({
449
+ processSigninResponse: jest.fn().mockResolvedValue({
450
+ access_token: null
451
+ })
452
+ }));
453
+
454
+ const authNew = new GameGlueAuth(mockConfig);
455
+
456
+ await expect(authNew._processCallback()).rejects.toThrow('No access token received');
457
+ });
458
+
459
+ it('should handle concurrent calls (only process once)', async () => {
460
+ const { OidcClient } = require('oidc-client-ts');
461
+ const processSigninResponse = jest.fn().mockResolvedValue({
462
+ access_token: validAccessToken,
463
+ refresh_token: validRefreshToken
464
+ });
465
+ OidcClient.mockImplementation(() => ({
466
+ processSigninResponse
467
+ }));
468
+
469
+ const authNew = new GameGlueAuth(mockConfig);
470
+
471
+ // Call twice concurrently
472
+ await Promise.all([
473
+ authNew._processCallback(),
474
+ authNew._processCallback()
475
+ ]);
476
+
477
+ // Should only have been called once due to locking
478
+ expect(processSigninResponse).toHaveBeenCalledTimes(1);
479
+ });
480
+ });
481
+ });