@flusys/nestjs-iam 6.1.0 → 6.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cjs/controllers/company-action-permission.controller.js +1 -1
- package/cjs/controllers/company-action-permission.controller.spec.js +1 -1
- package/cjs/controllers/user-action-permission.controller.js +1 -1
- package/cjs/controllers/user-action-permission.controller.spec.js +1 -1
- package/cjs/dtos/permission.dto.js +7 -0
- package/cjs/entities/permission-base.entity.js +1 -10
- package/cjs/entities/permission-with-company.entity.js +0 -3
- package/cjs/entities/user-iam-permission.entity.js +0 -3
- package/cjs/modules/iam.module.js +2 -2
- package/cjs/services/action.service.js +4 -4
- package/cjs/services/action.service.spec.js +11 -11
- package/cjs/services/permission-cache.service.js +363 -52
- package/cjs/services/permission-cache.service.spec.js +462 -15
- package/cjs/services/permission.service.js +92 -396
- package/cjs/services/permission.service.spec.js +35 -310
- package/cjs/services/role.service.js +1 -1
- package/cjs/services/role.service.spec.js +4 -7
- package/dtos/permission.dto.d.ts +1 -0
- package/entities/permission-base.entity.d.ts +0 -1
- package/fesm/controllers/company-action-permission.controller.js +1 -1
- package/fesm/controllers/company-action-permission.controller.spec.js +1 -1
- package/fesm/controllers/user-action-permission.controller.js +1 -1
- package/fesm/controllers/user-action-permission.controller.spec.js +1 -1
- package/fesm/dtos/permission.dto.js +7 -0
- package/fesm/entities/permission-base.entity.js +1 -10
- package/fesm/entities/permission-with-company.entity.js +0 -3
- package/fesm/entities/user-iam-permission.entity.js +0 -3
- package/fesm/modules/iam.module.js +4 -4
- package/fesm/services/action.service.js +4 -4
- package/fesm/services/action.service.spec.js +11 -11
- package/fesm/services/permission-cache.service.js +365 -51
- package/fesm/services/permission-cache.service.spec.js +462 -15
- package/fesm/services/permission.service.js +92 -396
- package/fesm/services/permission.service.spec.js +35 -310
- package/fesm/services/role.service.js +1 -1
- package/fesm/services/role.service.spec.js +4 -7
- package/package.json +3 -3
- package/services/permission-cache.service.d.ts +20 -3
- package/services/permission.service.d.ts +2 -16
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { buildPermissionCacheKey } from '@flusys/nestjs-shared';
|
|
2
|
+
import { createMockRepository } from '@test-utils/mocks/repository.mock';
|
|
3
|
+
import { Action } from '../entities/action.entity';
|
|
4
|
+
import { RoleWithCompany } from '../entities/role-with-company.entity';
|
|
5
|
+
import { Role } from '../entities/role.entity';
|
|
6
|
+
import { ActionType } from '../enums/action-type.enum';
|
|
7
|
+
import { IamEntityType } from '../enums/iam-entity-type.enum';
|
|
8
|
+
import { IamPermissionType } from '../enums/iam-permission-type.enum';
|
|
9
|
+
import { IAMPermissionMode } from '../enums/permission-type.enum';
|
|
2
10
|
import { PermissionCacheService } from './permission-cache.service';
|
|
3
11
|
function buildCacheData(overrides = {}) {
|
|
4
12
|
return {
|
|
@@ -7,17 +15,83 @@ function buildCacheData(overrides = {}) {
|
|
|
7
15
|
...overrides
|
|
8
16
|
};
|
|
9
17
|
}
|
|
18
|
+
function buildPermissionRow(overrides = {}) {
|
|
19
|
+
const row = {
|
|
20
|
+
id: 'perm-uuid-1',
|
|
21
|
+
permissionType: IamPermissionType.USER_ACTION,
|
|
22
|
+
sourceType: IamEntityType.USER,
|
|
23
|
+
sourceId: 'user-1',
|
|
24
|
+
targetType: IamEntityType.ACTION,
|
|
25
|
+
targetId: 'action-1',
|
|
26
|
+
companyId: null,
|
|
27
|
+
branchId: null,
|
|
28
|
+
validFrom: null,
|
|
29
|
+
validUntil: null,
|
|
30
|
+
reason: null,
|
|
31
|
+
createdAt: new Date(),
|
|
32
|
+
...overrides
|
|
33
|
+
};
|
|
34
|
+
row.isValid = (now = new Date())=>{
|
|
35
|
+
if (row.validFrom && now < row.validFrom) return false;
|
|
36
|
+
if (row.validUntil && now > row.validUntil) return false;
|
|
37
|
+
return true;
|
|
38
|
+
};
|
|
39
|
+
return row;
|
|
40
|
+
}
|
|
41
|
+
function buildAction(overrides = {}) {
|
|
42
|
+
return {
|
|
43
|
+
id: 'action-1',
|
|
44
|
+
name: 'View Users',
|
|
45
|
+
code: 'user.view',
|
|
46
|
+
description: null,
|
|
47
|
+
actionType: ActionType.BACKEND,
|
|
48
|
+
parentId: null,
|
|
49
|
+
isActive: true,
|
|
50
|
+
...overrides
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function mockRawMany(repo, rows) {
|
|
54
|
+
const builder = {
|
|
55
|
+
select: jest.fn().mockReturnThis(),
|
|
56
|
+
addSelect: jest.fn().mockReturnThis(),
|
|
57
|
+
where: jest.fn().mockReturnThis(),
|
|
58
|
+
andWhere: jest.fn().mockReturnThis(),
|
|
59
|
+
getRawMany: jest.fn().mockResolvedValue(rows)
|
|
60
|
+
};
|
|
61
|
+
repo.createQueryBuilder.mockReturnValue(builder);
|
|
62
|
+
return builder;
|
|
63
|
+
}
|
|
10
64
|
describe('PermissionCacheService', ()=>{
|
|
11
65
|
let mockCacheManager;
|
|
66
|
+
let mockIamConfigService;
|
|
67
|
+
let mockPermissionRepo;
|
|
68
|
+
let mockActionRepo;
|
|
69
|
+
let mockRoleRepo;
|
|
70
|
+
let mockDataSourceProvider;
|
|
12
71
|
beforeEach(()=>{
|
|
13
72
|
mockCacheManager = {
|
|
14
73
|
get: jest.fn(),
|
|
15
74
|
set: jest.fn(),
|
|
16
75
|
del: jest.fn()
|
|
17
76
|
};
|
|
77
|
+
mockIamConfigService = {
|
|
78
|
+
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false),
|
|
79
|
+
getPermissionMode: jest.fn().mockReturnValue(IAMPermissionMode.FULL)
|
|
80
|
+
};
|
|
81
|
+
mockPermissionRepo = createMockRepository();
|
|
82
|
+
mockActionRepo = createMockRepository();
|
|
83
|
+
mockRoleRepo = createMockRepository();
|
|
84
|
+
mockDataSourceProvider = {
|
|
85
|
+
getRepository: jest.fn((entity)=>{
|
|
86
|
+
if (entity === Action) return Promise.resolve(mockActionRepo);
|
|
87
|
+
if (entity === Role || entity === RoleWithCompany) return Promise.resolve(mockRoleRepo);
|
|
88
|
+
return Promise.resolve(mockPermissionRepo);
|
|
89
|
+
}),
|
|
90
|
+
getDataSource: jest.fn()
|
|
91
|
+
};
|
|
18
92
|
});
|
|
19
93
|
function buildService(guardConfig) {
|
|
20
|
-
return new PermissionCacheService(mockCacheManager, guardConfig);
|
|
94
|
+
return new PermissionCacheService(mockCacheManager, mockIamConfigService, mockDataSourceProvider, guardConfig);
|
|
21
95
|
}
|
|
22
96
|
describe('setMyPermissions / getMyPermissions', ()=>{
|
|
23
97
|
it('stores data under the user-scoped key and tracks it', async ()=>{
|
|
@@ -35,13 +109,11 @@ describe('PermissionCacheService', ()=>{
|
|
|
35
109
|
userId: 'user-1'
|
|
36
110
|
});
|
|
37
111
|
expect(mockCacheManager.set).toHaveBeenCalledWith(expectedKey, data, expect.any(Number));
|
|
38
|
-
//
|
|
39
|
-
expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys:user:user-1'), [
|
|
40
|
-
expectedKey
|
|
41
|
-
], expect.any(Number));
|
|
112
|
+
// no company scope -> the raw cache key itself is tracked directly in the scopes index
|
|
42
113
|
expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'), [
|
|
43
|
-
|
|
114
|
+
expectedKey
|
|
44
115
|
], expect.any(Number));
|
|
116
|
+
expect(mockCacheManager.set).not.toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys'), expect.anything(), expect.any(Number));
|
|
45
117
|
});
|
|
46
118
|
it('does not duplicate an already-tracked key', async ()=>{
|
|
47
119
|
const service = buildService();
|
|
@@ -110,20 +182,28 @@ describe('PermissionCacheService', ()=>{
|
|
|
110
182
|
});
|
|
111
183
|
});
|
|
112
184
|
describe('invalidateUser', ()=>{
|
|
113
|
-
it('invalidates only the given company scope when companyId is provided', async ()=>{
|
|
185
|
+
it('invalidates only the given company scope when companyId is provided, narrowing the scopes index', async ()=>{
|
|
114
186
|
const service = buildService();
|
|
187
|
+
const trackedKeysKey = 'permission-tracked-keys:user:user-1:company:company-1';
|
|
115
188
|
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
116
189
|
if (key.includes('permission-tracked-keys')) return [
|
|
117
190
|
'tracked-key-1',
|
|
118
191
|
'tracked-key-2'
|
|
119
192
|
];
|
|
193
|
+
if (key.includes('permission-tracked-scopes')) return [
|
|
194
|
+
trackedKeysKey,
|
|
195
|
+
'other-scope'
|
|
196
|
+
];
|
|
120
197
|
return undefined;
|
|
121
198
|
});
|
|
122
199
|
await service.invalidateUser('user-1', 'company-1');
|
|
123
200
|
expect(mockCacheManager.del).toHaveBeenCalledWith('tracked-key-1');
|
|
124
201
|
expect(mockCacheManager.del).toHaveBeenCalledWith('tracked-key-2');
|
|
125
202
|
expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys:user:user-1:company:company-1'));
|
|
126
|
-
//
|
|
203
|
+
// the invalidated scope is dropped from the index, but the index survives since another scope remains
|
|
204
|
+
expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'), [
|
|
205
|
+
'other-scope'
|
|
206
|
+
], expect.any(Number));
|
|
127
207
|
expect(mockCacheManager.del).not.toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes'));
|
|
128
208
|
});
|
|
129
209
|
it('invalidates only the requested branch keys, keeping remaining branches tracked', async ()=>{
|
|
@@ -187,22 +267,23 @@ describe('PermissionCacheService', ()=>{
|
|
|
187
267
|
});
|
|
188
268
|
it('without a companyId, invalidates every tracked scope for the user and clears the scopes index', async ()=>{
|
|
189
269
|
const service = buildService();
|
|
270
|
+
// scopes list mixes a nested tracked-keys index (company scope) with a raw cache key (untracked scope)
|
|
271
|
+
const trackedKeysKey = 'permission-tracked-keys:user:user-1:company:company-a';
|
|
272
|
+
const rawKey = 'my-permissions:user:user-1';
|
|
190
273
|
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
191
274
|
if (key.includes('permission-tracked-scopes')) return [
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
];
|
|
195
|
-
if (key.includes('company:none')) return [
|
|
196
|
-
'key-none-1'
|
|
275
|
+
trackedKeysKey,
|
|
276
|
+
rawKey
|
|
197
277
|
];
|
|
198
|
-
if (key
|
|
278
|
+
if (key === trackedKeysKey) return [
|
|
199
279
|
'key-company-a-1'
|
|
200
280
|
];
|
|
201
281
|
return undefined;
|
|
202
282
|
});
|
|
203
283
|
await service.invalidateUser('user-1');
|
|
204
|
-
expect(mockCacheManager.del).toHaveBeenCalledWith('key-none-1');
|
|
205
284
|
expect(mockCacheManager.del).toHaveBeenCalledWith('key-company-a-1');
|
|
285
|
+
expect(mockCacheManager.del).toHaveBeenCalledWith(trackedKeysKey);
|
|
286
|
+
expect(mockCacheManager.del).toHaveBeenCalledWith(rawKey);
|
|
206
287
|
expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'));
|
|
207
288
|
});
|
|
208
289
|
it('treats a cache read error for tracked scopes as an empty list (no throw)', async ()=>{
|
|
@@ -245,4 +326,370 @@ describe('PermissionCacheService', ()=>{
|
|
|
245
326
|
expect(result).toBe(2);
|
|
246
327
|
});
|
|
247
328
|
});
|
|
329
|
+
describe('getMyPermissionsResponse', ()=>{
|
|
330
|
+
it('returns the response built directly from cached data on a cache hit', async ()=>{
|
|
331
|
+
const service = buildService();
|
|
332
|
+
mockCacheManager.get.mockResolvedValue(buildCacheData({
|
|
333
|
+
frontendActions: [
|
|
334
|
+
{
|
|
335
|
+
id: 'action-1',
|
|
336
|
+
code: 'user.view',
|
|
337
|
+
name: 'View',
|
|
338
|
+
description: null,
|
|
339
|
+
parentId: null
|
|
340
|
+
}
|
|
341
|
+
],
|
|
342
|
+
backendCodes: [
|
|
343
|
+
'user.read'
|
|
344
|
+
]
|
|
345
|
+
}));
|
|
346
|
+
const result = await service.getMyPermissionsResponse('user-1', null, 'company-1');
|
|
347
|
+
expect(result.frontendActions).toHaveLength(1);
|
|
348
|
+
expect(result.cachedEndpoints).toBe(1);
|
|
349
|
+
expect(mockPermissionRepo.find).not.toHaveBeenCalled();
|
|
350
|
+
});
|
|
351
|
+
it('fetches from the database and caches the result on a cache miss', async ()=>{
|
|
352
|
+
const service = buildService();
|
|
353
|
+
mockCacheManager.get.mockResolvedValue(undefined);
|
|
354
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
355
|
+
const result = await service.getMyPermissionsResponse('user-1', null, null);
|
|
356
|
+
expect(result).toEqual({
|
|
357
|
+
frontendActions: [],
|
|
358
|
+
cachedEndpoints: 0
|
|
359
|
+
});
|
|
360
|
+
expect(mockCacheManager.set).toHaveBeenCalledWith(expect.any(String), {
|
|
361
|
+
frontendActions: [],
|
|
362
|
+
backendCodes: []
|
|
363
|
+
}, expect.any(Number));
|
|
364
|
+
});
|
|
365
|
+
it('filters frontend actions by parentCodes when provided', async ()=>{
|
|
366
|
+
const service = buildService();
|
|
367
|
+
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
368
|
+
if (key.includes('permission-tracked')) return undefined;
|
|
369
|
+
return buildCacheData({
|
|
370
|
+
frontendActions: [
|
|
371
|
+
{
|
|
372
|
+
id: 'child-1',
|
|
373
|
+
code: 'user.view',
|
|
374
|
+
name: 'View',
|
|
375
|
+
description: null,
|
|
376
|
+
parentId: 'parent-1'
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
id: 'child-2',
|
|
380
|
+
code: 'other.view',
|
|
381
|
+
name: 'View 2',
|
|
382
|
+
description: null,
|
|
383
|
+
parentId: 'other-parent'
|
|
384
|
+
}
|
|
385
|
+
],
|
|
386
|
+
backendCodes: []
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
mockActionRepo.find.mockResolvedValue([
|
|
390
|
+
{
|
|
391
|
+
id: 'parent-1',
|
|
392
|
+
code: 'user'
|
|
393
|
+
}
|
|
394
|
+
]);
|
|
395
|
+
const result = await service.getMyPermissionsResponse('user-1', null, null, [
|
|
396
|
+
'user'
|
|
397
|
+
]);
|
|
398
|
+
expect(result.frontendActions).toHaveLength(1);
|
|
399
|
+
expect(result.frontendActions[0].id).toBe('child-1');
|
|
400
|
+
});
|
|
401
|
+
it('returns an empty frontendActions list when parentCodes resolve to no known parents', async ()=>{
|
|
402
|
+
const service = buildService();
|
|
403
|
+
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
404
|
+
if (key.includes('permission-tracked')) return undefined;
|
|
405
|
+
return buildCacheData({
|
|
406
|
+
frontendActions: [
|
|
407
|
+
{
|
|
408
|
+
id: 'child-1',
|
|
409
|
+
code: 'user.view',
|
|
410
|
+
name: 'View',
|
|
411
|
+
description: null,
|
|
412
|
+
parentId: 'parent-1'
|
|
413
|
+
}
|
|
414
|
+
],
|
|
415
|
+
backendCodes: []
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
mockActionRepo.find.mockResolvedValue([]);
|
|
419
|
+
const result = await service.getMyPermissionsResponse('user-1', null, null, [
|
|
420
|
+
'unknown-parent-code'
|
|
421
|
+
]);
|
|
422
|
+
expect(result.frontendActions).toEqual([]);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
describe('collectAllActionIds (permission-mode branching)', ()=>{
|
|
426
|
+
it('RBAC mode: collects only role-derived action ids, ignoring direct user actions', async ()=>{
|
|
427
|
+
const service = buildService();
|
|
428
|
+
mockIamConfigService.getPermissionMode.mockReturnValue(IAMPermissionMode.RBAC);
|
|
429
|
+
mockPermissionRepo.find.mockImplementation(async ({ where })=>{
|
|
430
|
+
if (where.permissionType === IamPermissionType.USER_ROLE) {
|
|
431
|
+
return [
|
|
432
|
+
buildPermissionRow({
|
|
433
|
+
permissionType: IamPermissionType.USER_ROLE,
|
|
434
|
+
targetId: 'role-1'
|
|
435
|
+
})
|
|
436
|
+
];
|
|
437
|
+
}
|
|
438
|
+
if (where.permissionType === IamPermissionType.ROLE_ACTION) {
|
|
439
|
+
return [
|
|
440
|
+
buildPermissionRow({
|
|
441
|
+
permissionType: IamPermissionType.ROLE_ACTION,
|
|
442
|
+
targetId: 'action-role-1'
|
|
443
|
+
})
|
|
444
|
+
];
|
|
445
|
+
}
|
|
446
|
+
if (where.permissionType === IamPermissionType.USER_ACTION) {
|
|
447
|
+
throw new Error('DIRECT lookup should not run in RBAC mode');
|
|
448
|
+
}
|
|
449
|
+
return [];
|
|
450
|
+
});
|
|
451
|
+
const result = await service.collectAllActionIds('user-1', null, null);
|
|
452
|
+
expect(Array.from(result)).toEqual([
|
|
453
|
+
'action-role-1'
|
|
454
|
+
]);
|
|
455
|
+
});
|
|
456
|
+
it('DIRECT mode: collects only directly-assigned user action ids, ignoring roles', async ()=>{
|
|
457
|
+
const service = buildService();
|
|
458
|
+
mockIamConfigService.getPermissionMode.mockReturnValue(IAMPermissionMode.DIRECT);
|
|
459
|
+
mockPermissionRepo.find.mockImplementation(async ({ where })=>{
|
|
460
|
+
if (where.permissionType === IamPermissionType.USER_ROLE) {
|
|
461
|
+
throw new Error('RBAC lookup should not run in DIRECT mode');
|
|
462
|
+
}
|
|
463
|
+
if (where.permissionType === IamPermissionType.USER_ACTION) {
|
|
464
|
+
return [
|
|
465
|
+
buildPermissionRow({
|
|
466
|
+
permissionType: IamPermissionType.USER_ACTION,
|
|
467
|
+
targetId: 'action-direct-1'
|
|
468
|
+
})
|
|
469
|
+
];
|
|
470
|
+
}
|
|
471
|
+
return [];
|
|
472
|
+
});
|
|
473
|
+
const result = await service.collectAllActionIds('user-1', null, null);
|
|
474
|
+
expect(Array.from(result)).toEqual([
|
|
475
|
+
'action-direct-1'
|
|
476
|
+
]);
|
|
477
|
+
});
|
|
478
|
+
it('FULL mode: merges role-derived and direct action ids, de-duplicating overlaps', async ()=>{
|
|
479
|
+
const service = buildService();
|
|
480
|
+
mockIamConfigService.getPermissionMode.mockReturnValue(IAMPermissionMode.FULL);
|
|
481
|
+
mockPermissionRepo.find.mockImplementation(async ({ where })=>{
|
|
482
|
+
if (where.permissionType === IamPermissionType.USER_ROLE) {
|
|
483
|
+
return [
|
|
484
|
+
buildPermissionRow({
|
|
485
|
+
permissionType: IamPermissionType.USER_ROLE,
|
|
486
|
+
targetId: 'role-1'
|
|
487
|
+
})
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
if (where.permissionType === IamPermissionType.ROLE_ACTION) {
|
|
491
|
+
return [
|
|
492
|
+
buildPermissionRow({
|
|
493
|
+
permissionType: IamPermissionType.ROLE_ACTION,
|
|
494
|
+
targetId: 'action-shared'
|
|
495
|
+
})
|
|
496
|
+
];
|
|
497
|
+
}
|
|
498
|
+
if (where.permissionType === IamPermissionType.USER_ACTION) {
|
|
499
|
+
return [
|
|
500
|
+
buildPermissionRow({
|
|
501
|
+
permissionType: IamPermissionType.USER_ACTION,
|
|
502
|
+
targetId: 'action-shared'
|
|
503
|
+
}),
|
|
504
|
+
buildPermissionRow({
|
|
505
|
+
permissionType: IamPermissionType.USER_ACTION,
|
|
506
|
+
targetId: 'action-direct-only'
|
|
507
|
+
})
|
|
508
|
+
];
|
|
509
|
+
}
|
|
510
|
+
return [];
|
|
511
|
+
});
|
|
512
|
+
const result = await service.collectAllActionIds('user-1', null, null);
|
|
513
|
+
expect(Array.from(result).sort()).toEqual([
|
|
514
|
+
'action-direct-only',
|
|
515
|
+
'action-shared'
|
|
516
|
+
]);
|
|
517
|
+
});
|
|
518
|
+
it('excludes expired/not-yet-valid permission rows via isValid()', async ()=>{
|
|
519
|
+
const service = buildService();
|
|
520
|
+
mockIamConfigService.getPermissionMode.mockReturnValue(IAMPermissionMode.DIRECT);
|
|
521
|
+
const expired = buildPermissionRow({
|
|
522
|
+
permissionType: IamPermissionType.USER_ACTION,
|
|
523
|
+
targetId: 'action-expired',
|
|
524
|
+
validUntil: new Date('2000-01-01')
|
|
525
|
+
});
|
|
526
|
+
const active = buildPermissionRow({
|
|
527
|
+
permissionType: IamPermissionType.USER_ACTION,
|
|
528
|
+
targetId: 'action-active'
|
|
529
|
+
});
|
|
530
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
531
|
+
expired,
|
|
532
|
+
active
|
|
533
|
+
]);
|
|
534
|
+
const result = await service.collectAllActionIds('user-1', null, null);
|
|
535
|
+
expect(Array.from(result)).toEqual([
|
|
536
|
+
'action-active'
|
|
537
|
+
]);
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
describe('buildAndCachePermissionData (via getMyPermissionsResponse on a cache miss)', ()=>{
|
|
541
|
+
it('splits actions into frontend/backend and caches them', async ()=>{
|
|
542
|
+
const service = buildService();
|
|
543
|
+
mockIamConfigService.getPermissionMode.mockReturnValue(IAMPermissionMode.DIRECT);
|
|
544
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
545
|
+
buildPermissionRow({
|
|
546
|
+
permissionType: IamPermissionType.USER_ACTION,
|
|
547
|
+
targetId: 'action-1'
|
|
548
|
+
})
|
|
549
|
+
]);
|
|
550
|
+
mockCacheManager.get.mockResolvedValue(undefined);
|
|
551
|
+
mockActionRepo.find.mockResolvedValue([
|
|
552
|
+
buildAction({
|
|
553
|
+
id: 'action-1',
|
|
554
|
+
actionType: ActionType.BOTH,
|
|
555
|
+
code: 'user.view'
|
|
556
|
+
})
|
|
557
|
+
]);
|
|
558
|
+
const result = await service.getMyPermissionsResponse('user-1', null, null);
|
|
559
|
+
expect(result.cachedEndpoints).toBe(1);
|
|
560
|
+
expect(result.frontendActions).toEqual([
|
|
561
|
+
{
|
|
562
|
+
id: 'action-1',
|
|
563
|
+
code: 'user.view',
|
|
564
|
+
name: 'View Users',
|
|
565
|
+
description: null
|
|
566
|
+
}
|
|
567
|
+
]);
|
|
568
|
+
expect(mockCacheManager.set).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
569
|
+
backendCodes: [
|
|
570
|
+
'user.view'
|
|
571
|
+
]
|
|
572
|
+
}), expect.any(Number));
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
describe('invalidateCompanyMembersCache', ()=>{
|
|
576
|
+
it('returns 0 without querying when the company feature is disabled', async ()=>{
|
|
577
|
+
const service = buildService();
|
|
578
|
+
const result = await service.invalidateCompanyMembersCache('company-1');
|
|
579
|
+
expect(result).toBe(0);
|
|
580
|
+
expect(mockPermissionRepo.createQueryBuilder).not.toHaveBeenCalled();
|
|
581
|
+
});
|
|
582
|
+
it('returns 0 when the company has no members with permissions', async ()=>{
|
|
583
|
+
const service = buildService();
|
|
584
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
585
|
+
mockRawMany(mockPermissionRepo, []);
|
|
586
|
+
const result = await service.invalidateCompanyMembersCache('company-1');
|
|
587
|
+
expect(result).toBe(0);
|
|
588
|
+
});
|
|
589
|
+
it('invalidates every distinct member user id for the company', async ()=>{
|
|
590
|
+
const service = buildService();
|
|
591
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
592
|
+
mockRawMany(mockPermissionRepo, [
|
|
593
|
+
{
|
|
594
|
+
userId: 'user-1'
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
userId: 'user-2'
|
|
598
|
+
}
|
|
599
|
+
]);
|
|
600
|
+
mockCacheManager.get.mockResolvedValue(undefined);
|
|
601
|
+
const result = await service.invalidateCompanyMembersCache('company-1');
|
|
602
|
+
expect(result).toBe(2);
|
|
603
|
+
});
|
|
604
|
+
it('wipes every tracked cache entry per member instead of only branches with an existing permission row', async ()=>{
|
|
605
|
+
// Company-wide action changes are merged into every branch a member queries, including
|
|
606
|
+
// branches where the member holds no explicit permission row — so invalidation must not
|
|
607
|
+
// be narrowed to whatever branch_ids happen to already exist in the permission table.
|
|
608
|
+
const service = buildService();
|
|
609
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
610
|
+
mockRawMany(mockPermissionRepo, [
|
|
611
|
+
{
|
|
612
|
+
userId: 'user-1'
|
|
613
|
+
}
|
|
614
|
+
]);
|
|
615
|
+
const trackedKeysKey = 'permission-tracked-keys:user:user-1:company:company-1';
|
|
616
|
+
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
617
|
+
if (key.includes('permission-tracked-keys')) {
|
|
618
|
+
return [
|
|
619
|
+
'cached-key-branch-untracked-by-any-permission-row'
|
|
620
|
+
];
|
|
621
|
+
}
|
|
622
|
+
return undefined;
|
|
623
|
+
});
|
|
624
|
+
const result = await service.invalidateCompanyMembersCache('company-1');
|
|
625
|
+
const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
|
|
626
|
+
expect(deletedKeys).toContain('cached-key-branch-untracked-by-any-permission-row');
|
|
627
|
+
expect(deletedKeys).toContain(trackedKeysKey);
|
|
628
|
+
expect(result).toBe(1);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
describe('invalidateRoleMembersCache', ()=>{
|
|
632
|
+
it('returns 0 when the role has no members', async ()=>{
|
|
633
|
+
const service = buildService();
|
|
634
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
635
|
+
const result = await service.invalidateRoleMembersCache('role-1');
|
|
636
|
+
expect(result).toBe(0);
|
|
637
|
+
expect(mockRoleRepo.findOne).not.toHaveBeenCalled();
|
|
638
|
+
});
|
|
639
|
+
it('invalidates every distinct user holding the role, scoped to the role company', async ()=>{
|
|
640
|
+
const service = buildService();
|
|
641
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
642
|
+
buildPermissionRow({
|
|
643
|
+
sourceId: 'user-9'
|
|
644
|
+
}),
|
|
645
|
+
buildPermissionRow({
|
|
646
|
+
id: 'perm-2',
|
|
647
|
+
sourceId: 'user-9'
|
|
648
|
+
})
|
|
649
|
+
]);
|
|
650
|
+
mockRoleRepo.findOne.mockResolvedValue({
|
|
651
|
+
id: 'role-1',
|
|
652
|
+
companyId: null
|
|
653
|
+
});
|
|
654
|
+
mockCacheManager.get.mockResolvedValue(undefined);
|
|
655
|
+
const result = await service.invalidateRoleMembersCache('role-1');
|
|
656
|
+
expect(mockRoleRepo.findOne).toHaveBeenCalledWith({
|
|
657
|
+
where: {
|
|
658
|
+
id: 'role-1'
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
expect(result).toBe(1);
|
|
662
|
+
});
|
|
663
|
+
it('wipes every tracked cache entry for role members instead of only branches with an existing permission row', async ()=>{
|
|
664
|
+
// Role actions are merged into every branch a member queries, including branches
|
|
665
|
+
// where the member holds no explicit permission row — so invalidation must not be
|
|
666
|
+
// narrowed to whatever branch_ids happen to already exist in the permission table.
|
|
667
|
+
const service = buildService();
|
|
668
|
+
mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
669
|
+
mockPermissionRepo.find.mockResolvedValue([
|
|
670
|
+
buildPermissionRow({
|
|
671
|
+
sourceId: 'user-9'
|
|
672
|
+
})
|
|
673
|
+
]);
|
|
674
|
+
mockRoleRepo.findOne.mockResolvedValue({
|
|
675
|
+
id: 'role-1',
|
|
676
|
+
companyId: 'company-1'
|
|
677
|
+
});
|
|
678
|
+
const trackedKeysKey = 'permission-tracked-keys:user:user-9:company:company-1';
|
|
679
|
+
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
680
|
+
if (key.includes('permission-tracked-keys')) {
|
|
681
|
+
return [
|
|
682
|
+
'cached-key-branch-untracked-by-any-permission-row'
|
|
683
|
+
];
|
|
684
|
+
}
|
|
685
|
+
return undefined;
|
|
686
|
+
});
|
|
687
|
+
const result = await service.invalidateRoleMembersCache('role-1');
|
|
688
|
+
expect(mockPermissionRepo.createQueryBuilder).not.toHaveBeenCalled();
|
|
689
|
+
const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
|
|
690
|
+
expect(deletedKeys).toContain('cached-key-branch-untracked-by-any-permission-row');
|
|
691
|
+
expect(deletedKeys).toContain(trackedKeysKey);
|
|
692
|
+
expect(result).toBe(1);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
248
695
|
});
|