strapi-plugin-keycloak-realm-users 1.0.0

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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +485 -0
  3. package/__tests__/constants.test.mjs +207 -0
  4. package/__tests__/mocks/strapi.mjs +182 -0
  5. package/__tests__/services/audit-log.test.mjs +283 -0
  6. package/__tests__/services/keycloak-client.test.mjs +651 -0
  7. package/__tests__/services/permission.test.mjs +374 -0
  8. package/__tests__/services/realm.test.mjs +415 -0
  9. package/__tests__/services/user.test.mjs +487 -0
  10. package/__tests__/utils/errors.test.mjs +109 -0
  11. package/admin/src/components/Initializer.jsx +14 -0
  12. package/admin/src/components/RealmBadge.jsx +17 -0
  13. package/admin/src/constants.js +14 -0
  14. package/admin/src/hooks/useAuditLogs.js +142 -0
  15. package/admin/src/hooks/useKeycloakRoles.js +182 -0
  16. package/admin/src/hooks/useKeycloakUsers.js +477 -0
  17. package/admin/src/hooks/useRealmAdmins.js +249 -0
  18. package/admin/src/hooks/useRealms.js +269 -0
  19. package/admin/src/index.js +46 -0
  20. package/admin/src/pages/App.jsx +21 -0
  21. package/admin/src/pages/AuditPage/index.jsx +213 -0
  22. package/admin/src/pages/RealmsPage/RealmEditPage.jsx +791 -0
  23. package/admin/src/pages/RealmsPage/RealmListPage.jsx +231 -0
  24. package/admin/src/pages/RealmsPage/index.jsx +7 -0
  25. package/admin/src/pages/UsersPage/UserEditPage.jsx +313 -0
  26. package/admin/src/pages/UsersPage/UserListPage.jsx +437 -0
  27. package/admin/src/pages/UsersPage/index.jsx +7 -0
  28. package/admin/src/pluginId.js +2 -0
  29. package/admin/src/translations/en.json +77 -0
  30. package/admin/src/translations/fr.json +77 -0
  31. package/babel.config.cjs +17 -0
  32. package/coverage/clover.xml +422 -0
  33. package/coverage/coverage-final.json +8 -0
  34. package/coverage/lcov-report/base.css +224 -0
  35. package/coverage/lcov-report/block-navigation.js +87 -0
  36. package/coverage/lcov-report/favicon.png +0 -0
  37. package/coverage/lcov-report/index.html +146 -0
  38. package/coverage/lcov-report/prettify.css +1 -0
  39. package/coverage/lcov-report/prettify.js +2 -0
  40. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  41. package/coverage/lcov-report/sorter.js +210 -0
  42. package/coverage/lcov-report/src/bootstrap.js.html +346 -0
  43. package/coverage/lcov-report/src/config/index.html +116 -0
  44. package/coverage/lcov-report/src/config/index.js.html +106 -0
  45. package/coverage/lcov-report/src/constants.js.html +850 -0
  46. package/coverage/lcov-report/src/content-types/audit-log/index.html +116 -0
  47. package/coverage/lcov-report/src/content-types/audit-log/index.js.html +94 -0
  48. package/coverage/lcov-report/src/content-types/index.html +116 -0
  49. package/coverage/lcov-report/src/content-types/index.js.html +112 -0
  50. package/coverage/lcov-report/src/content-types/realm-admin/index.html +116 -0
  51. package/coverage/lcov-report/src/content-types/realm-admin/index.js.html +94 -0
  52. package/coverage/lcov-report/src/content-types/realm-config/index.html +116 -0
  53. package/coverage/lcov-report/src/content-types/realm-config/index.js.html +94 -0
  54. package/coverage/lcov-report/src/controllers/audit.js.html +517 -0
  55. package/coverage/lcov-report/src/controllers/index.html +161 -0
  56. package/coverage/lcov-report/src/controllers/index.js.html +112 -0
  57. package/coverage/lcov-report/src/controllers/realm.js.html +1057 -0
  58. package/coverage/lcov-report/src/controllers/user.js.html +1324 -0
  59. package/coverage/lcov-report/src/destroy.js.html +100 -0
  60. package/coverage/lcov-report/src/index.html +116 -0
  61. package/coverage/lcov-report/src/policies/can-access-realm.js.html +163 -0
  62. package/coverage/lcov-report/src/policies/index.html +146 -0
  63. package/coverage/lcov-report/src/policies/index.js.html +106 -0
  64. package/coverage/lcov-report/src/policies/is-authenticated.js.html +100 -0
  65. package/coverage/lcov-report/src/register.js.html +106 -0
  66. package/coverage/lcov-report/src/routes/admin.js.html +844 -0
  67. package/coverage/lcov-report/src/routes/index.html +131 -0
  68. package/coverage/lcov-report/src/routes/index.js.html +109 -0
  69. package/coverage/lcov-report/src/services/audit-log.js.html +673 -0
  70. package/coverage/lcov-report/src/services/index.html +176 -0
  71. package/coverage/lcov-report/src/services/index.js.html +124 -0
  72. package/coverage/lcov-report/src/services/keycloak-client.js.html +2359 -0
  73. package/coverage/lcov-report/src/services/permission.js.html +955 -0
  74. package/coverage/lcov-report/src/services/realm.js.html +1207 -0
  75. package/coverage/lcov-report/src/services/user.js.html +1924 -0
  76. package/coverage/lcov-report/src/utils/errors.js.html +274 -0
  77. package/coverage/lcov-report/src/utils/index.html +116 -0
  78. package/coverage/lcov-report/src/utils/index.js.html +103 -0
  79. package/coverage/lcov.info +804 -0
  80. package/dist/_chunks/App-BaKrvCeS.mjs +1975 -0
  81. package/dist/_chunks/App-DO6syS77.js +1975 -0
  82. package/dist/_chunks/en-Li-XBDe9.mjs +72 -0
  83. package/dist/_chunks/en-aCyfgNfr.js +72 -0
  84. package/dist/_chunks/fr-Cj33Q8jI.js +72 -0
  85. package/dist/_chunks/fr-vLrXph-Z.mjs +72 -0
  86. package/dist/_chunks/index-DwDO4-0C.js +69 -0
  87. package/dist/_chunks/index-jTVd7LdQ.mjs +70 -0
  88. package/dist/admin/index.js +3 -0
  89. package/dist/admin/index.mjs +4 -0
  90. package/dist/server/index.js +3003 -0
  91. package/dist/server/index.mjs +3004 -0
  92. package/jest.config.cjs +50 -0
  93. package/package.json +55 -0
  94. package/server/src/bootstrap.js +87 -0
  95. package/server/src/config/index.js +7 -0
  96. package/server/src/constants.js +255 -0
  97. package/server/src/content-types/audit-log/index.js +3 -0
  98. package/server/src/content-types/audit-log/schema.json +61 -0
  99. package/server/src/content-types/index.js +9 -0
  100. package/server/src/content-types/realm-admin/index.js +3 -0
  101. package/server/src/content-types/realm-admin/schema.json +45 -0
  102. package/server/src/content-types/realm-config/index.js +3 -0
  103. package/server/src/content-types/realm-config/schema.json +56 -0
  104. package/server/src/controllers/audit.js +144 -0
  105. package/server/src/controllers/index.js +9 -0
  106. package/server/src/controllers/realm.js +324 -0
  107. package/server/src/controllers/user.js +413 -0
  108. package/server/src/destroy.js +5 -0
  109. package/server/src/index.js +21 -0
  110. package/server/src/policies/can-access-realm.js +26 -0
  111. package/server/src/policies/index.js +7 -0
  112. package/server/src/policies/is-authenticated.js +5 -0
  113. package/server/src/register.js +7 -0
  114. package/server/src/routes/admin.js +253 -0
  115. package/server/src/routes/index.js +8 -0
  116. package/server/src/services/audit-log.js +196 -0
  117. package/server/src/services/index.js +13 -0
  118. package/server/src/services/keycloak-client.js +758 -0
  119. package/server/src/services/permission.js +290 -0
  120. package/server/src/services/realm.js +374 -0
  121. package/server/src/services/user.js +613 -0
  122. package/server/src/utils/errors.js +63 -0
  123. package/server/src/utils/index.js +6 -0
