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,415 @@
1
+ /**
2
+ * @fileoverview Unit tests for the realm configuration service.
3
+ * @module __tests__/services/realm
4
+ */
5
+
6
+ import realmService from '../../server/src/services/realm.js';
7
+ import { createMockStrapi, createMockRealmConfig } from '../mocks/strapi.mjs';
8
+ import {
9
+ CONTENT_TYPES,
10
+ ERROR_MESSAGES,
11
+ DEFAULT_REALM_COLOR,
12
+ } from '../../server/src/constants.js';
13
+
14
+ describe('Realm Service', () => {
15
+ let strapi;
16
+ let service;
17
+ let mockKeycloakClient;
18
+
19
+ beforeEach(() => {
20
+ mockKeycloakClient = {
21
+ testConnection: jest.fn().mockResolvedValue({ success: true }),
22
+ clearTokenCache: jest.fn(),
23
+ };
24
+
25
+ strapi = createMockStrapi({
26
+ pluginServices: {
27
+ 'keycloak-client': mockKeycloakClient,
28
+ },
29
+ });
30
+ service = realmService({ strapi });
31
+ });
32
+
33
+ describe('findAll', () => {
34
+ it('should return all realms sorted by displayName', async () => {
35
+ const realms = [
36
+ createMockRealmConfig({ displayName: 'Bravo' }),
37
+ createMockRealmConfig({ displayName: 'Alpha' }),
38
+ ];
39
+ strapi.documents.mockReturnValue({
40
+ findMany: jest.fn().mockResolvedValue(realms),
41
+ });
42
+
43
+ const result = await service.findAll();
44
+
45
+ expect(result).toEqual(realms);
46
+ expect(strapi.documents).toHaveBeenCalledWith(CONTENT_TYPES.REALM_CONFIG);
47
+ });
48
+ });
49
+
50
+ describe('findOne', () => {
51
+ it('should return realm when found', async () => {
52
+ const realm = createMockRealmConfig();
53
+ strapi.documents.mockReturnValue({
54
+ findOne: jest.fn().mockResolvedValue(realm),
55
+ });
56
+
57
+ const result = await service.findOne('realm-doc-123');
58
+
59
+ expect(result).toEqual(realm);
60
+ });
61
+
62
+ it('should throw when realm not found', async () => {
63
+ strapi.documents.mockReturnValue({
64
+ findOne: jest.fn().mockResolvedValue(null),
65
+ });
66
+
67
+ await expect(service.findOne('non-existent')).rejects.toThrow();
68
+ });
69
+
70
+ it('should throw with sanitized error message', async () => {
71
+ strapi.documents.mockReturnValue({
72
+ findOne: jest.fn().mockResolvedValue(null),
73
+ });
74
+
75
+ try {
76
+ await service.findOne('non-existent');
77
+ } catch (err) {
78
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.REALM_NOT_FOUND);
79
+ }
80
+ });
81
+ });
82
+
83
+ describe('findOneWithSecret', () => {
84
+ it('should return realm with clientSecret from db.query', async () => {
85
+ const realm = createMockRealmConfig({ clientSecret: 'super-secret' });
86
+ strapi.db.query.mockReturnValue({
87
+ findOne: jest.fn().mockResolvedValue(realm),
88
+ });
89
+
90
+ const result = await service.findOneWithSecret('realm-doc-123');
91
+
92
+ expect(result.clientSecret).toBe('super-secret');
93
+ expect(strapi.db.query).toHaveBeenCalledWith(CONTENT_TYPES.REALM_CONFIG);
94
+ });
95
+
96
+ it('should throw when realm not found', async () => {
97
+ strapi.db.query.mockReturnValue({
98
+ findOne: jest.fn().mockResolvedValue(null),
99
+ });
100
+
101
+ await expect(service.findOneWithSecret('non-existent')).rejects.toThrow();
102
+ });
103
+ });
104
+
105
+ describe('findByName', () => {
106
+ it('should return realm when found by name', async () => {
107
+ const realm = createMockRealmConfig({ name: 'production-users' });
108
+ strapi.documents.mockReturnValue({
109
+ findMany: jest.fn().mockResolvedValue([realm]),
110
+ });
111
+
112
+ const result = await service.findByName('production-users');
113
+
114
+ expect(result).toEqual(realm);
115
+ });
116
+
117
+ it('should return null when not found', async () => {
118
+ strapi.documents.mockReturnValue({
119
+ findMany: jest.fn().mockResolvedValue([]),
120
+ });
121
+
122
+ const result = await service.findByName('non-existent');
123
+
124
+ expect(result).toBeNull();
125
+ });
126
+ });
127
+
128
+ describe('isValidName', () => {
129
+ it('should accept valid names', () => {
130
+ expect(service.isValidName('valid-name')).toBe(true);
131
+ expect(service.isValidName('realm123')).toBe(true);
132
+ expect(service.isValidName('my-realm-v2')).toBe(true);
133
+ });
134
+
135
+ it('should reject invalid names', () => {
136
+ expect(service.isValidName('Invalid')).toBe(false);
137
+ expect(service.isValidName('has_underscore')).toBe(false);
138
+ expect(service.isValidName('has space')).toBe(false);
139
+ expect(service.isValidName('')).toBe(false);
140
+ });
141
+ });
142
+
143
+ describe('normalizeServerUrl', () => {
144
+ it('should remove trailing slash', () => {
145
+ expect(service.normalizeServerUrl('https://example.com/')).toBe('https://example.com');
146
+ });
147
+
148
+ it('should not modify URL without trailing slash', () => {
149
+ expect(service.normalizeServerUrl('https://example.com')).toBe('https://example.com');
150
+ });
151
+ });
152
+
153
+ describe('create', () => {
154
+ it('should create realm with valid data', async () => {
155
+ const createMock = jest.fn().mockResolvedValue({ documentId: 'new-realm' });
156
+ strapi.documents.mockReturnValue({
157
+ findMany: jest.fn().mockResolvedValue([]),
158
+ create: createMock,
159
+ });
160
+
161
+ await service.create({
162
+ name: 'new-realm',
163
+ displayName: 'New Realm',
164
+ serverUrl: 'https://keycloak.example.com/',
165
+ realmName: 'new',
166
+ clientId: 'admin-cli',
167
+ clientSecret: 'secret',
168
+ });
169
+
170
+ expect(createMock).toHaveBeenCalledWith({
171
+ data: expect.objectContaining({
172
+ name: 'new-realm',
173
+ displayName: 'New Realm',
174
+ serverUrl: 'https://keycloak.example.com', // normalized
175
+ realmName: 'new',
176
+ clientId: 'admin-cli',
177
+ clientSecret: 'secret',
178
+ enabled: true,
179
+ color: DEFAULT_REALM_COLOR,
180
+ }),
181
+ });
182
+ });
183
+
184
+ it('should throw when required fields are missing', async () => {
185
+ await expect(
186
+ service.create({
187
+ name: 'test',
188
+ // missing other required fields
189
+ })
190
+ ).rejects.toThrow();
191
+
192
+ try {
193
+ await service.create({ name: 'test' });
194
+ } catch (err) {
195
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.MISSING_REQUIRED_FIELDS);
196
+ }
197
+ });
198
+
199
+ it('should throw for invalid name format', async () => {
200
+ try {
201
+ await service.create({
202
+ name: 'Invalid Name',
203
+ displayName: 'Test',
204
+ serverUrl: 'https://example.com',
205
+ realmName: 'test',
206
+ clientId: 'client',
207
+ });
208
+ } catch (err) {
209
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.INVALID_NAME_FORMAT);
210
+ }
211
+ });
212
+
213
+ it('should throw when name already exists', async () => {
214
+ strapi.documents.mockReturnValue({
215
+ findMany: jest.fn().mockResolvedValue([createMockRealmConfig({ name: 'existing' })]),
216
+ });
217
+
218
+ try {
219
+ await service.create({
220
+ name: 'existing',
221
+ displayName: 'Test',
222
+ serverUrl: 'https://example.com',
223
+ realmName: 'test',
224
+ clientId: 'client',
225
+ });
226
+ } catch (err) {
227
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.REALM_NAME_EXISTS);
228
+ }
229
+ });
230
+
231
+ it('should allow null clientSecret', async () => {
232
+ const createMock = jest.fn().mockResolvedValue({});
233
+ strapi.documents.mockReturnValue({
234
+ findMany: jest.fn().mockResolvedValue([]),
235
+ create: createMock,
236
+ });
237
+
238
+ await service.create({
239
+ name: 'test-realm',
240
+ displayName: 'Test',
241
+ serverUrl: 'https://example.com',
242
+ realmName: 'test',
243
+ clientId: 'client',
244
+ });
245
+
246
+ const calledWith = createMock.mock.calls[0][0];
247
+ expect(calledWith.data.clientSecret).toBeNull();
248
+ });
249
+ });
250
+
251
+ describe('update', () => {
252
+ it('should update realm with valid data', async () => {
253
+ const existing = createMockRealmConfig();
254
+ const updateMock = jest.fn().mockResolvedValue({});
255
+ strapi.documents.mockReturnValue({
256
+ findOne: jest.fn().mockResolvedValue(existing),
257
+ findMany: jest.fn().mockResolvedValue([]),
258
+ update: updateMock,
259
+ });
260
+
261
+ await service.update('realm-123', { displayName: 'Updated Name' });
262
+
263
+ expect(updateMock).toHaveBeenCalledWith({
264
+ documentId: 'realm-123',
265
+ data: { displayName: 'Updated Name' },
266
+ });
267
+ });
268
+
269
+ it('should validate new name format on update', async () => {
270
+ const existing = createMockRealmConfig();
271
+ strapi.documents.mockReturnValue({
272
+ findOne: jest.fn().mockResolvedValue(existing),
273
+ });
274
+
275
+ try {
276
+ await service.update('realm-123', { name: 'Invalid Name' });
277
+ } catch (err) {
278
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.INVALID_NAME_FORMAT);
279
+ }
280
+ });
281
+
282
+ it('should check for duplicate name on update', async () => {
283
+ const existing = createMockRealmConfig({ documentId: 'realm-123', name: 'old-name' });
284
+ const duplicate = createMockRealmConfig({ documentId: 'other-realm', name: 'taken-name' });
285
+ strapi.documents.mockReturnValue({
286
+ findOne: jest.fn().mockResolvedValue(existing),
287
+ findMany: jest.fn().mockResolvedValue([duplicate]),
288
+ });
289
+
290
+ try {
291
+ await service.update('realm-123', { name: 'taken-name' });
292
+ } catch (err) {
293
+ expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.REALM_NAME_EXISTS);
294
+ }
295
+ });
296
+
297
+ it('should allow same name for same realm', async () => {
298
+ const existing = createMockRealmConfig({ documentId: 'realm-123', name: 'same-name' });
299
+ const updateMock = jest.fn().mockResolvedValue({});
300
+ strapi.documents.mockReturnValue({
301
+ findOne: jest.fn().mockResolvedValue(existing),
302
+ findMany: jest.fn().mockResolvedValue([existing]), // same realm
303
+ update: updateMock,
304
+ });
305
+
306
+ await service.update('realm-123', { name: 'same-name' });
307
+
308
+ expect(updateMock).toHaveBeenCalled();
309
+ });
310
+
311
+ it('should normalize serverUrl on update', async () => {
312
+ const existing = createMockRealmConfig();
313
+ const updateMock = jest.fn().mockResolvedValue({});
314
+ strapi.documents.mockReturnValue({
315
+ findOne: jest.fn().mockResolvedValue(existing),
316
+ update: updateMock,
317
+ });
318
+
319
+ await service.update('realm-123', { serverUrl: 'https://new-server.com/' });
320
+
321
+ const calledWith = updateMock.mock.calls[0][0];
322
+ expect(calledWith.data.serverUrl).toBe('https://new-server.com');
323
+ });
324
+
325
+ it('should clear token cache when connection details change', async () => {
326
+ const existing = createMockRealmConfig();
327
+ strapi.documents.mockReturnValue({
328
+ findOne: jest.fn().mockResolvedValue(existing),
329
+ update: jest.fn().mockResolvedValue({}),
330
+ });
331
+
332
+ await service.update('realm-123', { clientSecret: 'new-secret' });
333
+
334
+ expect(mockKeycloakClient.clearTokenCache).toHaveBeenCalledWith(existing);
335
+ });
336
+ });
337
+
338
+ describe('delete', () => {
339
+ it('should delete realm and all admin assignments', async () => {
340
+ const realm = createMockRealmConfig();
341
+ const assignments = [
342
+ { documentId: 'assignment-1' },
343
+ { documentId: 'assignment-2' },
344
+ ];
345
+ const deleteMock = jest.fn().mockResolvedValue({});
346
+ strapi.documents.mockReturnValue({
347
+ findOne: jest.fn().mockResolvedValue(realm),
348
+ findMany: jest.fn().mockResolvedValue(assignments),
349
+ delete: deleteMock,
350
+ });
351
+
352
+ const result = await service.delete('realm-123');
353
+
354
+ expect(result).toEqual({ success: true });
355
+ expect(deleteMock).toHaveBeenCalledTimes(3); // 2 assignments + 1 realm
356
+ expect(mockKeycloakClient.clearTokenCache).toHaveBeenCalledWith(realm);
357
+ });
358
+
359
+ it('should throw when realm not found', async () => {
360
+ strapi.documents.mockReturnValue({
361
+ findOne: jest.fn().mockResolvedValue(null),
362
+ });
363
+
364
+ await expect(service.delete('non-existent')).rejects.toThrow();
365
+ });
366
+ });
367
+
368
+ describe('testConnection', () => {
369
+ it('should test connection for enabled realm', async () => {
370
+ const realm = createMockRealmConfig({ enabled: true });
371
+ strapi.documents.mockReturnValue({
372
+ findOne: jest.fn().mockResolvedValue(realm),
373
+ });
374
+ strapi.db.query.mockReturnValue({
375
+ findOne: jest.fn().mockResolvedValue({ ...realm, clientSecret: 'secret' }),
376
+ });
377
+
378
+ const result = await service.testConnection('realm-123');
379
+
380
+ expect(result.success).toBe(true);
381
+ expect(mockKeycloakClient.testConnection).toHaveBeenCalled();
382
+ });
383
+
384
+ it('should return failure for disabled realm', async () => {
385
+ const realm = createMockRealmConfig({ enabled: false });
386
+ strapi.documents.mockReturnValue({
387
+ findOne: jest.fn().mockResolvedValue(realm),
388
+ });
389
+
390
+ const result = await service.testConnection('realm-123');
391
+
392
+ expect(result.success).toBe(false);
393
+ expect(result.message).toBe(ERROR_MESSAGES.REALM_DISABLED);
394
+ });
395
+ });
396
+
397
+ describe('testConnectionRaw', () => {
398
+ it('should test connection with raw config', async () => {
399
+ mockKeycloakClient.testConnection.mockResolvedValue({
400
+ success: true,
401
+ realmDisplayName: 'Test Realm',
402
+ });
403
+
404
+ const result = await service.testConnectionRaw({
405
+ serverUrl: 'https://keycloak.example.com',
406
+ realmName: 'test',
407
+ clientId: 'admin-cli',
408
+ clientSecret: 'secret',
409
+ });
410
+
411
+ expect(result.success).toBe(true);
412
+ expect(result.realmDisplayName).toBe('Test Realm');
413
+ });
414
+ });
415
+ });