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