@@ -0,0 +1,651 @@
1
+ /**
2
+ * @fileoverview Unit tests for the Keycloak client service.
3
+ * @module __tests__/services/keycloak-client
4
+ */
5
+
6
+ import keycloakClientService from '../../server/src/services/keycloak-client.js';
7
+ import { createMockStrapi, createMockRealmConfig, createMockKeycloakUser } from '../mocks/strapi.mjs';
8
+ import { ERROR_MESSAGES, HTTP_STATUS } from '../../server/src/constants.js';
9
+
10
+ // Mock global fetch
11
+ const mockFetch = jest.fn();
12
+ global.fetch = mockFetch;
13
+
14
+ describe('Keycloak Client Service', () => {
15
+ let strapi;
16
+ let service;
17
+ let realmConfig;
18
+
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ strapi = createMockStrapi();
22
+ service = keycloakClientService({ strapi });
23
+ realmConfig = createMockRealmConfig();
24
+ service.clearTokenCache(); // Clear token cache between tests
25
+ });
26
+
27
+ /**
28
+ * Helper to create a mock fetch response
29
+ */
30
+ const mockFetchResponse = (data, options = {}) => {
31
+ const { ok = true, status = 200, headers = {} } = options;
32
+ return Promise.resolve({
33
+ ok,
34
+ status,
35
+ statusText: ok ? 'OK' : 'Error',
36
+ json: () => Promise.resolve(data),
37
+ text: () => Promise.resolve(typeof data === 'string' ? data : JSON.stringify(data)),
38
+ headers: {
39
+ get: (name) => headers[name] || null,
40
+ },
41
+ });
42
+ };
43
+
44
+ describe('getAccessToken', () => {
45
+ it('should fetch and cache a new token', async () => {
46
+ mockFetch.mockResolvedValueOnce(
47
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
48
+ );
49
+
50
+ const token = await service.getAccessToken(realmConfig);
51
+
52
+ expect(token).toBe('test-token');
53
+ expect(mockFetch).toHaveBeenCalledWith(
54
+ `${realmConfig.serverUrl}/realms/${realmConfig.realmName}/protocol/openid-connect/token`,
55
+ expect.objectContaining({
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
58
+ })
59
+ );
60
+ });
61
+
62
+ it('should return cached token on subsequent calls', async () => {
63
+ mockFetch.mockResolvedValueOnce(
64
+ mockFetchResponse({ access_token: 'cached-token', expires_in: 300 })
65
+ );
66
+
67
+ await service.getAccessToken(realmConfig);
68
+ const token2 = await service.getAccessToken(realmConfig);
69
+
70
+ expect(token2).toBe('cached-token');
71
+ expect(mockFetch).toHaveBeenCalledTimes(1); // Only called once
72
+ });
73
+
74
+ it('should throw sanitized error on auth failure', async () => {
75
+ mockFetch.mockResolvedValueOnce(
76
+ mockFetchResponse('Invalid credentials', { ok: false, status: 401 })
77
+ );
78
+
79
+ try {
80
+ await service.getAccessToken(realmConfig);
81
+ fail('Should have thrown');
82
+ } catch (err) {
83
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.KEYCLOAK_AUTH_FAILED);
84
+ }
85
+ });
86
+
87
+ it('should throw connection error on network failure', async () => {
88
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
89
+
90
+ try {
91
+ await service.getAccessToken(realmConfig);
92
+ fail('Should have thrown');
93
+ } catch (err) {
94
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
95
+ }
96
+ });
97
+ });
98
+
99
+ describe('testConnection', () => {
100
+ beforeEach(() => {
101
+ // Setup token fetch
102
+ mockFetch.mockResolvedValueOnce(
103
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
104
+ );
105
+ });
106
+
107
+ it('should return success with realm display name', async () => {
108
+ mockFetch.mockResolvedValueOnce(
109
+ mockFetchResponse({ realm: 'test', displayName: 'Test Realm' })
110
+ );
111
+
112
+ const result = await service.testConnection(realmConfig);
113
+
114
+ expect(result.success).toBe(true);
115
+ expect(result.realmDisplayName).toBe('Test Realm');
116
+ });
117
+
118
+ it('should return failure with message on error', async () => {
119
+ mockFetch.mockResolvedValueOnce(
120
+ mockFetchResponse('Forbidden', { ok: false, status: 403 })
121
+ );
122
+
123
+ const result = await service.testConnection(realmConfig);
124
+
125
+ expect(result.success).toBe(false);
126
+ expect(result.message).toBe('HTTP 403: Error');
127
+ });
128
+
129
+ it('should handle token fetch failure gracefully', async () => {
130
+ service.clearTokenCache();
131
+ mockFetch.mockReset();
132
+ mockFetch.mockResolvedValueOnce(
133
+ mockFetchResponse('Auth failed', { ok: false, status: 401 })
134
+ );
135
+
136
+ const result = await service.testConnection(realmConfig);
137
+
138
+ expect(result.success).toBe(false);
139
+ expect(result.message).toBe(ERROR_MESSAGES.KEYCLOAK_AUTH_FAILED);
140
+ });
141
+ });
142
+
143
+ describe('getUsers', () => {
144
+ beforeEach(() => {
145
+ mockFetch.mockResolvedValueOnce(
146
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
147
+ );
148
+ });
149
+
150
+ it('should fetch users with pagination', async () => {
151
+ const users = [createMockKeycloakUser()];
152
+ mockFetch.mockResolvedValueOnce(mockFetchResponse(users));
153
+
154
+ const result = await service.getUsers(realmConfig, { first: 10, max: 25 });
155
+
156
+ expect(result).toEqual(users);
157
+ expect(mockFetch).toHaveBeenLastCalledWith(
158
+ expect.stringContaining('first=10'),
159
+ expect.anything()
160
+ );
161
+ });
162
+
163
+ it('should include search parameter when provided', async () => {
164
+ mockFetch.mockResolvedValueOnce(mockFetchResponse([]));
165
+
166
+ await service.getUsers(realmConfig, { search: 'john' });
167
+
168
+ expect(mockFetch).toHaveBeenLastCalledWith(
169
+ expect.stringContaining('search=john'),
170
+ expect.anything()
171
+ );
172
+ });
173
+
174
+ it('should throw on error', async () => {
175
+ mockFetch.mockResolvedValueOnce(
176
+ mockFetchResponse('Server error', { ok: false, status: 500 })
177
+ );
178
+
179
+ await expect(service.getUsers(realmConfig)).rejects.toThrow();
180
+ });
181
+ });
182
+
183
+ describe('countUsers', () => {
184
+ beforeEach(() => {
185
+ mockFetch.mockResolvedValueOnce(
186
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
187
+ );
188
+ });
189
+
190
+ it('should return user count', async () => {
191
+ mockFetch.mockResolvedValueOnce(mockFetchResponse(42));
192
+
193
+ const count = await service.countUsers(realmConfig);
194
+
195
+ expect(count).toBe(42);
196
+ });
197
+
198
+ it('should return 0 on error', async () => {
199
+ mockFetch.mockResolvedValueOnce(
200
+ mockFetchResponse('Error', { ok: false, status: 500 })
201
+ );
202
+
203
+ const count = await service.countUsers(realmConfig);
204
+
205
+ expect(count).toBe(0);
206
+ });
207
+ });
208
+
209
+ describe('getUserById', () => {
210
+ beforeEach(() => {
211
+ mockFetch.mockResolvedValueOnce(
212
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
213
+ );
214
+ });
215
+
216
+ it('should return user by ID', async () => {
217
+ const user = createMockKeycloakUser();
218
+ mockFetch.mockResolvedValueOnce(mockFetchResponse(user));
219
+
220
+ const result = await service.getUserById(realmConfig, 'user-123');
221
+
222
+ expect(result).toEqual(user);
223
+ expect(mockFetch).toHaveBeenLastCalledWith(
224
+ expect.stringContaining('/users/user-123'),
225
+ expect.anything()
226
+ );
227
+ });
228
+
229
+ it('should throw USER_NOT_FOUND on 404', async () => {
230
+ mockFetch.mockResolvedValueOnce(
231
+ mockFetchResponse('Not found', { ok: false, status: HTTP_STATUS.NOT_FOUND })
232
+ );
233
+
234
+ try {
235
+ await service.getUserById(realmConfig, 'missing');
236
+ fail('Should have thrown');
237
+ } catch (err) {
238
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.USER_NOT_FOUND);
239
+ }
240
+ });
241
+ });
242
+
243
+ describe('createUser', () => {
244
+ beforeEach(() => {
245
+ mockFetch.mockResolvedValueOnce(
246
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
247
+ );
248
+ });
249
+
250
+ it('should create user and return ID from Location header', async () => {
251
+ mockFetch.mockResolvedValueOnce(
252
+ mockFetchResponse('', {
253
+ ok: true,
254
+ status: 201,
255
+ headers: { Location: 'https://keycloak.example.com/admin/realms/test/users/new-user-id' },
256
+ })
257
+ );
258
+
259
+ const result = await service.createUser(realmConfig, {
260
+ username: 'newuser',
261
+ email: 'new@example.com',
262
+ });
263
+
264
+ expect(result.userId).toBe('new-user-id');
265
+ });
266
+
267
+ it('should throw USER_ALREADY_EXISTS on 409', async () => {
268
+ mockFetch.mockResolvedValueOnce(
269
+ mockFetchResponse('Conflict', { ok: false, status: HTTP_STATUS.CONFLICT })
270
+ );
271
+
272
+ try {
273
+ await service.createUser(realmConfig, { username: 'existing' });
274
+ fail('Should have thrown');
275
+ } catch (err) {
276
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.USER_ALREADY_EXISTS);
277
+ }
278
+ });
279
+ });
280
+
281
+ describe('updateUser', () => {
282
+ beforeEach(() => {
283
+ mockFetch.mockResolvedValueOnce(
284
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
285
+ );
286
+ });
287
+
288
+ it('should update user successfully', async () => {
289
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true, status: 204 }));
290
+
291
+ const result = await service.updateUser(realmConfig, 'user-123', {
292
+ firstName: 'Updated',
293
+ });
294
+
295
+ expect(result.success).toBe(true);
296
+ expect(mockFetch).toHaveBeenLastCalledWith(
297
+ expect.stringContaining('/users/user-123'),
298
+ expect.objectContaining({ method: 'PUT' })
299
+ );
300
+ });
301
+
302
+ it('should throw USER_NOT_FOUND on 404', async () => {
303
+ mockFetch.mockResolvedValueOnce(
304
+ mockFetchResponse('Not found', { ok: false, status: HTTP_STATUS.NOT_FOUND })
305
+ );
306
+
307
+ try {
308
+ await service.updateUser(realmConfig, 'missing', {});
309
+ fail('Should have thrown');
310
+ } catch (err) {
311
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.USER_NOT_FOUND);
312
+ }
313
+ });
314
+ });
315
+
316
+ describe('deleteUser', () => {
317
+ beforeEach(() => {
318
+ mockFetch.mockResolvedValueOnce(
319
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
320
+ );
321
+ });
322
+
323
+ it('should delete user successfully', async () => {
324
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true, status: 204 }));
325
+
326
+ const result = await service.deleteUser(realmConfig, 'user-123');
327
+
328
+ expect(result.success).toBe(true);
329
+ expect(mockFetch).toHaveBeenLastCalledWith(
330
+ expect.stringContaining('/users/user-123'),
331
+ expect.objectContaining({ method: 'DELETE' })
332
+ );
333
+ });
334
+ });
335
+
336
+ describe('resetPassword', () => {
337
+ beforeEach(() => {
338
+ mockFetch.mockResolvedValueOnce(
339
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
340
+ );
341
+ });
342
+
343
+ it('should reset password with temporary flag', async () => {
344
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true }));
345
+
346
+ const result = await service.resetPassword(realmConfig, 'user-123', 'NewPass!', true);
347
+
348
+ expect(result.success).toBe(true);
349
+ expect(mockFetch).toHaveBeenLastCalledWith(
350
+ expect.stringContaining('/reset-password'),
351
+ expect.objectContaining({
352
+ body: expect.stringContaining('"temporary":true'),
353
+ })
354
+ );
355
+ });
356
+
357
+ it('should throw PASSWORD_RESET_FAILED on error', async () => {
358
+ mockFetch.mockResolvedValueOnce(
359
+ mockFetchResponse('Failed', { ok: false, status: 400 })
360
+ );
361
+
362
+ try {
363
+ await service.resetPassword(realmConfig, 'user-123', 'weak');
364
+ fail('Should have thrown');
365
+ } catch (err) {
366
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.PASSWORD_RESET_FAILED);
367
+ }
368
+ });
369
+ });
370
+
371
+ describe('enableUser / disableUser', () => {
372
+ beforeEach(() => {
373
+ mockFetch.mockResolvedValueOnce(
374
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
375
+ );
376
+ });
377
+
378
+ it('should enable user by updating enabled field', async () => {
379
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true }));
380
+
381
+ await service.enableUser(realmConfig, 'user-123');
382
+
383
+ expect(mockFetch).toHaveBeenLastCalledWith(
384
+ expect.anything(),
385
+ expect.objectContaining({
386
+ body: JSON.stringify({ enabled: true }),
387
+ })
388
+ );
389
+ });
390
+
391
+ it('should disable user by updating enabled field', async () => {
392
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true }));
393
+
394
+ await service.disableUser(realmConfig, 'user-123');
395
+
396
+ expect(mockFetch).toHaveBeenLastCalledWith(
397
+ expect.anything(),
398
+ expect.objectContaining({
399
+ body: JSON.stringify({ enabled: false }),
400
+ })
401
+ );
402
+ });
403
+ });
404
+
405
+ describe('executeEmailActions', () => {
406
+ beforeEach(() => {
407
+ mockFetch.mockResolvedValueOnce(
408
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
409
+ );
410
+ });
411
+
412
+ it('should send email actions with lifespan', async () => {
413
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true }));
414
+
415
+ await service.executeEmailActions(realmConfig, 'user-123', ['VERIFY_EMAIL'], 3600);
416
+
417
+ expect(mockFetch).toHaveBeenLastCalledWith(
418
+ expect.stringContaining('lifespan=3600'),
419
+ expect.objectContaining({
420
+ body: JSON.stringify(['VERIFY_EMAIL']),
421
+ })
422
+ );
423
+ });
424
+
425
+ it('should throw EMAIL_SEND_FAILED on error', async () => {
426
+ mockFetch.mockResolvedValueOnce(
427
+ mockFetchResponse('Email failed', { ok: false, status: 500 })
428
+ );
429
+
430
+ try {
431
+ await service.executeEmailActions(realmConfig, 'user-123', ['VERIFY_EMAIL']);
432
+ fail('Should have thrown');
433
+ } catch (err) {
434
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.EMAIL_SEND_FAILED);
435
+ }
436
+ });
437
+ });
438
+
439
+ describe('sendVerificationEmail / sendResetPasswordEmail', () => {
440
+ beforeEach(() => {
441
+ mockFetch.mockResolvedValueOnce(
442
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
443
+ );
444
+ });
445
+
446
+ it('should send verification email', async () => {
447
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true }));
448
+
449
+ await service.sendVerificationEmail(realmConfig, 'user-123');
450
+
451
+ expect(mockFetch).toHaveBeenLastCalledWith(
452
+ expect.anything(),
453
+ expect.objectContaining({
454
+ body: JSON.stringify(['VERIFY_EMAIL']),
455
+ })
456
+ );
457
+ });
458
+
459
+ it('should send password reset email', async () => {
460
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true }));
461
+
462
+ await service.sendResetPasswordEmail(realmConfig, 'user-123');
463
+
464
+ expect(mockFetch).toHaveBeenLastCalledWith(
465
+ expect.anything(),
466
+ expect.objectContaining({
467
+ body: JSON.stringify(['UPDATE_PASSWORD']),
468
+ })
469
+ );
470
+ });
471
+ });
472
+
473
+ describe('getRealmRoles / getUserRoles', () => {
474
+ beforeEach(() => {
475
+ mockFetch.mockResolvedValueOnce(
476
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
477
+ );
478
+ });
479
+
480
+ it('should get realm roles', async () => {
481
+ const roles = [{ id: '1', name: 'admin' }];
482
+ mockFetch.mockResolvedValueOnce(mockFetchResponse(roles));
483
+
484
+ const result = await service.getRealmRoles(realmConfig);
485
+
486
+ expect(result).toEqual(roles);
487
+ expect(mockFetch).toHaveBeenLastCalledWith(
488
+ expect.stringContaining('/roles'),
489
+ expect.anything()
490
+ );
491
+ });
492
+
493
+ it('should get user roles', async () => {
494
+ const roles = [{ id: '1', name: 'user' }];
495
+ mockFetch.mockResolvedValueOnce(mockFetchResponse(roles));
496
+
497
+ const result = await service.getUserRoles(realmConfig, 'user-123');
498
+
499
+ expect(result).toEqual(roles);
500
+ expect(mockFetch).toHaveBeenLastCalledWith(
501
+ expect.stringContaining('/users/user-123/role-mappings/realm'),
502
+ expect.anything()
503
+ );
504
+ });
505
+ });
506
+
507
+ describe('assignRoles / removeRoles', () => {
508
+ beforeEach(() => {
509
+ mockFetch.mockResolvedValueOnce(
510
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
511
+ );
512
+ });
513
+
514
+ it('should assign roles with POST', async () => {
515
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true }));
516
+ const roles = [{ id: '1', name: 'admin' }];
517
+
518
+ await service.assignRoles(realmConfig, 'user-123', roles);
519
+
520
+ expect(mockFetch).toHaveBeenLastCalledWith(
521
+ expect.anything(),
522
+ expect.objectContaining({
523
+ method: 'POST',
524
+ body: JSON.stringify(roles),
525
+ })
526
+ );
527
+ });
528
+
529
+ it('should remove roles with DELETE', async () => {
530
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true }));
531
+ const roles = [{ id: '1', name: 'admin' }];
532
+
533
+ await service.removeRoles(realmConfig, 'user-123', roles);
534
+
535
+ expect(mockFetch).toHaveBeenLastCalledWith(
536
+ expect.anything(),
537
+ expect.objectContaining({
538
+ method: 'DELETE',
539
+ body: JSON.stringify(roles),
540
+ })
541
+ );
542
+ });
543
+
544
+ it('should throw ROLE_ASSIGNMENT_FAILED on assign error', async () => {
545
+ mockFetch.mockResolvedValueOnce(
546
+ mockFetchResponse('Failed', { ok: false, status: 400 })
547
+ );
548
+
549
+ try {
550
+ await service.assignRoles(realmConfig, 'user-123', []);
551
+ fail('Should have thrown');
552
+ } catch (err) {
553
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.ROLE_ASSIGNMENT_FAILED);
554
+ }
555
+ });
556
+ });
557
+
558
+ describe('getUserSessions', () => {
559
+ beforeEach(() => {
560
+ mockFetch.mockResolvedValueOnce(
561
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
562
+ );
563
+ });
564
+
565
+ it('should return user sessions', async () => {
566
+ const sessions = [{ id: 'session-1', start: Date.now() }];
567
+ mockFetch.mockResolvedValueOnce(mockFetchResponse(sessions));
568
+
569
+ const result = await service.getUserSessions(realmConfig, 'user-123');
570
+
571
+ expect(result).toEqual(sessions);
572
+ });
573
+
574
+ it('should return empty array on error', async () => {
575
+ mockFetch.mockResolvedValueOnce(
576
+ mockFetchResponse('Error', { ok: false, status: 500 })
577
+ );
578
+
579
+ const result = await service.getUserSessions(realmConfig, 'user-123');
580
+
581
+ expect(result).toEqual([]);
582
+ });
583
+ });
584
+
585
+ describe('logoutUser', () => {
586
+ beforeEach(() => {
587
+ mockFetch.mockResolvedValueOnce(
588
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
589
+ );
590
+ });
591
+
592
+ it('should logout user successfully', async () => {
593
+ mockFetch.mockResolvedValueOnce(mockFetchResponse('', { ok: true, status: 204 }));
594
+
595
+ const result = await service.logoutUser(realmConfig, 'user-123');
596
+
597
+ expect(result.success).toBe(true);
598
+ expect(mockFetch).toHaveBeenLastCalledWith(
599
+ expect.stringContaining('/logout'),
600
+ expect.objectContaining({ method: 'POST' })
601
+ );
602
+ });
603
+
604
+ it('should throw LOGOUT_FAILED on error', async () => {
605
+ mockFetch.mockResolvedValueOnce(
606
+ mockFetchResponse('Failed', { ok: false, status: 500 })
607
+ );
608
+
609
+ try {
610
+ await service.logoutUser(realmConfig, 'user-123');
611
+ fail('Should have thrown');
612
+ } catch (err) {
613
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.LOGOUT_FAILED);
614
+ }
615
+ });
616
+ });
617
+
618
+ describe('clearTokenCache', () => {
619
+ it('should clear specific realm token', async () => {
620
+ mockFetch.mockResolvedValue(
621
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
622
+ );
623
+
624
+ await service.getAccessToken(realmConfig);
625
+ service.clearTokenCache(realmConfig);
626
+ await service.getAccessToken(realmConfig);
627
+
628
+ // Should have been called twice (token was cleared)
629
+ expect(mockFetch).toHaveBeenCalledTimes(2);
630
+ });
631
+
632
+ it('should clear all tokens when no config provided', async () => {
633
+ mockFetch.mockResolvedValue(
634
+ mockFetchResponse({ access_token: 'test-token', expires_in: 300 })
635
+ );
636
+
637
+ // Use different realmName to get different cache keys
638
+ const realm1 = createMockRealmConfig({ realmName: 'realm-one' });
639
+ const realm2 = createMockRealmConfig({ realmName: 'realm-two' });
640
+
641
+ await service.getAccessToken(realm1);
642
+ await service.getAccessToken(realm2);
643
+ service.clearTokenCache();
644
+ await service.getAccessToken(realm1);
645
+ await service.getAccessToken(realm2);
646
+
647
+ // Should have been called 4 times (2 initial + 2 after clear)
648
+ expect(mockFetch).toHaveBeenCalledTimes(4);
649
+ });
650
+ });
651
+ });