@flusys/nestjs-iam 6.0.1 → 6.1.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/cjs/controllers/action.controller.js +17 -9
- package/cjs/controllers/action.controller.spec.js +79 -0
- package/cjs/controllers/company-action-permission.controller.spec.js +65 -0
- package/cjs/controllers/my-permission.controller.spec.js +56 -0
- package/cjs/controllers/role-permission.controller.spec.js +140 -0
- package/cjs/controllers/role.controller.spec.js +86 -0
- package/cjs/controllers/user-action-permission.controller.js +0 -3
- package/cjs/controllers/user-action-permission.controller.spec.js +68 -0
- package/cjs/docs/iam-swagger.config.js +6 -0
- package/cjs/docs/iam-swagger.config.spec.js +99 -0
- package/cjs/dtos/action.dto.js +17 -0
- package/cjs/entities/index.spec.js +56 -0
- package/cjs/helpers/company-access.helper.spec.js +68 -0
- package/cjs/helpers/permission-mode.helper.spec.js +54 -0
- package/cjs/modules/iam.module.js +3 -1
- package/cjs/modules/iam.module.spec.js +293 -0
- package/cjs/services/action.service.js +90 -7
- package/cjs/services/action.service.spec.js +314 -0
- package/cjs/services/iam-config.service.spec.js +110 -0
- package/cjs/services/iam-datasource.service.spec.js +117 -0
- package/cjs/services/permission-cache.service.js +113 -110
- package/cjs/services/permission-cache.service.spec.js +252 -0
- package/cjs/services/permission.service.js +213 -92
- package/cjs/services/permission.service.spec.js +967 -0
- package/cjs/services/role.service.js +47 -3
- package/cjs/services/role.service.spec.js +312 -0
- package/controllers/action.controller.d.ts +5 -3
- package/dtos/action.dto.d.ts +3 -0
- package/entities/index.d.ts +2 -2
- package/fesm/controllers/action.controller.js +18 -10
- package/fesm/controllers/action.controller.spec.js +75 -0
- package/fesm/controllers/company-action-permission.controller.spec.js +61 -0
- package/fesm/controllers/my-permission.controller.spec.js +52 -0
- package/fesm/controllers/role-permission.controller.spec.js +136 -0
- package/fesm/controllers/role.controller.spec.js +82 -0
- package/fesm/controllers/user-action-permission.controller.js +0 -3
- package/fesm/controllers/user-action-permission.controller.spec.js +64 -0
- package/fesm/docs/iam-swagger.config.js +6 -0
- package/fesm/docs/iam-swagger.config.spec.js +95 -0
- package/fesm/dtos/action.dto.js +14 -0
- package/fesm/entities/index.spec.js +52 -0
- package/fesm/helpers/company-access.helper.spec.js +64 -0
- package/fesm/helpers/permission-mode.helper.spec.js +50 -0
- package/fesm/modules/iam.module.js +4 -2
- package/fesm/modules/iam.module.spec.js +289 -0
- package/fesm/services/action.service.js +90 -7
- package/fesm/services/action.service.spec.js +310 -0
- package/fesm/services/iam-config.service.spec.js +106 -0
- package/fesm/services/iam-datasource.service.spec.js +113 -0
- package/fesm/services/permission-cache.service.js +101 -108
- package/fesm/services/permission-cache.service.spec.js +248 -0
- package/fesm/services/permission.service.js +213 -92
- package/fesm/services/permission.service.spec.js +963 -0
- package/fesm/services/role.service.js +48 -4
- package/fesm/services/role.service.spec.js +308 -0
- package/package.json +3 -3
- package/services/action.service.d.ts +7 -3
- package/services/permission-cache.service.d.ts +13 -19
- package/services/permission.service.d.ts +6 -3
- package/services/role.service.d.ts +7 -3
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
const _testing = require("@nestjs/testing");
|
|
6
|
+
const _repositorymock = require("@test-utils/mocks/repository.mock");
|
|
7
|
+
const _config = require("../config");
|
|
8
|
+
const _permissiondto = require("../dtos/permission.dto");
|
|
9
|
+
const _actionentity = require("../entities/action.entity");
|
|
10
|
+
const _roleentity = require("../entities/role.entity");
|
|
11
|
+
const _rolewithcompanyentity = require("../entities/role-with-company.entity");
|
|
12
|
+
const _actiontypeenum = require("../enums/action-type.enum");
|
|
13
|
+
const _iamentitytypeenum = require("../enums/iam-entity-type.enum");
|
|
14
|
+
const _iampermissiontypeenum = require("../enums/iam-permission-type.enum");
|
|
15
|
+
const _permissiontypeenum = require("../enums/permission-type.enum");
|
|
16
|
+
const _iamconfigservice = require("./iam-config.service");
|
|
17
|
+
const _iamdatasourceservice = require("./iam-datasource.service");
|
|
18
|
+
const _permissioncacheservice = require("./permission-cache.service");
|
|
19
|
+
const _permissionservice = require("./permission.service");
|
|
20
|
+
function buildPermissionRow(overrides = {}) {
|
|
21
|
+
const row = {
|
|
22
|
+
id: 'perm-uuid-1',
|
|
23
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.USER_ACTION,
|
|
24
|
+
sourceType: _iamentitytypeenum.IamEntityType.USER,
|
|
25
|
+
sourceId: 'user-1',
|
|
26
|
+
targetType: _iamentitytypeenum.IamEntityType.ACTION,
|
|
27
|
+
targetId: 'action-1',
|
|
28
|
+
userId: 'user-1',
|
|
29
|
+
companyId: null,
|
|
30
|
+
branchId: null,
|
|
31
|
+
validFrom: null,
|
|
32
|
+
validUntil: null,
|
|
33
|
+
reason: null,
|
|
34
|
+
createdAt: new Date(),
|
|
35
|
+
...overrides
|
|
36
|
+
};
|
|
37
|
+
row.isValid = (now = new Date())=>{
|
|
38
|
+
if (row.validFrom && now < row.validFrom) return false;
|
|
39
|
+
if (row.validUntil && now > row.validUntil) return false;
|
|
40
|
+
return true;
|
|
41
|
+
};
|
|
42
|
+
return row;
|
|
43
|
+
}
|
|
44
|
+
function buildDeleteResult(affected) {
|
|
45
|
+
return {
|
|
46
|
+
affected,
|
|
47
|
+
raw: []
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function mockRawMany(repo, rows) {
|
|
51
|
+
const builder = {
|
|
52
|
+
select: jest.fn().mockReturnThis(),
|
|
53
|
+
addSelect: jest.fn().mockReturnThis(),
|
|
54
|
+
where: jest.fn().mockReturnThis(),
|
|
55
|
+
andWhere: jest.fn().mockReturnThis(),
|
|
56
|
+
getRawMany: jest.fn().mockResolvedValue(rows)
|
|
57
|
+
};
|
|
58
|
+
repo.createQueryBuilder.mockReturnValue(builder);
|
|
59
|
+
return builder;
|
|
60
|
+
}
|
|
61
|
+
function buildAction(overrides = {}) {
|
|
62
|
+
return {
|
|
63
|
+
id: 'action-1',
|
|
64
|
+
name: 'View Users',
|
|
65
|
+
code: 'user.view',
|
|
66
|
+
description: null,
|
|
67
|
+
actionType: _actiontypeenum.ActionType.BACKEND,
|
|
68
|
+
parentId: null,
|
|
69
|
+
isActive: true,
|
|
70
|
+
...overrides
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
describe('PermissionService', ()=>{
|
|
74
|
+
let service;
|
|
75
|
+
let mockPermissionRepo;
|
|
76
|
+
let mockActionRepo;
|
|
77
|
+
let mockRoleRepo;
|
|
78
|
+
let mockIamConfigService;
|
|
79
|
+
let mockPermissionCacheService;
|
|
80
|
+
let mockDataSourceProvider;
|
|
81
|
+
beforeEach(async ()=>{
|
|
82
|
+
mockPermissionRepo = (0, _repositorymock.createMockRepository)();
|
|
83
|
+
mockActionRepo = (0, _repositorymock.createMockRepository)();
|
|
84
|
+
mockRoleRepo = (0, _repositorymock.createMockRepository)();
|
|
85
|
+
mockDataSourceProvider = {
|
|
86
|
+
getRepository: jest.fn((entity)=>{
|
|
87
|
+
if (entity === _actionentity.Action) return Promise.resolve(mockActionRepo);
|
|
88
|
+
if (entity === _roleentity.Role || entity === _rolewithcompanyentity.RoleWithCompany) return Promise.resolve(mockRoleRepo);
|
|
89
|
+
return Promise.resolve(mockPermissionRepo);
|
|
90
|
+
}),
|
|
91
|
+
getDataSource: jest.fn()
|
|
92
|
+
};
|
|
93
|
+
mockIamConfigService = {
|
|
94
|
+
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false),
|
|
95
|
+
isDirectPermissionEnabled: jest.fn().mockReturnValue(true),
|
|
96
|
+
isRbacEnabled: jest.fn().mockReturnValue(true),
|
|
97
|
+
getPermissionMode: jest.fn().mockReturnValue(_permissiontypeenum.IAMPermissionMode.FULL)
|
|
98
|
+
};
|
|
99
|
+
mockPermissionCacheService = {
|
|
100
|
+
invalidateUser: jest.fn(),
|
|
101
|
+
invalidateUsers: jest.fn(),
|
|
102
|
+
getMyPermissions: jest.fn(),
|
|
103
|
+
setMyPermissions: jest.fn()
|
|
104
|
+
};
|
|
105
|
+
const module = await _testing.Test.createTestingModule({
|
|
106
|
+
providers: [
|
|
107
|
+
_permissionservice.PermissionService,
|
|
108
|
+
{
|
|
109
|
+
provide: _permissioncacheservice.PermissionCacheService,
|
|
110
|
+
useValue: mockPermissionCacheService
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
provide: _iamconfigservice.IAMConfigService,
|
|
114
|
+
useValue: mockIamConfigService
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
provide: _iamdatasourceservice.IAMDataSourceService,
|
|
118
|
+
useValue: mockDataSourceProvider
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}).compile();
|
|
122
|
+
service = await module.resolve(_permissionservice.PermissionService);
|
|
123
|
+
});
|
|
124
|
+
// ==================== User-Action Permissions ====================
|
|
125
|
+
describe('assignUserActions', ()=>{
|
|
126
|
+
const dto = {
|
|
127
|
+
userId: 'user-1',
|
|
128
|
+
items: [
|
|
129
|
+
{
|
|
130
|
+
id: 'action-1',
|
|
131
|
+
action: _permissiondto.PermissionAction.ADD
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'action-2',
|
|
135
|
+
action: _permissiondto.PermissionAction.ADD
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'action-3',
|
|
139
|
+
action: _permissiondto.PermissionAction.REMOVE
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
};
|
|
143
|
+
it('throws BadRequestException when direct permissions are disabled (RBAC-only mode)', async ()=>{
|
|
144
|
+
mockIamConfigService.isDirectPermissionEnabled.mockReturnValue(false);
|
|
145
|
+
await expect(service.assignUserActions(dto)).rejects.toMatchObject({
|
|
146
|
+
response: expect.objectContaining({
|
|
147
|
+
messageKey: _config.IAM_MODE_MESSAGES.DIRECT_MODE_UNAVAILABLE
|
|
148
|
+
})
|
|
149
|
+
});
|
|
150
|
+
expect(mockPermissionRepo.find).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
it('adds only actions not already assigned, removes requested actions, and invalidates the user cache', async ()=>{
|
|
153
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
154
|
+
{
|
|
155
|
+
targetId: 'action-1'
|
|
156
|
+
}
|
|
157
|
+
]);
|
|
158
|
+
mockPermissionRepo.save.mockResolvedValue([
|
|
159
|
+
{}
|
|
160
|
+
]);
|
|
161
|
+
mockPermissionRepo.delete.mockResolvedValue(buildDeleteResult(1));
|
|
162
|
+
const result = await service.assignUserActions(dto);
|
|
163
|
+
expect(mockPermissionRepo.save).toHaveBeenCalledWith([
|
|
164
|
+
expect.objectContaining({
|
|
165
|
+
targetId: 'action-2',
|
|
166
|
+
companyId: null,
|
|
167
|
+
branchId: null
|
|
168
|
+
})
|
|
169
|
+
]);
|
|
170
|
+
expect(result).toEqual({
|
|
171
|
+
added: 1,
|
|
172
|
+
removed: 1,
|
|
173
|
+
total: 3
|
|
174
|
+
});
|
|
175
|
+
expect(mockPermissionCacheService.invalidateUser).toHaveBeenCalledWith('user-1', null, [
|
|
176
|
+
null
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
it('scopes new permissions and cache invalidation to the company/branch when the company feature is enabled', async ()=>{
|
|
180
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
181
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
182
|
+
mockPermissionRepo.save.mockResolvedValue([
|
|
183
|
+
{},
|
|
184
|
+
{}
|
|
185
|
+
]);
|
|
186
|
+
mockPermissionRepo.delete.mockResolvedValue(buildDeleteResult(1));
|
|
187
|
+
await service.assignUserActions({
|
|
188
|
+
userId: 'user-1',
|
|
189
|
+
companyId: 'company-1',
|
|
190
|
+
branchId: 'branch-1',
|
|
191
|
+
items: dto.items
|
|
192
|
+
});
|
|
193
|
+
expect(mockPermissionRepo.find).toHaveBeenCalledWith(expect.objectContaining({
|
|
194
|
+
where: expect.objectContaining({
|
|
195
|
+
companyId: 'company-1',
|
|
196
|
+
branchId: 'branch-1'
|
|
197
|
+
})
|
|
198
|
+
}));
|
|
199
|
+
expect(mockPermissionRepo.save).toHaveBeenCalledWith([
|
|
200
|
+
expect.objectContaining({
|
|
201
|
+
companyId: 'company-1',
|
|
202
|
+
branchId: 'branch-1'
|
|
203
|
+
}),
|
|
204
|
+
expect.objectContaining({
|
|
205
|
+
companyId: 'company-1',
|
|
206
|
+
branchId: 'branch-1'
|
|
207
|
+
})
|
|
208
|
+
]);
|
|
209
|
+
expect(mockPermissionCacheService.invalidateUser).toHaveBeenCalledWith('user-1', 'company-1', [
|
|
210
|
+
'branch-1'
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
it('translates a duplicate-entry DB error into ConflictException', async ()=>{
|
|
214
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
215
|
+
mockPermissionRepo.save.mockRejectedValue({
|
|
216
|
+
code: 'ER_DUP_ENTRY'
|
|
217
|
+
});
|
|
218
|
+
await expect(service.assignUserActions(dto)).rejects.toMatchObject({
|
|
219
|
+
response: expect.objectContaining({
|
|
220
|
+
messageKey: _config.PERMISSION_OPERATION_MESSAGES.ALREADY_EXISTS
|
|
221
|
+
})
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
it('rethrows unknown errors from save as-is', async ()=>{
|
|
225
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
226
|
+
const unexpected = new Error('connection lost');
|
|
227
|
+
mockPermissionRepo.save.mockRejectedValue(unexpected);
|
|
228
|
+
await expect(service.assignUserActions(dto)).rejects.toBe(unexpected);
|
|
229
|
+
});
|
|
230
|
+
it('does nothing but still invalidates the cache when items is empty', async ()=>{
|
|
231
|
+
const result = await service.assignUserActions({
|
|
232
|
+
userId: 'user-1',
|
|
233
|
+
items: []
|
|
234
|
+
});
|
|
235
|
+
expect(result).toEqual({
|
|
236
|
+
added: 0,
|
|
237
|
+
removed: 0,
|
|
238
|
+
total: 0
|
|
239
|
+
});
|
|
240
|
+
expect(mockPermissionRepo.save).not.toHaveBeenCalled();
|
|
241
|
+
expect(mockPermissionRepo.delete).not.toHaveBeenCalled();
|
|
242
|
+
expect(mockPermissionCacheService.invalidateUser).toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
describe('getUserActions', ()=>{
|
|
246
|
+
it('returns an empty array without querying actions when the user has no permissions', async ()=>{
|
|
247
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
248
|
+
const result = await service.getUserActions('user-1', undefined);
|
|
249
|
+
expect(result).toEqual([]);
|
|
250
|
+
expect(mockActionRepo.find).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
it('joins permissions with their actions and filters out permissions for deleted actions', async ()=>{
|
|
253
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
254
|
+
buildPermissionRow({
|
|
255
|
+
targetId: 'action-1'
|
|
256
|
+
}),
|
|
257
|
+
buildPermissionRow({
|
|
258
|
+
id: 'perm-2',
|
|
259
|
+
targetId: 'action-missing'
|
|
260
|
+
})
|
|
261
|
+
]);
|
|
262
|
+
mockActionRepo.find.mockResolvedValue([
|
|
263
|
+
buildAction({
|
|
264
|
+
id: 'action-1'
|
|
265
|
+
})
|
|
266
|
+
]);
|
|
267
|
+
const result = await service.getUserActions('user-1', undefined);
|
|
268
|
+
expect(result).toHaveLength(1);
|
|
269
|
+
expect(result[0]).toEqual(expect.objectContaining({
|
|
270
|
+
actionId: 'action-1',
|
|
271
|
+
actionCode: 'user.view'
|
|
272
|
+
}));
|
|
273
|
+
});
|
|
274
|
+
it('filters by companyId/branchId when the company feature is enabled', async ()=>{
|
|
275
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
276
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
277
|
+
await service.getUserActions('user-1', 'branch-1', 'company-1');
|
|
278
|
+
expect(mockPermissionRepo.find).toHaveBeenCalledWith({
|
|
279
|
+
where: expect.objectContaining({
|
|
280
|
+
companyId: 'company-1',
|
|
281
|
+
branchId: 'branch-1'
|
|
282
|
+
})
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
// ==================== Role-Action Permissions ====================
|
|
287
|
+
describe('assignRoleActions', ()=>{
|
|
288
|
+
const dto = {
|
|
289
|
+
roleId: 'role-1',
|
|
290
|
+
items: [
|
|
291
|
+
{
|
|
292
|
+
id: 'action-1',
|
|
293
|
+
action: _permissiondto.PermissionAction.ADD
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: 'action-2',
|
|
297
|
+
action: _permissiondto.PermissionAction.REMOVE
|
|
298
|
+
}
|
|
299
|
+
]
|
|
300
|
+
};
|
|
301
|
+
it('throws BadRequestException when RBAC is disabled (DIRECT-only mode)', async ()=>{
|
|
302
|
+
mockIamConfigService.isRbacEnabled.mockReturnValue(false);
|
|
303
|
+
await expect(service.assignRoleActions(dto)).rejects.toMatchObject({
|
|
304
|
+
response: expect.objectContaining({
|
|
305
|
+
messageKey: _config.IAM_MODE_MESSAGES.RBAC_MODE_UNAVAILABLE
|
|
306
|
+
})
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
it('resolves the role company scope from the role repository when the company feature is enabled', async ()=>{
|
|
310
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
311
|
+
mockRoleRepo.findOne.mockResolvedValue({
|
|
312
|
+
id: 'role-1',
|
|
313
|
+
companyId: 'company-9'
|
|
314
|
+
});
|
|
315
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
316
|
+
mockPermissionRepo.save.mockResolvedValue([
|
|
317
|
+
{}
|
|
318
|
+
]);
|
|
319
|
+
mockPermissionRepo.delete.mockResolvedValue(buildDeleteResult(1));
|
|
320
|
+
await service.assignRoleActions(dto);
|
|
321
|
+
expect(mockPermissionRepo.save).toHaveBeenCalledWith([
|
|
322
|
+
expect.objectContaining({
|
|
323
|
+
companyId: 'company-9'
|
|
324
|
+
})
|
|
325
|
+
]);
|
|
326
|
+
});
|
|
327
|
+
it('translates duplicate role-action entries into ConflictException', async ()=>{
|
|
328
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
329
|
+
mockPermissionRepo.save.mockRejectedValue({
|
|
330
|
+
message: 'Duplicate entry for key'
|
|
331
|
+
});
|
|
332
|
+
await expect(service.assignRoleActions(dto)).rejects.toMatchObject({
|
|
333
|
+
response: expect.objectContaining({
|
|
334
|
+
messageKey: _config.PERMISSION_OPERATION_MESSAGES.ALREADY_EXISTS
|
|
335
|
+
})
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
it('removes role actions and reports counts and invalidates role member caches', async ()=>{
|
|
339
|
+
mockPermissionRepo.find.mockResolvedValueOnce([]) // existing check for add
|
|
340
|
+
.mockResolvedValueOnce([
|
|
341
|
+
buildPermissionRow({
|
|
342
|
+
sourceId: 'user-9'
|
|
343
|
+
})
|
|
344
|
+
]); // invalidateRoleMembersCache lookup (sourceId = the user who holds the role)
|
|
345
|
+
mockPermissionRepo.save.mockResolvedValue([
|
|
346
|
+
{}
|
|
347
|
+
]);
|
|
348
|
+
mockPermissionRepo.delete.mockResolvedValue(buildDeleteResult(1));
|
|
349
|
+
mockRoleRepo.findOne.mockResolvedValue({
|
|
350
|
+
id: 'role-1',
|
|
351
|
+
companyId: null
|
|
352
|
+
});
|
|
353
|
+
const result = await service.assignRoleActions(dto);
|
|
354
|
+
expect(result).toEqual({
|
|
355
|
+
added: 1,
|
|
356
|
+
removed: 1,
|
|
357
|
+
total: 2
|
|
358
|
+
});
|
|
359
|
+
expect(mockPermissionCacheService.invalidateUsers).toHaveBeenCalledWith([
|
|
360
|
+
'user-9'
|
|
361
|
+
], null, [
|
|
362
|
+
null
|
|
363
|
+
]);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
describe('getRoleActions', ()=>{
|
|
367
|
+
it('returns an empty array when the role has no actions assigned', async ()=>{
|
|
368
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
369
|
+
const result = await service.getRoleActions('role-1');
|
|
370
|
+
expect(result).toEqual([]);
|
|
371
|
+
});
|
|
372
|
+
it('maps role-action permissions to their action details', async ()=>{
|
|
373
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
374
|
+
buildPermissionRow({
|
|
375
|
+
sourceId: 'role-1',
|
|
376
|
+
targetId: 'action-1'
|
|
377
|
+
})
|
|
378
|
+
]);
|
|
379
|
+
mockActionRepo.find.mockResolvedValue([
|
|
380
|
+
buildAction({
|
|
381
|
+
id: 'action-1'
|
|
382
|
+
})
|
|
383
|
+
]);
|
|
384
|
+
const result = await service.getRoleActions('role-1');
|
|
385
|
+
expect(result).toEqual([
|
|
386
|
+
expect.objectContaining({
|
|
387
|
+
roleId: 'role-1',
|
|
388
|
+
actionCode: 'user.view'
|
|
389
|
+
})
|
|
390
|
+
]);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
// ==================== Company-Action Permissions ====================
|
|
394
|
+
describe('assignCompanyActions', ()=>{
|
|
395
|
+
function mockTransaction() {
|
|
396
|
+
const fakeManager = {
|
|
397
|
+
getRepository: jest.fn((entity)=>{
|
|
398
|
+
if (entity === _roleentity.Role || entity === _rolewithcompanyentity.RoleWithCompany) return mockRoleRepo;
|
|
399
|
+
return mockPermissionRepo;
|
|
400
|
+
})
|
|
401
|
+
};
|
|
402
|
+
mockPermissionRepo.manager.connection.transaction = jest.fn().mockImplementation((cb)=>cb(fakeManager));
|
|
403
|
+
return fakeManager;
|
|
404
|
+
}
|
|
405
|
+
it('adds new company-whitelisted actions inside a transaction, skipping duplicates', async ()=>{
|
|
406
|
+
mockTransaction();
|
|
407
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
408
|
+
{
|
|
409
|
+
targetId: 'action-1'
|
|
410
|
+
}
|
|
411
|
+
]);
|
|
412
|
+
mockPermissionRepo.create.mockImplementation((data)=>data);
|
|
413
|
+
mockPermissionRepo.save.mockResolvedValue([
|
|
414
|
+
{}
|
|
415
|
+
]);
|
|
416
|
+
const result = await service.assignCompanyActions({
|
|
417
|
+
companyId: 'company-1',
|
|
418
|
+
items: [
|
|
419
|
+
{
|
|
420
|
+
id: 'action-1',
|
|
421
|
+
action: _permissiondto.PermissionAction.ADD
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
id: 'action-2',
|
|
425
|
+
action: _permissiondto.PermissionAction.ADD
|
|
426
|
+
}
|
|
427
|
+
]
|
|
428
|
+
});
|
|
429
|
+
expect(mockPermissionRepo.save).toHaveBeenCalledWith([
|
|
430
|
+
expect.objectContaining({
|
|
431
|
+
targetId: 'action-2',
|
|
432
|
+
sourceId: 'company-1'
|
|
433
|
+
})
|
|
434
|
+
]);
|
|
435
|
+
expect(result.added).toBe(1);
|
|
436
|
+
expect(mockPermissionCacheService.invalidateUsers).not.toHaveBeenCalled(); // no company feature -> invalidateCompanyMembersCache no-ops
|
|
437
|
+
});
|
|
438
|
+
it('cascades removal to role-action and user-action permissions for the company', async ()=>{
|
|
439
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
440
|
+
mockTransaction();
|
|
441
|
+
mockPermissionRepo.delete.mockResolvedValue(buildDeleteResult(2));
|
|
442
|
+
mockRoleRepo.find.mockResolvedValue([
|
|
443
|
+
{
|
|
444
|
+
id: 'role-1'
|
|
445
|
+
}
|
|
446
|
+
]);
|
|
447
|
+
mockRawMany(mockPermissionRepo, []);
|
|
448
|
+
const result = await service.assignCompanyActions({
|
|
449
|
+
companyId: 'company-1',
|
|
450
|
+
items: [
|
|
451
|
+
{
|
|
452
|
+
id: 'action-1',
|
|
453
|
+
action: _permissiondto.PermissionAction.REMOVE
|
|
454
|
+
}
|
|
455
|
+
]
|
|
456
|
+
});
|
|
457
|
+
expect(mockPermissionRepo.delete).toHaveBeenCalledWith(expect.objectContaining({
|
|
458
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.COMPANY_ACTION
|
|
459
|
+
}));
|
|
460
|
+
expect(mockPermissionRepo.delete).toHaveBeenCalledWith(expect.objectContaining({
|
|
461
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.ROLE_ACTION
|
|
462
|
+
}));
|
|
463
|
+
expect(mockPermissionRepo.delete).toHaveBeenCalledWith(expect.objectContaining({
|
|
464
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.USER_ACTION
|
|
465
|
+
}));
|
|
466
|
+
expect(result.removed).toBe(2);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
describe('getCompanyActions / getCompanyActionIds', ()=>{
|
|
470
|
+
it('getCompanyActions returns an empty array when the company has no whitelist entries', async ()=>{
|
|
471
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
472
|
+
expect(await service.getCompanyActions('company-1')).toEqual([]);
|
|
473
|
+
});
|
|
474
|
+
it('getCompanyActions maps whitelist entries to action details', async ()=>{
|
|
475
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
476
|
+
buildPermissionRow({
|
|
477
|
+
sourceType: _iamentitytypeenum.IamEntityType.COMPANY,
|
|
478
|
+
sourceId: 'company-1',
|
|
479
|
+
targetId: 'action-1'
|
|
480
|
+
})
|
|
481
|
+
]);
|
|
482
|
+
mockActionRepo.find.mockResolvedValue([
|
|
483
|
+
buildAction({
|
|
484
|
+
id: 'action-1'
|
|
485
|
+
})
|
|
486
|
+
]);
|
|
487
|
+
const result = await service.getCompanyActions('company-1');
|
|
488
|
+
expect(result).toEqual([
|
|
489
|
+
expect.objectContaining({
|
|
490
|
+
companyId: 'company-1',
|
|
491
|
+
actionCode: 'user.view'
|
|
492
|
+
})
|
|
493
|
+
]);
|
|
494
|
+
});
|
|
495
|
+
it('getCompanyActionIds returns just the whitelisted action ids', async ()=>{
|
|
496
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
497
|
+
{
|
|
498
|
+
targetId: 'action-1'
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
targetId: 'action-2'
|
|
502
|
+
}
|
|
503
|
+
]);
|
|
504
|
+
const result = await service.getCompanyActionIds('company-1');
|
|
505
|
+
expect(result).toEqual([
|
|
506
|
+
'action-1',
|
|
507
|
+
'action-2'
|
|
508
|
+
]);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
// ==================== User-Role Permissions ====================
|
|
512
|
+
describe('assignUserRoles', ()=>{
|
|
513
|
+
const dto = {
|
|
514
|
+
userId: 'user-1',
|
|
515
|
+
items: [
|
|
516
|
+
{
|
|
517
|
+
id: 'role-1',
|
|
518
|
+
action: _permissiondto.PermissionAction.ADD
|
|
519
|
+
}
|
|
520
|
+
]
|
|
521
|
+
};
|
|
522
|
+
it('throws BadRequestException when RBAC is disabled (DIRECT-only mode)', async ()=>{
|
|
523
|
+
mockIamConfigService.isRbacEnabled.mockReturnValue(false);
|
|
524
|
+
await expect(service.assignUserRoles(dto)).rejects.toMatchObject({
|
|
525
|
+
response: expect.objectContaining({
|
|
526
|
+
messageKey: _config.IAM_MODE_MESSAGES.ROLE_ASSIGNMENT_UNAVAILABLE
|
|
527
|
+
})
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
it('adds new role assignments and invalidates the user cache', async ()=>{
|
|
531
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
532
|
+
mockPermissionRepo.save.mockResolvedValue([
|
|
533
|
+
{}
|
|
534
|
+
]);
|
|
535
|
+
const result = await service.assignUserRoles(dto);
|
|
536
|
+
expect(result).toEqual({
|
|
537
|
+
added: 1,
|
|
538
|
+
removed: 0,
|
|
539
|
+
total: 1
|
|
540
|
+
});
|
|
541
|
+
expect(mockPermissionCacheService.invalidateUser).toHaveBeenCalledWith('user-1', null, [
|
|
542
|
+
null
|
|
543
|
+
]);
|
|
544
|
+
});
|
|
545
|
+
it('translates duplicate user-role entries into ConflictException', async ()=>{
|
|
546
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
547
|
+
mockPermissionRepo.save.mockRejectedValue({
|
|
548
|
+
code: 'ER_DUP_ENTRY'
|
|
549
|
+
});
|
|
550
|
+
await expect(service.assignUserRoles(dto)).rejects.toMatchObject({
|
|
551
|
+
response: expect.objectContaining({
|
|
552
|
+
messageKey: _config.PERMISSION_OPERATION_MESSAGES.ALREADY_EXISTS
|
|
553
|
+
})
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
describe('getUserRoles', ()=>{
|
|
558
|
+
it('returns an empty array when the user has no role assignments', async ()=>{
|
|
559
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
560
|
+
expect(await service.getUserRoles('user-1')).toEqual([]);
|
|
561
|
+
expect(mockRoleRepo.find).not.toHaveBeenCalled();
|
|
562
|
+
});
|
|
563
|
+
it('joins role permissions with role details and filters out permissions for deleted roles', async ()=>{
|
|
564
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
565
|
+
buildPermissionRow({
|
|
566
|
+
targetId: 'role-1',
|
|
567
|
+
targetType: _iamentitytypeenum.IamEntityType.ROLE
|
|
568
|
+
}),
|
|
569
|
+
buildPermissionRow({
|
|
570
|
+
id: 'perm-2',
|
|
571
|
+
targetId: 'role-missing',
|
|
572
|
+
targetType: _iamentitytypeenum.IamEntityType.ROLE
|
|
573
|
+
})
|
|
574
|
+
]);
|
|
575
|
+
mockRoleRepo.find.mockResolvedValue([
|
|
576
|
+
{
|
|
577
|
+
id: 'role-1',
|
|
578
|
+
name: 'Manager'
|
|
579
|
+
}
|
|
580
|
+
]);
|
|
581
|
+
const result = await service.getUserRoles('user-1');
|
|
582
|
+
expect(result).toHaveLength(1);
|
|
583
|
+
expect(result[0]).toEqual(expect.objectContaining({
|
|
584
|
+
roleId: 'role-1',
|
|
585
|
+
roleName: 'Manager'
|
|
586
|
+
}));
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
// ==================== My Permissions / permission-mode branching ====================
|
|
590
|
+
describe('getMyPermissions', ()=>{
|
|
591
|
+
it('returns the response built directly from cached data on a cache hit', async ()=>{
|
|
592
|
+
mockPermissionCacheService.getMyPermissions.mockResolvedValue({
|
|
593
|
+
frontendActions: [
|
|
594
|
+
{
|
|
595
|
+
id: 'action-1',
|
|
596
|
+
code: 'user.view',
|
|
597
|
+
name: 'View',
|
|
598
|
+
description: null,
|
|
599
|
+
parentId: null
|
|
600
|
+
}
|
|
601
|
+
],
|
|
602
|
+
backendCodes: [
|
|
603
|
+
'user.read'
|
|
604
|
+
]
|
|
605
|
+
});
|
|
606
|
+
const result = await service.getMyPermissions('user-1', null, 'company-1');
|
|
607
|
+
expect(result.frontendActions).toHaveLength(1);
|
|
608
|
+
expect(result.cachedEndpoints).toBe(1);
|
|
609
|
+
expect(mockPermissionRepo.find).not.toHaveBeenCalled();
|
|
610
|
+
});
|
|
611
|
+
it('fetches from the database and caches the result on a cache miss', async ()=>{
|
|
612
|
+
mockPermissionCacheService.getMyPermissions.mockResolvedValue(null);
|
|
613
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
614
|
+
const result = await service.getMyPermissions('user-1', null, null);
|
|
615
|
+
expect(result).toEqual({
|
|
616
|
+
frontendActions: [],
|
|
617
|
+
cachedEndpoints: 0
|
|
618
|
+
});
|
|
619
|
+
expect(mockPermissionCacheService.setMyPermissions).toHaveBeenCalled();
|
|
620
|
+
});
|
|
621
|
+
it('filters frontend actions by parentCodes when provided', async ()=>{
|
|
622
|
+
mockPermissionCacheService.getMyPermissions.mockResolvedValue({
|
|
623
|
+
frontendActions: [
|
|
624
|
+
{
|
|
625
|
+
id: 'child-1',
|
|
626
|
+
code: 'user.view',
|
|
627
|
+
name: 'View',
|
|
628
|
+
description: null,
|
|
629
|
+
parentId: 'parent-1'
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
id: 'child-2',
|
|
633
|
+
code: 'other.view',
|
|
634
|
+
name: 'View 2',
|
|
635
|
+
description: null,
|
|
636
|
+
parentId: 'other-parent'
|
|
637
|
+
}
|
|
638
|
+
],
|
|
639
|
+
backendCodes: []
|
|
640
|
+
});
|
|
641
|
+
mockActionRepo.find.mockResolvedValue([
|
|
642
|
+
{
|
|
643
|
+
id: 'parent-1',
|
|
644
|
+
code: 'user'
|
|
645
|
+
}
|
|
646
|
+
]);
|
|
647
|
+
const result = await service.getMyPermissions('user-1', null, null, [
|
|
648
|
+
'user'
|
|
649
|
+
]);
|
|
650
|
+
expect(result.frontendActions).toHaveLength(1);
|
|
651
|
+
expect(result.frontendActions[0].id).toBe('child-1');
|
|
652
|
+
});
|
|
653
|
+
it('returns an empty frontendActions list when parentCodes resolve to no known parents', async ()=>{
|
|
654
|
+
mockPermissionCacheService.getMyPermissions.mockResolvedValue({
|
|
655
|
+
frontendActions: [
|
|
656
|
+
{
|
|
657
|
+
id: 'child-1',
|
|
658
|
+
code: 'user.view',
|
|
659
|
+
name: 'View',
|
|
660
|
+
description: null,
|
|
661
|
+
parentId: 'parent-1'
|
|
662
|
+
}
|
|
663
|
+
],
|
|
664
|
+
backendCodes: []
|
|
665
|
+
});
|
|
666
|
+
mockActionRepo.find.mockResolvedValue([]);
|
|
667
|
+
const result = await service.getMyPermissions('user-1', null, null, [
|
|
668
|
+
'unknown-parent-code'
|
|
669
|
+
]);
|
|
670
|
+
expect(result.frontendActions).toEqual([]);
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
describe('collectAllActionIds (permission-mode branching)', ()=>{
|
|
674
|
+
it('RBAC mode: collects only role-derived action ids, ignoring direct user actions', async ()=>{
|
|
675
|
+
mockIamConfigService.getPermissionMode.mockReturnValue(_permissiontypeenum.IAMPermissionMode.RBAC);
|
|
676
|
+
mockPermissionRepo.find.mockImplementation(async ({ where })=>{
|
|
677
|
+
if (where.permissionType === _iampermissiontypeenum.IamPermissionType.USER_ROLE) {
|
|
678
|
+
return [
|
|
679
|
+
buildPermissionRow({
|
|
680
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.USER_ROLE,
|
|
681
|
+
targetId: 'role-1'
|
|
682
|
+
})
|
|
683
|
+
];
|
|
684
|
+
}
|
|
685
|
+
if (where.permissionType === _iampermissiontypeenum.IamPermissionType.ROLE_ACTION) {
|
|
686
|
+
return [
|
|
687
|
+
buildPermissionRow({
|
|
688
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.ROLE_ACTION,
|
|
689
|
+
targetId: 'action-role-1'
|
|
690
|
+
})
|
|
691
|
+
];
|
|
692
|
+
}
|
|
693
|
+
if (where.permissionType === _iampermissiontypeenum.IamPermissionType.USER_ACTION) {
|
|
694
|
+
throw new Error('DIRECT lookup should not run in RBAC mode');
|
|
695
|
+
}
|
|
696
|
+
return [];
|
|
697
|
+
});
|
|
698
|
+
const result = await service.collectAllActionIds('user-1', null, null);
|
|
699
|
+
expect(Array.from(result)).toEqual([
|
|
700
|
+
'action-role-1'
|
|
701
|
+
]);
|
|
702
|
+
});
|
|
703
|
+
it('DIRECT mode: collects only directly-assigned user action ids, ignoring roles', async ()=>{
|
|
704
|
+
mockIamConfigService.getPermissionMode.mockReturnValue(_permissiontypeenum.IAMPermissionMode.DIRECT);
|
|
705
|
+
mockPermissionRepo.find.mockImplementation(async ({ where })=>{
|
|
706
|
+
if (where.permissionType === _iampermissiontypeenum.IamPermissionType.USER_ROLE) {
|
|
707
|
+
throw new Error('RBAC lookup should not run in DIRECT mode');
|
|
708
|
+
}
|
|
709
|
+
if (where.permissionType === _iampermissiontypeenum.IamPermissionType.USER_ACTION) {
|
|
710
|
+
return [
|
|
711
|
+
buildPermissionRow({
|
|
712
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.USER_ACTION,
|
|
713
|
+
targetId: 'action-direct-1'
|
|
714
|
+
})
|
|
715
|
+
];
|
|
716
|
+
}
|
|
717
|
+
return [];
|
|
718
|
+
});
|
|
719
|
+
const result = await service.collectAllActionIds('user-1', null, null);
|
|
720
|
+
expect(Array.from(result)).toEqual([
|
|
721
|
+
'action-direct-1'
|
|
722
|
+
]);
|
|
723
|
+
});
|
|
724
|
+
it('FULL mode: merges role-derived and direct action ids, de-duplicating overlaps', async ()=>{
|
|
725
|
+
mockIamConfigService.getPermissionMode.mockReturnValue(_permissiontypeenum.IAMPermissionMode.FULL);
|
|
726
|
+
mockPermissionRepo.find.mockImplementation(async ({ where })=>{
|
|
727
|
+
if (where.permissionType === _iampermissiontypeenum.IamPermissionType.USER_ROLE) {
|
|
728
|
+
return [
|
|
729
|
+
buildPermissionRow({
|
|
730
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.USER_ROLE,
|
|
731
|
+
targetId: 'role-1'
|
|
732
|
+
})
|
|
733
|
+
];
|
|
734
|
+
}
|
|
735
|
+
if (where.permissionType === _iampermissiontypeenum.IamPermissionType.ROLE_ACTION) {
|
|
736
|
+
return [
|
|
737
|
+
buildPermissionRow({
|
|
738
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.ROLE_ACTION,
|
|
739
|
+
targetId: 'action-shared'
|
|
740
|
+
})
|
|
741
|
+
];
|
|
742
|
+
}
|
|
743
|
+
if (where.permissionType === _iampermissiontypeenum.IamPermissionType.USER_ACTION) {
|
|
744
|
+
return [
|
|
745
|
+
buildPermissionRow({
|
|
746
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.USER_ACTION,
|
|
747
|
+
targetId: 'action-shared'
|
|
748
|
+
}),
|
|
749
|
+
buildPermissionRow({
|
|
750
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.USER_ACTION,
|
|
751
|
+
targetId: 'action-direct-only'
|
|
752
|
+
})
|
|
753
|
+
];
|
|
754
|
+
}
|
|
755
|
+
return [];
|
|
756
|
+
});
|
|
757
|
+
const result = await service.collectAllActionIds('user-1', null, null);
|
|
758
|
+
expect(Array.from(result).sort()).toEqual([
|
|
759
|
+
'action-direct-only',
|
|
760
|
+
'action-shared'
|
|
761
|
+
]);
|
|
762
|
+
});
|
|
763
|
+
it('excludes expired/not-yet-valid permission rows via isValid()', async ()=>{
|
|
764
|
+
mockIamConfigService.getPermissionMode.mockReturnValue(_permissiontypeenum.IAMPermissionMode.DIRECT);
|
|
765
|
+
const expired = buildPermissionRow({
|
|
766
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.USER_ACTION,
|
|
767
|
+
targetId: 'action-expired',
|
|
768
|
+
validUntil: new Date('2000-01-01')
|
|
769
|
+
});
|
|
770
|
+
const active = buildPermissionRow({
|
|
771
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.USER_ACTION,
|
|
772
|
+
targetId: 'action-active'
|
|
773
|
+
});
|
|
774
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
775
|
+
expired,
|
|
776
|
+
active
|
|
777
|
+
]);
|
|
778
|
+
const result = await service.collectAllActionIds('user-1', null, null);
|
|
779
|
+
expect(Array.from(result)).toEqual([
|
|
780
|
+
'action-active'
|
|
781
|
+
]);
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
describe('applyCompanyWhitelist', ()=>{
|
|
785
|
+
it('is a no-op when the company feature is disabled', async ()=>{
|
|
786
|
+
const actionIds = new Set([
|
|
787
|
+
'action-1',
|
|
788
|
+
'action-2'
|
|
789
|
+
]);
|
|
790
|
+
await service.applyCompanyWhitelist(actionIds, 'company-1');
|
|
791
|
+
expect(actionIds).toEqual(new Set([
|
|
792
|
+
'action-1',
|
|
793
|
+
'action-2'
|
|
794
|
+
]));
|
|
795
|
+
expect(mockPermissionRepo.find).not.toHaveBeenCalled();
|
|
796
|
+
});
|
|
797
|
+
it('is a no-op when companyId is missing even if the company feature is enabled', async ()=>{
|
|
798
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
799
|
+
const actionIds = new Set([
|
|
800
|
+
'action-1'
|
|
801
|
+
]);
|
|
802
|
+
await service.applyCompanyWhitelist(actionIds, null);
|
|
803
|
+
expect(actionIds).toEqual(new Set([
|
|
804
|
+
'action-1'
|
|
805
|
+
]));
|
|
806
|
+
});
|
|
807
|
+
it('does not filter anything when the company has an empty whitelist', async ()=>{
|
|
808
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
809
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
810
|
+
const actionIds = new Set([
|
|
811
|
+
'action-1',
|
|
812
|
+
'action-2'
|
|
813
|
+
]);
|
|
814
|
+
await service.applyCompanyWhitelist(actionIds, 'company-1');
|
|
815
|
+
expect(actionIds).toEqual(new Set([
|
|
816
|
+
'action-1',
|
|
817
|
+
'action-2'
|
|
818
|
+
]));
|
|
819
|
+
});
|
|
820
|
+
it('removes action ids that are not present in the company whitelist', async ()=>{
|
|
821
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
822
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
823
|
+
{
|
|
824
|
+
targetId: 'action-1'
|
|
825
|
+
}
|
|
826
|
+
]);
|
|
827
|
+
const actionIds = new Set([
|
|
828
|
+
'action-1',
|
|
829
|
+
'action-2'
|
|
830
|
+
]);
|
|
831
|
+
await service.applyCompanyWhitelist(actionIds, 'company-1');
|
|
832
|
+
expect(actionIds).toEqual(new Set([
|
|
833
|
+
'action-1'
|
|
834
|
+
]));
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
// ==================== Cache invalidation helpers ====================
|
|
838
|
+
describe('invalidateCompanyMembersCache', ()=>{
|
|
839
|
+
it('returns 0 without querying when the company feature is disabled', async ()=>{
|
|
840
|
+
const result = await service.invalidateCompanyMembersCache('company-1');
|
|
841
|
+
expect(result).toBe(0);
|
|
842
|
+
expect(mockPermissionRepo.createQueryBuilder).not.toHaveBeenCalled();
|
|
843
|
+
});
|
|
844
|
+
it('returns 0 when the company has no members with permissions', async ()=>{
|
|
845
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
846
|
+
mockRawMany(mockPermissionRepo, []);
|
|
847
|
+
const result = await service.invalidateCompanyMembersCache('company-1');
|
|
848
|
+
expect(result).toBe(0);
|
|
849
|
+
});
|
|
850
|
+
it('invalidates every distinct member user id for the company', async ()=>{
|
|
851
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
852
|
+
mockRawMany(mockPermissionRepo, [
|
|
853
|
+
{
|
|
854
|
+
userId: 'user-1',
|
|
855
|
+
branchId: 'branch-1'
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
userId: 'user-2',
|
|
859
|
+
branchId: null
|
|
860
|
+
}
|
|
861
|
+
]);
|
|
862
|
+
mockPermissionCacheService.invalidateUsers.mockResolvedValue(2);
|
|
863
|
+
const result = await service.invalidateCompanyMembersCache('company-1');
|
|
864
|
+
expect(mockPermissionCacheService.invalidateUsers).toHaveBeenCalledWith([
|
|
865
|
+
'user-1',
|
|
866
|
+
'user-2'
|
|
867
|
+
], 'company-1', [
|
|
868
|
+
'branch-1',
|
|
869
|
+
null
|
|
870
|
+
]);
|
|
871
|
+
expect(result).toBe(2);
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
describe('revokeCompanyPermissions', ()=>{
|
|
875
|
+
it('does nothing when the company feature is disabled', async ()=>{
|
|
876
|
+
await service.revokeCompanyPermissions('company-1');
|
|
877
|
+
expect(mockPermissionRepo.find).not.toHaveBeenCalled();
|
|
878
|
+
expect(mockRoleRepo.find).not.toHaveBeenCalled();
|
|
879
|
+
});
|
|
880
|
+
it('deletes company/role/user permissions transactionally and invalidates affected users', async ()=>{
|
|
881
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
882
|
+
mockRoleRepo.find.mockResolvedValue([
|
|
883
|
+
{
|
|
884
|
+
id: 'role-1'
|
|
885
|
+
}
|
|
886
|
+
]);
|
|
887
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
888
|
+
{
|
|
889
|
+
userId: 'user-1'
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
userId: null
|
|
893
|
+
}
|
|
894
|
+
]);
|
|
895
|
+
const fakeTxRepo = {
|
|
896
|
+
delete: jest.fn().mockResolvedValue(buildDeleteResult(1))
|
|
897
|
+
};
|
|
898
|
+
const fakeTxRoleRepo = {
|
|
899
|
+
delete: jest.fn().mockResolvedValue(buildDeleteResult(1))
|
|
900
|
+
};
|
|
901
|
+
mockPermissionRepo.manager.connection.transaction = jest.fn().mockImplementation((cb)=>cb({
|
|
902
|
+
getRepository: jest.fn((target)=>{
|
|
903
|
+
if (target === mockRoleRepo.target) return fakeTxRoleRepo;
|
|
904
|
+
return fakeTxRepo;
|
|
905
|
+
})
|
|
906
|
+
}));
|
|
907
|
+
await service.revokeCompanyPermissions('company-1');
|
|
908
|
+
expect(fakeTxRepo.delete).toHaveBeenCalledWith(expect.objectContaining({
|
|
909
|
+
permissionType: _iampermissiontypeenum.IamPermissionType.COMPANY_ACTION
|
|
910
|
+
}));
|
|
911
|
+
expect(fakeTxRoleRepo.delete).toHaveBeenCalledWith([
|
|
912
|
+
'role-1'
|
|
913
|
+
]);
|
|
914
|
+
expect(mockPermissionCacheService.invalidateUsers).toHaveBeenCalledWith([
|
|
915
|
+
'user-1'
|
|
916
|
+
], 'company-1');
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
describe('revokeBranchPermissions', ()=>{
|
|
920
|
+
it('does nothing when the company feature is disabled', async ()=>{
|
|
921
|
+
await service.revokeBranchPermissions('branch-1', 'company-1');
|
|
922
|
+
expect(mockPermissionRepo.delete).not.toHaveBeenCalled();
|
|
923
|
+
});
|
|
924
|
+
it('deletes matching branch permissions and invalidates affected users', async ()=>{
|
|
925
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
926
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
927
|
+
{
|
|
928
|
+
userId: 'user-1'
|
|
929
|
+
}
|
|
930
|
+
]);
|
|
931
|
+
mockPermissionRepo.delete.mockResolvedValue(buildDeleteResult(1));
|
|
932
|
+
await service.revokeBranchPermissions('branch-1', 'company-1');
|
|
933
|
+
expect(mockPermissionRepo.delete).toHaveBeenCalledWith(expect.objectContaining({
|
|
934
|
+
branchId: 'branch-1',
|
|
935
|
+
companyId: 'company-1'
|
|
936
|
+
}));
|
|
937
|
+
expect(mockPermissionCacheService.invalidateUsers).toHaveBeenCalledWith([
|
|
938
|
+
'user-1'
|
|
939
|
+
], 'company-1', [
|
|
940
|
+
'branch-1'
|
|
941
|
+
]);
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
describe('revokeUserCompanyAccess', ()=>{
|
|
945
|
+
it('deletes user-role and user-action permissions for the company and invalidates the user cache', async ()=>{
|
|
946
|
+
await service.revokeUserCompanyAccess('user-1', 'company-1');
|
|
947
|
+
expect(mockPermissionRepo.delete).toHaveBeenCalledWith(expect.objectContaining({
|
|
948
|
+
sourceId: 'user-1',
|
|
949
|
+
companyId: 'company-1'
|
|
950
|
+
}));
|
|
951
|
+
expect(mockPermissionCacheService.invalidateUser).toHaveBeenCalledWith('user-1', 'company-1');
|
|
952
|
+
});
|
|
953
|
+
});
|
|
954
|
+
describe('revokeUserBranchAccess', ()=>{
|
|
955
|
+
it('deletes branch-scoped permissions for the user and invalidates the user+branch cache', async ()=>{
|
|
956
|
+
await service.revokeUserBranchAccess('user-1', 'branch-1', 'company-1');
|
|
957
|
+
expect(mockPermissionRepo.delete).toHaveBeenCalledWith(expect.objectContaining({
|
|
958
|
+
sourceId: 'user-1',
|
|
959
|
+
branchId: 'branch-1',
|
|
960
|
+
companyId: 'company-1'
|
|
961
|
+
}));
|
|
962
|
+
expect(mockPermissionCacheService.invalidateUser).toHaveBeenCalledWith('user-1', 'company-1', [
|
|
963
|
+
'branch-1'
|
|
964
|
+
]);
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
});
|