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.
- package/LICENSE +21 -0
- package/README.md +485 -0
- package/__tests__/constants.test.mjs +207 -0
- package/__tests__/mocks/strapi.mjs +182 -0
- package/__tests__/services/audit-log.test.mjs +283 -0
- package/__tests__/services/keycloak-client.test.mjs +651 -0
- package/__tests__/services/permission.test.mjs +374 -0
- package/__tests__/services/realm.test.mjs +415 -0
- package/__tests__/services/user.test.mjs +487 -0
- package/__tests__/utils/errors.test.mjs +109 -0
- package/admin/src/components/Initializer.jsx +14 -0
- package/admin/src/components/RealmBadge.jsx +17 -0
- package/admin/src/constants.js +14 -0
- package/admin/src/hooks/useAuditLogs.js +142 -0
- package/admin/src/hooks/useKeycloakRoles.js +182 -0
- package/admin/src/hooks/useKeycloakUsers.js +477 -0
- package/admin/src/hooks/useRealmAdmins.js +249 -0
- package/admin/src/hooks/useRealms.js +269 -0
- package/admin/src/index.js +46 -0
- package/admin/src/pages/App.jsx +21 -0
- package/admin/src/pages/AuditPage/index.jsx +213 -0
- package/admin/src/pages/RealmsPage/RealmEditPage.jsx +791 -0
- package/admin/src/pages/RealmsPage/RealmListPage.jsx +231 -0
- package/admin/src/pages/RealmsPage/index.jsx +7 -0
- package/admin/src/pages/UsersPage/UserEditPage.jsx +313 -0
- package/admin/src/pages/UsersPage/UserListPage.jsx +437 -0
- package/admin/src/pages/UsersPage/index.jsx +7 -0
- package/admin/src/pluginId.js +2 -0
- package/admin/src/translations/en.json +77 -0
- package/admin/src/translations/fr.json +77 -0
- package/babel.config.cjs +17 -0
- package/coverage/clover.xml +422 -0
- package/coverage/coverage-final.json +8 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +146 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/src/bootstrap.js.html +346 -0
- package/coverage/lcov-report/src/config/index.html +116 -0
- package/coverage/lcov-report/src/config/index.js.html +106 -0
- package/coverage/lcov-report/src/constants.js.html +850 -0
- package/coverage/lcov-report/src/content-types/audit-log/index.html +116 -0
- package/coverage/lcov-report/src/content-types/audit-log/index.js.html +94 -0
- package/coverage/lcov-report/src/content-types/index.html +116 -0
- package/coverage/lcov-report/src/content-types/index.js.html +112 -0
- package/coverage/lcov-report/src/content-types/realm-admin/index.html +116 -0
- package/coverage/lcov-report/src/content-types/realm-admin/index.js.html +94 -0
- package/coverage/lcov-report/src/content-types/realm-config/index.html +116 -0
- package/coverage/lcov-report/src/content-types/realm-config/index.js.html +94 -0
- package/coverage/lcov-report/src/controllers/audit.js.html +517 -0
- package/coverage/lcov-report/src/controllers/index.html +161 -0
- package/coverage/lcov-report/src/controllers/index.js.html +112 -0
- package/coverage/lcov-report/src/controllers/realm.js.html +1057 -0
- package/coverage/lcov-report/src/controllers/user.js.html +1324 -0
- package/coverage/lcov-report/src/destroy.js.html +100 -0
- package/coverage/lcov-report/src/index.html +116 -0
- package/coverage/lcov-report/src/policies/can-access-realm.js.html +163 -0
- package/coverage/lcov-report/src/policies/index.html +146 -0
- package/coverage/lcov-report/src/policies/index.js.html +106 -0
- package/coverage/lcov-report/src/policies/is-authenticated.js.html +100 -0
- package/coverage/lcov-report/src/register.js.html +106 -0
- package/coverage/lcov-report/src/routes/admin.js.html +844 -0
- package/coverage/lcov-report/src/routes/index.html +131 -0
- package/coverage/lcov-report/src/routes/index.js.html +109 -0
- package/coverage/lcov-report/src/services/audit-log.js.html +673 -0
- package/coverage/lcov-report/src/services/index.html +176 -0
- package/coverage/lcov-report/src/services/index.js.html +124 -0
- package/coverage/lcov-report/src/services/keycloak-client.js.html +2359 -0
- package/coverage/lcov-report/src/services/permission.js.html +955 -0
- package/coverage/lcov-report/src/services/realm.js.html +1207 -0
- package/coverage/lcov-report/src/services/user.js.html +1924 -0
- package/coverage/lcov-report/src/utils/errors.js.html +274 -0
- package/coverage/lcov-report/src/utils/index.html +116 -0
- package/coverage/lcov-report/src/utils/index.js.html +103 -0
- package/coverage/lcov.info +804 -0
- package/dist/_chunks/App-BaKrvCeS.mjs +1975 -0
- package/dist/_chunks/App-DO6syS77.js +1975 -0
- package/dist/_chunks/en-Li-XBDe9.mjs +72 -0
- package/dist/_chunks/en-aCyfgNfr.js +72 -0
- package/dist/_chunks/fr-Cj33Q8jI.js +72 -0
- package/dist/_chunks/fr-vLrXph-Z.mjs +72 -0
- package/dist/_chunks/index-DwDO4-0C.js +69 -0
- package/dist/_chunks/index-jTVd7LdQ.mjs +70 -0
- package/dist/admin/index.js +3 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/server/index.js +3003 -0
- package/dist/server/index.mjs +3004 -0
- package/jest.config.cjs +50 -0
- package/package.json +55 -0
- package/server/src/bootstrap.js +87 -0
- package/server/src/config/index.js +7 -0
- package/server/src/constants.js +255 -0
- package/server/src/content-types/audit-log/index.js +3 -0
- package/server/src/content-types/audit-log/schema.json +61 -0
- package/server/src/content-types/index.js +9 -0
- package/server/src/content-types/realm-admin/index.js +3 -0
- package/server/src/content-types/realm-admin/schema.json +45 -0
- package/server/src/content-types/realm-config/index.js +3 -0
- package/server/src/content-types/realm-config/schema.json +56 -0
- package/server/src/controllers/audit.js +144 -0
- package/server/src/controllers/index.js +9 -0
- package/server/src/controllers/realm.js +324 -0
- package/server/src/controllers/user.js +413 -0
- package/server/src/destroy.js +5 -0
- package/server/src/index.js +21 -0
- package/server/src/policies/can-access-realm.js +26 -0
- package/server/src/policies/index.js +7 -0
- package/server/src/policies/is-authenticated.js +5 -0
- package/server/src/register.js +7 -0
- package/server/src/routes/admin.js +253 -0
- package/server/src/routes/index.js +8 -0
- package/server/src/services/audit-log.js +196 -0
- package/server/src/services/index.js +13 -0
- package/server/src/services/keycloak-client.js +758 -0
- package/server/src/services/permission.js +290 -0
- package/server/src/services/realm.js +374 -0
- package/server/src/services/user.js +613 -0
- package/server/src/utils/errors.js +63 -0
- package/server/src/utils/index.js +6 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Unit tests for the user management service.
|
|
3
|
+
* @module __tests__/services/user
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import userService from '../../server/src/services/user.js';
|
|
7
|
+
import {
|
|
8
|
+
createMockStrapi,
|
|
9
|
+
createMockStrapiUser,
|
|
10
|
+
createMockRealmConfig,
|
|
11
|
+
createMockKeycloakUser,
|
|
12
|
+
createMockKeycloakRole,
|
|
13
|
+
} from '../mocks/strapi.mjs';
|
|
14
|
+
import { AUDIT_ACTIONS, ERROR_MESSAGES, PAGINATION } from '../../server/src/constants.js';
|
|
15
|
+
|
|
16
|
+
describe('User Service', () => {
|
|
17
|
+
let strapi;
|
|
18
|
+
let service;
|
|
19
|
+
let mockRealmService;
|
|
20
|
+
let mockKeycloakClient;
|
|
21
|
+
let mockPermissionService;
|
|
22
|
+
let mockAuditLogService;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockRealmService = {
|
|
26
|
+
findOne: jest.fn().mockResolvedValue(createMockRealmConfig({ enabled: true })),
|
|
27
|
+
findOneWithSecret: jest.fn().mockResolvedValue(
|
|
28
|
+
createMockRealmConfig({ clientSecret: 'secret' })
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
mockKeycloakClient = {
|
|
33
|
+
getUsers: jest.fn().mockResolvedValue([]),
|
|
34
|
+
countUsers: jest.fn().mockResolvedValue(0),
|
|
35
|
+
getUserById: jest.fn().mockResolvedValue(createMockKeycloakUser()),
|
|
36
|
+
createUser: jest.fn().mockResolvedValue({ userId: 'new-user-id' }),
|
|
37
|
+
updateUser: jest.fn().mockResolvedValue(undefined),
|
|
38
|
+
deleteUser: jest.fn().mockResolvedValue(undefined),
|
|
39
|
+
resetPassword: jest.fn().mockResolvedValue(undefined),
|
|
40
|
+
enableUser: jest.fn().mockResolvedValue(undefined),
|
|
41
|
+
disableUser: jest.fn().mockResolvedValue(undefined),
|
|
42
|
+
sendVerificationEmail: jest.fn().mockResolvedValue(undefined),
|
|
43
|
+
sendResetPasswordEmail: jest.fn().mockResolvedValue(undefined),
|
|
44
|
+
getRealmRoles: jest.fn().mockResolvedValue([]),
|
|
45
|
+
getUserRoles: jest.fn().mockResolvedValue([]),
|
|
46
|
+
assignRoles: jest.fn().mockResolvedValue(undefined),
|
|
47
|
+
removeRoles: jest.fn().mockResolvedValue(undefined),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
mockPermissionService = {
|
|
51
|
+
canAccessRealm: jest.fn().mockResolvedValue(true),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
mockAuditLogService = {
|
|
55
|
+
log: jest.fn().mockResolvedValue(undefined),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
strapi = createMockStrapi({
|
|
59
|
+
pluginServices: {
|
|
60
|
+
realm: mockRealmService,
|
|
61
|
+
'keycloak-client': mockKeycloakClient,
|
|
62
|
+
permission: mockPermissionService,
|
|
63
|
+
'audit-log': mockAuditLogService,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
service = userService({ strapi });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('getRealmWithPermission', () => {
|
|
70
|
+
it('should return realm when user has permission', async () => {
|
|
71
|
+
const user = createMockStrapiUser();
|
|
72
|
+
|
|
73
|
+
const result = await service.getRealmWithPermission('realm-123', user, 'canRead');
|
|
74
|
+
|
|
75
|
+
expect(result.clientSecret).toBe('secret');
|
|
76
|
+
expect(mockRealmService.findOne).toHaveBeenCalledWith('realm-123');
|
|
77
|
+
expect(mockPermissionService.canAccessRealm).toHaveBeenCalledWith(user, 'realm-123', 'canRead');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should throw when realm is disabled', async () => {
|
|
81
|
+
mockRealmService.findOne.mockResolvedValue(createMockRealmConfig({ enabled: false }));
|
|
82
|
+
const user = createMockStrapiUser();
|
|
83
|
+
|
|
84
|
+
await expect(
|
|
85
|
+
service.getRealmWithPermission('realm-123', user, 'canRead')
|
|
86
|
+
).rejects.toThrow();
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await service.getRealmWithPermission('realm-123', user, 'canRead');
|
|
90
|
+
} catch (err) {
|
|
91
|
+
expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.REALM_DISABLED);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should throw when user lacks permission', async () => {
|
|
96
|
+
mockPermissionService.canAccessRealm.mockResolvedValue(false);
|
|
97
|
+
const user = createMockStrapiUser();
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await service.getRealmWithPermission('realm-123', user, 'canDelete');
|
|
101
|
+
} catch (err) {
|
|
102
|
+
expect(err.sanitizedMessage).toBe(ERROR_MESSAGES.REALM_ACCESS_DENIED);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('listUsers', () => {
|
|
108
|
+
it('should return paginated users', async () => {
|
|
109
|
+
const users = [createMockKeycloakUser(), createMockKeycloakUser({ id: 'user-2' })];
|
|
110
|
+
mockKeycloakClient.getUsers.mockResolvedValue(users);
|
|
111
|
+
mockKeycloakClient.countUsers.mockResolvedValue(50);
|
|
112
|
+
const strapiUser = createMockStrapiUser();
|
|
113
|
+
|
|
114
|
+
const result = await service.listUsers('realm-123', { page: 2, pageSize: 10 }, strapiUser);
|
|
115
|
+
|
|
116
|
+
expect(result.users).toEqual(users);
|
|
117
|
+
expect(result.pagination).toEqual({
|
|
118
|
+
page: 2,
|
|
119
|
+
pageSize: 10,
|
|
120
|
+
total: 50,
|
|
121
|
+
pageCount: 5,
|
|
122
|
+
});
|
|
123
|
+
expect(mockKeycloakClient.getUsers).toHaveBeenCalledWith(
|
|
124
|
+
expect.anything(),
|
|
125
|
+
{ search: '', first: 10, max: 10 }
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should use default pagination', async () => {
|
|
130
|
+
const strapiUser = createMockStrapiUser();
|
|
131
|
+
|
|
132
|
+
await service.listUsers('realm-123', {}, strapiUser);
|
|
133
|
+
|
|
134
|
+
expect(mockKeycloakClient.getUsers).toHaveBeenCalledWith(
|
|
135
|
+
expect.anything(),
|
|
136
|
+
{ search: '', first: 0, max: PAGINATION.PAGE_SIZE }
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should pass search parameter', async () => {
|
|
141
|
+
const strapiUser = createMockStrapiUser();
|
|
142
|
+
|
|
143
|
+
await service.listUsers('realm-123', { search: 'john' }, strapiUser);
|
|
144
|
+
|
|
145
|
+
expect(mockKeycloakClient.getUsers).toHaveBeenCalledWith(
|
|
146
|
+
expect.anything(),
|
|
147
|
+
expect.objectContaining({ search: 'john' })
|
|
148
|
+
);
|
|
149
|
+
expect(mockKeycloakClient.countUsers).toHaveBeenCalledWith(
|
|
150
|
+
expect.anything(),
|
|
151
|
+
{ search: 'john' }
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('getUser', () => {
|
|
157
|
+
it('should return user by ID', async () => {
|
|
158
|
+
const kcUser = createMockKeycloakUser();
|
|
159
|
+
mockKeycloakClient.getUserById.mockResolvedValue(kcUser);
|
|
160
|
+
const strapiUser = createMockStrapiUser();
|
|
161
|
+
|
|
162
|
+
const result = await service.getUser('realm-123', 'kc-user-123', strapiUser);
|
|
163
|
+
|
|
164
|
+
expect(result).toEqual(kcUser);
|
|
165
|
+
expect(mockKeycloakClient.getUserById).toHaveBeenCalledWith(
|
|
166
|
+
expect.anything(),
|
|
167
|
+
'kc-user-123'
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('createUser', () => {
|
|
173
|
+
it('should create user and log action', async () => {
|
|
174
|
+
const newUser = createMockKeycloakUser({ id: 'new-user-id' });
|
|
175
|
+
mockKeycloakClient.getUserById.mockResolvedValue(newUser);
|
|
176
|
+
const strapiUser = createMockStrapiUser();
|
|
177
|
+
const userData = {
|
|
178
|
+
username: 'newuser',
|
|
179
|
+
email: 'new@example.com',
|
|
180
|
+
firstName: 'New',
|
|
181
|
+
lastName: 'User',
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const result = await service.createUser('realm-123', userData, strapiUser);
|
|
185
|
+
|
|
186
|
+
expect(result).toEqual(newUser);
|
|
187
|
+
expect(mockKeycloakClient.createUser).toHaveBeenCalledWith(expect.anything(), userData);
|
|
188
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
189
|
+
expect.objectContaining({
|
|
190
|
+
action: AUDIT_ACTIONS.CREATE_USER,
|
|
191
|
+
keycloakUsername: 'newuser',
|
|
192
|
+
details: expect.objectContaining({ email: 'new@example.com' }),
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should require canCreate permission', async () => {
|
|
198
|
+
mockPermissionService.canAccessRealm.mockResolvedValue(false);
|
|
199
|
+
const strapiUser = createMockStrapiUser();
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
service.createUser('realm-123', { username: 'test' }, strapiUser)
|
|
203
|
+
).rejects.toThrow();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('updateUser', () => {
|
|
208
|
+
it('should update user and log action', async () => {
|
|
209
|
+
const existingUser = createMockKeycloakUser();
|
|
210
|
+
const updatedUser = createMockKeycloakUser({ firstName: 'Updated' });
|
|
211
|
+
mockKeycloakClient.getUserById
|
|
212
|
+
.mockResolvedValueOnce(existingUser)
|
|
213
|
+
.mockResolvedValueOnce(updatedUser);
|
|
214
|
+
const strapiUser = createMockStrapiUser();
|
|
215
|
+
|
|
216
|
+
const result = await service.updateUser(
|
|
217
|
+
'realm-123',
|
|
218
|
+
'kc-user-123',
|
|
219
|
+
{ firstName: 'Updated' },
|
|
220
|
+
strapiUser
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(result).toEqual(updatedUser);
|
|
224
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
225
|
+
expect.objectContaining({
|
|
226
|
+
action: AUDIT_ACTIONS.UPDATE_USER,
|
|
227
|
+
details: { changes: { firstName: 'Updated' } },
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('deleteUser', () => {
|
|
234
|
+
it('should delete user and log action', async () => {
|
|
235
|
+
const user = createMockKeycloakUser();
|
|
236
|
+
mockKeycloakClient.getUserById.mockResolvedValue(user);
|
|
237
|
+
const strapiUser = createMockStrapiUser();
|
|
238
|
+
|
|
239
|
+
const result = await service.deleteUser('realm-123', 'kc-user-123', strapiUser);
|
|
240
|
+
|
|
241
|
+
expect(result).toEqual({ success: true });
|
|
242
|
+
expect(mockKeycloakClient.deleteUser).toHaveBeenCalledWith(expect.anything(), 'kc-user-123');
|
|
243
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
244
|
+
expect.objectContaining({
|
|
245
|
+
action: AUDIT_ACTIONS.DELETE_USER,
|
|
246
|
+
keycloakUserId: 'kc-user-123',
|
|
247
|
+
})
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('resetPassword', () => {
|
|
253
|
+
it('should reset password and log action', async () => {
|
|
254
|
+
const user = createMockKeycloakUser();
|
|
255
|
+
mockKeycloakClient.getUserById.mockResolvedValue(user);
|
|
256
|
+
const strapiUser = createMockStrapiUser();
|
|
257
|
+
|
|
258
|
+
const result = await service.resetPassword(
|
|
259
|
+
'realm-123',
|
|
260
|
+
'kc-user-123',
|
|
261
|
+
'NewPass123!',
|
|
262
|
+
true,
|
|
263
|
+
strapiUser
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
expect(result).toEqual({ success: true });
|
|
267
|
+
expect(mockKeycloakClient.resetPassword).toHaveBeenCalledWith(
|
|
268
|
+
expect.anything(),
|
|
269
|
+
'kc-user-123',
|
|
270
|
+
'NewPass123!',
|
|
271
|
+
true
|
|
272
|
+
);
|
|
273
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
274
|
+
expect.objectContaining({
|
|
275
|
+
action: AUDIT_ACTIONS.RESET_PASSWORD,
|
|
276
|
+
details: { temporary: true },
|
|
277
|
+
})
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should require canResetPassword permission', async () => {
|
|
282
|
+
mockPermissionService.canAccessRealm.mockResolvedValue(false);
|
|
283
|
+
const strapiUser = createMockStrapiUser();
|
|
284
|
+
|
|
285
|
+
await expect(
|
|
286
|
+
service.resetPassword('realm-123', 'kc-user-123', 'pass', false, strapiUser)
|
|
287
|
+
).rejects.toThrow();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('enableUser / disableUser', () => {
|
|
292
|
+
it('should enable user and log action', async () => {
|
|
293
|
+
const user = createMockKeycloakUser({ enabled: false });
|
|
294
|
+
mockKeycloakClient.getUserById.mockResolvedValue(user);
|
|
295
|
+
const strapiUser = createMockStrapiUser();
|
|
296
|
+
|
|
297
|
+
await service.enableUser('realm-123', 'kc-user-123', strapiUser);
|
|
298
|
+
|
|
299
|
+
expect(mockKeycloakClient.enableUser).toHaveBeenCalled();
|
|
300
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
301
|
+
expect.objectContaining({ action: AUDIT_ACTIONS.ENABLE_USER })
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should disable user and log action', async () => {
|
|
306
|
+
const user = createMockKeycloakUser({ enabled: true });
|
|
307
|
+
mockKeycloakClient.getUserById.mockResolvedValue(user);
|
|
308
|
+
const strapiUser = createMockStrapiUser();
|
|
309
|
+
|
|
310
|
+
await service.disableUser('realm-123', 'kc-user-123', strapiUser);
|
|
311
|
+
|
|
312
|
+
expect(mockKeycloakClient.disableUser).toHaveBeenCalled();
|
|
313
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
314
|
+
expect.objectContaining({ action: AUDIT_ACTIONS.DISABLE_USER })
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('sendVerificationEmail', () => {
|
|
320
|
+
it('should send verification email and log action', async () => {
|
|
321
|
+
const user = createMockKeycloakUser();
|
|
322
|
+
mockKeycloakClient.getUserById.mockResolvedValue(user);
|
|
323
|
+
const strapiUser = createMockStrapiUser();
|
|
324
|
+
|
|
325
|
+
await service.sendVerificationEmail('realm-123', 'kc-user-123', strapiUser);
|
|
326
|
+
|
|
327
|
+
expect(mockKeycloakClient.sendVerificationEmail).toHaveBeenCalled();
|
|
328
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
329
|
+
expect.objectContaining({ action: AUDIT_ACTIONS.SEND_VERIFY_EMAIL })
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('sendResetPasswordEmail', () => {
|
|
335
|
+
it('should send password reset email and log action', async () => {
|
|
336
|
+
const user = createMockKeycloakUser();
|
|
337
|
+
mockKeycloakClient.getUserById.mockResolvedValue(user);
|
|
338
|
+
const strapiUser = createMockStrapiUser();
|
|
339
|
+
|
|
340
|
+
await service.sendResetPasswordEmail('realm-123', 'kc-user-123', strapiUser);
|
|
341
|
+
|
|
342
|
+
expect(mockKeycloakClient.sendResetPasswordEmail).toHaveBeenCalled();
|
|
343
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
344
|
+
expect.objectContaining({ action: AUDIT_ACTIONS.SEND_RESET_PASSWORD_EMAIL })
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should require canResetPassword permission', async () => {
|
|
349
|
+
mockPermissionService.canAccessRealm.mockResolvedValue(false);
|
|
350
|
+
const strapiUser = createMockStrapiUser();
|
|
351
|
+
|
|
352
|
+
await expect(
|
|
353
|
+
service.sendResetPasswordEmail('realm-123', 'kc-user-123', strapiUser)
|
|
354
|
+
).rejects.toThrow();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('getRoles / getUserRoles', () => {
|
|
359
|
+
it('should get all realm roles', async () => {
|
|
360
|
+
const roles = [createMockKeycloakRole({ name: 'admin' })];
|
|
361
|
+
mockKeycloakClient.getRealmRoles.mockResolvedValue(roles);
|
|
362
|
+
const strapiUser = createMockStrapiUser();
|
|
363
|
+
|
|
364
|
+
const result = await service.getRoles('realm-123', strapiUser);
|
|
365
|
+
|
|
366
|
+
expect(result).toEqual(roles);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should get user roles', async () => {
|
|
370
|
+
const roles = [createMockKeycloakRole({ name: 'user' })];
|
|
371
|
+
mockKeycloakClient.getUserRoles.mockResolvedValue(roles);
|
|
372
|
+
const strapiUser = createMockStrapiUser();
|
|
373
|
+
|
|
374
|
+
const result = await service.getUserRoles('realm-123', 'kc-user-123', strapiUser);
|
|
375
|
+
|
|
376
|
+
expect(result).toEqual(roles);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('assignRoles / removeRoles', () => {
|
|
381
|
+
it('should assign roles and log action', async () => {
|
|
382
|
+
const user = createMockKeycloakUser();
|
|
383
|
+
mockKeycloakClient.getUserById.mockResolvedValue(user);
|
|
384
|
+
const roles = [createMockKeycloakRole({ name: 'admin' })];
|
|
385
|
+
const strapiUser = createMockStrapiUser();
|
|
386
|
+
|
|
387
|
+
await service.assignRoles('realm-123', 'kc-user-123', roles, strapiUser);
|
|
388
|
+
|
|
389
|
+
expect(mockKeycloakClient.assignRoles).toHaveBeenCalledWith(
|
|
390
|
+
expect.anything(),
|
|
391
|
+
'kc-user-123',
|
|
392
|
+
roles
|
|
393
|
+
);
|
|
394
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
395
|
+
expect.objectContaining({
|
|
396
|
+
action: AUDIT_ACTIONS.ASSIGN_ROLE,
|
|
397
|
+
details: { roles: ['admin'] },
|
|
398
|
+
})
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should remove roles and log action', async () => {
|
|
403
|
+
const user = createMockKeycloakUser();
|
|
404
|
+
mockKeycloakClient.getUserById.mockResolvedValue(user);
|
|
405
|
+
const roles = [createMockKeycloakRole({ name: 'admin' })];
|
|
406
|
+
const strapiUser = createMockStrapiUser();
|
|
407
|
+
|
|
408
|
+
await service.removeRoles('realm-123', 'kc-user-123', roles, strapiUser);
|
|
409
|
+
|
|
410
|
+
expect(mockKeycloakClient.removeRoles).toHaveBeenCalled();
|
|
411
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
412
|
+
expect.objectContaining({
|
|
413
|
+
action: AUDIT_ACTIONS.REMOVE_ROLE,
|
|
414
|
+
details: { roles: ['admin'] },
|
|
415
|
+
})
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should require canManageRoles permission', async () => {
|
|
420
|
+
mockPermissionService.canAccessRealm.mockResolvedValue(false);
|
|
421
|
+
const strapiUser = createMockStrapiUser();
|
|
422
|
+
|
|
423
|
+
await expect(
|
|
424
|
+
service.assignRoles('realm-123', 'kc-user-123', [], strapiUser)
|
|
425
|
+
).rejects.toThrow();
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe('bulkImport', () => {
|
|
430
|
+
it('should import users and track success/failure', async () => {
|
|
431
|
+
mockKeycloakClient.createUser
|
|
432
|
+
.mockResolvedValueOnce({ userId: 'user-1' })
|
|
433
|
+
.mockRejectedValueOnce({ sanitizedMessage: 'User exists' });
|
|
434
|
+
const strapiUser = createMockStrapiUser();
|
|
435
|
+
const users = [
|
|
436
|
+
{ username: 'user1', email: 'user1@test.com' },
|
|
437
|
+
{ username: 'user2', email: 'user2@test.com' },
|
|
438
|
+
];
|
|
439
|
+
|
|
440
|
+
const result = await service.bulkImport('realm-123', users, strapiUser);
|
|
441
|
+
|
|
442
|
+
expect(result.success).toHaveLength(1);
|
|
443
|
+
expect(result.failed).toHaveLength(1);
|
|
444
|
+
expect(result.success[0].userId).toBe('user-1');
|
|
445
|
+
expect(result.failed[0].error).toBe('User exists');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('should log bulk import action', async () => {
|
|
449
|
+
const strapiUser = createMockStrapiUser();
|
|
450
|
+
|
|
451
|
+
await service.bulkImport('realm-123', [{ username: 'test' }], strapiUser);
|
|
452
|
+
|
|
453
|
+
expect(mockAuditLogService.log).toHaveBeenCalledWith(
|
|
454
|
+
expect.objectContaining({
|
|
455
|
+
action: AUDIT_ACTIONS.BULK_IMPORT,
|
|
456
|
+
details: expect.objectContaining({
|
|
457
|
+
totalAttempted: 1,
|
|
458
|
+
}),
|
|
459
|
+
})
|
|
460
|
+
);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe('exportUsers', () => {
|
|
465
|
+
it('should fetch all users in batches', async () => {
|
|
466
|
+
// First batch returns full batch, second returns partial
|
|
467
|
+
mockKeycloakClient.getUsers
|
|
468
|
+
.mockResolvedValueOnce(Array(PAGINATION.EXPORT_BATCH_SIZE).fill(createMockKeycloakUser()))
|
|
469
|
+
.mockResolvedValueOnce([createMockKeycloakUser()]);
|
|
470
|
+
const strapiUser = createMockStrapiUser();
|
|
471
|
+
|
|
472
|
+
const result = await service.exportUsers('realm-123', strapiUser);
|
|
473
|
+
|
|
474
|
+
expect(result).toHaveLength(PAGINATION.EXPORT_BATCH_SIZE + 1);
|
|
475
|
+
expect(mockKeycloakClient.getUsers).toHaveBeenCalledTimes(2);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should stop fetching when batch is not full', async () => {
|
|
479
|
+
mockKeycloakClient.getUsers.mockResolvedValue([createMockKeycloakUser()]);
|
|
480
|
+
const strapiUser = createMockStrapiUser();
|
|
481
|
+
|
|
482
|
+
await service.exportUsers('realm-123', strapiUser);
|
|
483
|
+
|
|
484
|
+
expect(mockKeycloakClient.getUsers).toHaveBeenCalledTimes(1);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Unit tests for error utility functions.
|
|
3
|
+
* @module __tests__/utils/errors
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createSanitizedError,
|
|
8
|
+
getErrorMessage,
|
|
9
|
+
isSanitizedError,
|
|
10
|
+
} from '../../server/src/utils/errors.js';
|
|
11
|
+
|
|
12
|
+
describe('Error Utilities', () => {
|
|
13
|
+
describe('createSanitizedError', () => {
|
|
14
|
+
it('should create an error with the internal message', () => {
|
|
15
|
+
const internalMsg = 'Internal error: connection timeout to 10.0.0.1:5432';
|
|
16
|
+
const sanitizedMsg = 'Database connection failed.';
|
|
17
|
+
|
|
18
|
+
const error = createSanitizedError(internalMsg, sanitizedMsg);
|
|
19
|
+
|
|
20
|
+
expect(error).toBeInstanceOf(Error);
|
|
21
|
+
expect(error.message).toBe(internalMsg);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should attach the sanitized message property', () => {
|
|
25
|
+
const internalMsg = 'Token fetch failed for client admin-cli: 401';
|
|
26
|
+
const sanitizedMsg = 'Authentication failed.';
|
|
27
|
+
|
|
28
|
+
const error = createSanitizedError(internalMsg, sanitizedMsg);
|
|
29
|
+
|
|
30
|
+
expect(error.sanitizedMessage).toBe(sanitizedMsg);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should be throwable', () => {
|
|
34
|
+
const error = createSanitizedError('internal', 'safe');
|
|
35
|
+
|
|
36
|
+
expect(() => {
|
|
37
|
+
throw error;
|
|
38
|
+
}).toThrow('internal');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('getErrorMessage', () => {
|
|
43
|
+
it('should return sanitized message when available', () => {
|
|
44
|
+
const error = createSanitizedError(
|
|
45
|
+
'Internal details: user_id=123',
|
|
46
|
+
'User not found.'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const message = getErrorMessage(error);
|
|
50
|
+
|
|
51
|
+
expect(message).toBe('User not found.');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return original message when no sanitized message exists', () => {
|
|
55
|
+
const error = new Error('Standard error message');
|
|
56
|
+
|
|
57
|
+
const message = getErrorMessage(error);
|
|
58
|
+
|
|
59
|
+
expect(message).toBe('Standard error message');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should return fallback message when error has no message', () => {
|
|
63
|
+
const error = new Error();
|
|
64
|
+
error.message = '';
|
|
65
|
+
|
|
66
|
+
const message = getErrorMessage(error, 'Something went wrong');
|
|
67
|
+
|
|
68
|
+
expect(message).toBe('Something went wrong');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should use default fallback when no fallback provided', () => {
|
|
72
|
+
const error = new Error();
|
|
73
|
+
error.message = '';
|
|
74
|
+
|
|
75
|
+
const message = getErrorMessage(error);
|
|
76
|
+
|
|
77
|
+
expect(message).toBe('An unexpected error occurred');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('isSanitizedError', () => {
|
|
82
|
+
it('should return true for sanitized errors', () => {
|
|
83
|
+
const error = createSanitizedError('internal', 'safe');
|
|
84
|
+
|
|
85
|
+
expect(isSanitizedError(error)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should return false for regular errors', () => {
|
|
89
|
+
const error = new Error('Regular error');
|
|
90
|
+
|
|
91
|
+
expect(isSanitizedError(error)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should return false for null', () => {
|
|
95
|
+
expect(isSanitizedError(null)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should return false for undefined', () => {
|
|
99
|
+
expect(isSanitizedError(undefined)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should return false for non-string sanitizedMessage', () => {
|
|
103
|
+
const error = new Error('test');
|
|
104
|
+
error.sanitizedMessage = 123;
|
|
105
|
+
|
|
106
|
+
expect(isSanitizedError(error)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import pluginId from '../pluginId';
|
|
3
|
+
|
|
4
|
+
const Initializer = ({ setPlugin }) => {
|
|
5
|
+
const ref = useRef(setPlugin);
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
ref.current(pluginId);
|
|
9
|
+
}, []);
|
|
10
|
+
|
|
11
|
+
return null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default Initializer;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Badge } from '@strapi/design-system';
|
|
2
|
+
|
|
3
|
+
const RealmBadge = ({ realm, size = 'S' }) => {
|
|
4
|
+
if (!realm) return null;
|
|
5
|
+
|
|
6
|
+
return (
|
|
7
|
+
<Badge
|
|
8
|
+
size={size}
|
|
9
|
+
backgroundColor={realm.color || 'primary600'}
|
|
10
|
+
textColor="neutral0"
|
|
11
|
+
>
|
|
12
|
+
{realm.displayName || realm.name}
|
|
13
|
+
</Badge>
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default RealmBadge;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import pluginId from './pluginId';
|
|
2
|
+
|
|
3
|
+
export const SERVER_PLUGIN_NAME = 'strapi-plugin-keycloak-realm-users';
|
|
4
|
+
export const API_BASE_PATH = `/${SERVER_PLUGIN_NAME}`;
|
|
5
|
+
|
|
6
|
+
export const getTrad = (id) => `${pluginId}.${id}`;
|
|
7
|
+
|
|
8
|
+
export const PERMISSIONS = {
|
|
9
|
+
canRead: 'canRead',
|
|
10
|
+
canCreate: 'canCreate',
|
|
11
|
+
canUpdate: 'canUpdate',
|
|
12
|
+
canDelete: 'canDelete',
|
|
13
|
+
canManageRoles: 'canManageRoles',
|
|
14
|
+
};
|