@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.
Files changed (60) hide show
  1. package/cjs/controllers/action.controller.js +17 -9
  2. package/cjs/controllers/action.controller.spec.js +79 -0
  3. package/cjs/controllers/company-action-permission.controller.spec.js +65 -0
  4. package/cjs/controllers/my-permission.controller.spec.js +56 -0
  5. package/cjs/controllers/role-permission.controller.spec.js +140 -0
  6. package/cjs/controllers/role.controller.spec.js +86 -0
  7. package/cjs/controllers/user-action-permission.controller.js +0 -3
  8. package/cjs/controllers/user-action-permission.controller.spec.js +68 -0
  9. package/cjs/docs/iam-swagger.config.js +6 -0
  10. package/cjs/docs/iam-swagger.config.spec.js +99 -0
  11. package/cjs/dtos/action.dto.js +17 -0
  12. package/cjs/entities/index.spec.js +56 -0
  13. package/cjs/helpers/company-access.helper.spec.js +68 -0
  14. package/cjs/helpers/permission-mode.helper.spec.js +54 -0
  15. package/cjs/modules/iam.module.js +3 -1
  16. package/cjs/modules/iam.module.spec.js +293 -0
  17. package/cjs/services/action.service.js +90 -7
  18. package/cjs/services/action.service.spec.js +314 -0
  19. package/cjs/services/iam-config.service.spec.js +110 -0
  20. package/cjs/services/iam-datasource.service.spec.js +117 -0
  21. package/cjs/services/permission-cache.service.js +113 -110
  22. package/cjs/services/permission-cache.service.spec.js +252 -0
  23. package/cjs/services/permission.service.js +213 -92
  24. package/cjs/services/permission.service.spec.js +967 -0
  25. package/cjs/services/role.service.js +47 -3
  26. package/cjs/services/role.service.spec.js +312 -0
  27. package/controllers/action.controller.d.ts +5 -3
  28. package/dtos/action.dto.d.ts +3 -0
  29. package/entities/index.d.ts +2 -2
  30. package/fesm/controllers/action.controller.js +18 -10
  31. package/fesm/controllers/action.controller.spec.js +75 -0
  32. package/fesm/controllers/company-action-permission.controller.spec.js +61 -0
  33. package/fesm/controllers/my-permission.controller.spec.js +52 -0
  34. package/fesm/controllers/role-permission.controller.spec.js +136 -0
  35. package/fesm/controllers/role.controller.spec.js +82 -0
  36. package/fesm/controllers/user-action-permission.controller.js +0 -3
  37. package/fesm/controllers/user-action-permission.controller.spec.js +64 -0
  38. package/fesm/docs/iam-swagger.config.js +6 -0
  39. package/fesm/docs/iam-swagger.config.spec.js +95 -0
  40. package/fesm/dtos/action.dto.js +14 -0
  41. package/fesm/entities/index.spec.js +52 -0
  42. package/fesm/helpers/company-access.helper.spec.js +64 -0
  43. package/fesm/helpers/permission-mode.helper.spec.js +50 -0
  44. package/fesm/modules/iam.module.js +4 -2
  45. package/fesm/modules/iam.module.spec.js +289 -0
  46. package/fesm/services/action.service.js +90 -7
  47. package/fesm/services/action.service.spec.js +310 -0
  48. package/fesm/services/iam-config.service.spec.js +106 -0
  49. package/fesm/services/iam-datasource.service.spec.js +113 -0
  50. package/fesm/services/permission-cache.service.js +101 -108
  51. package/fesm/services/permission-cache.service.spec.js +248 -0
  52. package/fesm/services/permission.service.js +213 -92
  53. package/fesm/services/permission.service.spec.js +963 -0
  54. package/fesm/services/role.service.js +48 -4
  55. package/fesm/services/role.service.spec.js +308 -0
  56. package/package.json +3 -3
  57. package/services/action.service.d.ts +7 -3
  58. package/services/permission-cache.service.d.ts +13 -19
  59. package/services/permission.service.d.ts +6 -3
  60. package/services/role.service.d.ts +7 -3
@@ -25,86 +25,122 @@ function _ts_param(paramIndex, decorator) {
25
25
  decorator(target, key, paramIndex);
26
26
  };
27
27
  }
28
- import { HybridCache, LogAction } from '@flusys/nestjs-shared';
29
- import { Inject, Injectable } from '@nestjs/common';
28
+ import { envConfig } from '@flusys/nestjs-core/config';
29
+ import { buildPermissionCacheKey, HybridCache, LogAction, PERMISSION_GUARD_CONFIG, PERMISSIONS_CACHE_PREFIX, PermissionCacheKeyOptions, PermissionGuardConfig } from '@flusys/nestjs-shared';
30
+ import { Inject, Injectable, Optional } from '@nestjs/common';
31
+ export { PermissionCacheKeyOptions } from '@flusys/nestjs-shared';
32
+ export const MY_PERMISSIONS_CACHE_PREFIX = PERMISSIONS_CACHE_PREFIX;
33
+ const NO_COMPANY_SCOPE = 'none';
30
34
  export class PermissionCacheService {
31
35
  // Cache Key Generation
32
- generateCacheKey(options) {
33
- return this.buildCacheKey(this.CACHE_PREFIX, options);
36
+ parseDurationMs(duration) {
37
+ const unitMs = {
38
+ ms: 1,
39
+ s: 1000,
40
+ m: 60000,
41
+ h: 3600000,
42
+ d: 86400000,
43
+ w: 604800000
44
+ };
45
+ const match = /^(\d+)\s*(ms|s|m|h|d|w)?$/i.exec(duration.trim());
46
+ if (!match) {
47
+ return unitMs.h; // fallback: 1 hour
48
+ }
49
+ const unit = (match[2] || 'ms').toLowerCase();
50
+ return Number(match[1]) * unitMs[unit];
34
51
  }
35
- generateMyPermissionsCacheKey(options) {
36
- return this.buildCacheKey(this.MY_PERMISSIONS_PREFIX, options);
52
+ buildCacheKey(options) {
53
+ return buildPermissionCacheKey(options, this.guardConfig);
37
54
  }
38
- buildCacheKey(prefix, options) {
39
- const { userId, companyId, branchId, enableCompanyFeature } = options;
40
- if (enableCompanyFeature && companyId) {
41
- return `${prefix}:company:${companyId}:branch:${branchId || 'null'}:user:${userId}`;
42
- }
43
- return `${prefix}:user:${userId}`;
55
+ scopeToken(companyId) {
56
+ return companyId || NO_COMPANY_SCOPE;
44
57
  }
45
- // Cache Operations
46
- async setPermissions(options, permissions) {
47
- const key = this.generateCacheKey(options);
48
- await this.cacheManager.set(key, permissions, this.TTL);
58
+ buildTrackedKeysKey(userId, companyId) {
59
+ return `${this.TRACKED_KEYS_PREFIX}:user:${userId}:company:${this.scopeToken(companyId)}`;
60
+ }
61
+ buildTrackedScopesKey(userId) {
62
+ return `${this.TRACKED_SCOPES_PREFIX}:user:${userId}`;
49
63
  }
50
- // My-Permissions Cache Operations
64
+ // Cache Operations
51
65
  async setMyPermissions(options, data) {
52
- const key = this.generateMyPermissionsCacheKey(options);
66
+ const key = this.buildCacheKey(options);
53
67
  await this.cacheManager.set(key, data, this.TTL);
68
+ await this.trackKey(options.userId, key, options.companyId);
54
69
  }
55
70
  async getMyPermissions(options) {
56
71
  try {
57
- const key = this.generateMyPermissionsCacheKey(options);
72
+ const key = this.buildCacheKey(options);
58
73
  const result = await this.cacheManager.get(key);
59
74
  return result || null;
60
75
  } catch {
61
76
  return null;
62
77
  }
63
78
  }
64
- // Action Code Cache Operations (tenant-aware for multi-tenant mode)
65
- /** Generate tenant-aware cache key for action codes */ generateActionCodeCacheKey(tenantId) {
66
- if (tenantId) {
67
- return `${this.ACTION_CODE_PREFIX}:tenant:${tenantId}:map`;
68
- }
69
- return `${this.ACTION_CODE_PREFIX}:map`;
70
- }
71
- async setActionCodeMap(codeToIdMap, tenantId) {
72
- const key = this.generateActionCodeCacheKey(tenantId);
73
- await this.cacheManager.set(key, codeToIdMap, this.ACTION_CODE_TTL);
74
- }
75
- async getActionIdsByCodes(codes, tenantId) {
79
+ async trackKey(userId, key, companyId) {
76
80
  try {
77
- const key = this.generateActionCodeCacheKey(tenantId);
78
- const fullMap = await this.cacheManager.get(key);
79
- if (!fullMap) {
80
- return null;
81
+ const trackedKeysKey = this.buildTrackedKeysKey(userId, companyId);
82
+ const keys = await this.cacheManager.get(trackedKeysKey) || [];
83
+ if (!keys.includes(key)) {
84
+ keys.push(key);
85
+ await this.cacheManager.set(trackedKeysKey, keys, this.TTL);
81
86
  }
82
- const result = {};
83
- for (const code of codes){
84
- if (fullMap[code]) {
85
- result[code] = fullMap[code];
86
- }
87
+ const scopesKey = this.buildTrackedScopesKey(userId);
88
+ const scopes = await this.cacheManager.get(scopesKey) || [];
89
+ const scope = this.scopeToken(companyId);
90
+ if (!scopes.includes(scope)) {
91
+ scopes.push(scope);
92
+ await this.cacheManager.set(scopesKey, scopes, this.TTL);
87
93
  }
88
- return Object.keys(result).length > 0 ? result : null;
89
94
  } catch {
90
- return null;
95
+ // tracking is best-effort; a missed entry only widens invalidation, never narrows it
91
96
  }
92
97
  }
93
98
  // Cache Invalidation
94
99
  async invalidateUser(userId, companyId, branchIds) {
95
- const keysToDelete = [
96
- `${this.CACHE_PREFIX}:user:${userId}`,
97
- `${this.MY_PERMISSIONS_PREFIX}:user:${userId}`
98
- ];
99
100
  if (companyId) {
100
- const branches = branchIds?.length ? branchIds : [
101
- null
102
- ];
103
- for (const branchId of branches){
104
- keysToDelete.push(`${this.CACHE_PREFIX}:company:${companyId}:branch:${branchId || 'null'}:user:${userId}`, `${this.MY_PERMISSIONS_PREFIX}:company:${companyId}:branch:${branchId || 'null'}:user:${userId}`);
101
+ await this.invalidateScope(userId, companyId, branchIds);
102
+ return;
103
+ }
104
+ // No company scope given: clear every company/branch scope tracked for this user
105
+ const scopesKey = this.buildTrackedScopesKey(userId);
106
+ let scopes = [];
107
+ try {
108
+ scopes = await this.cacheManager.get(scopesKey) || [];
109
+ } catch {
110
+ scopes = [];
111
+ }
112
+ await Promise.all(scopes.map((scope)=>this.invalidateScope(userId, scope === NO_COMPANY_SCOPE ? null : scope, branchIds)));
113
+ await this.cacheManager.del(scopesKey);
114
+ }
115
+ async invalidateScope(userId, companyId, branchIds) {
116
+ const keysToDelete = new Set();
117
+ const trackedKeysKey = this.buildTrackedKeysKey(userId, companyId);
118
+ let trackedKeys = [];
119
+ try {
120
+ trackedKeys = await this.cacheManager.get(trackedKeysKey) || [];
121
+ } catch {
122
+ trackedKeys = [];
123
+ }
124
+ if (!branchIds?.length) {
125
+ trackedKeys.forEach((key)=>keysToDelete.add(key));
126
+ keysToDelete.add(trackedKeysKey);
127
+ } else {
128
+ // Branch scope given: only invalidate those branches, keep the rest tracked
129
+ branchIds.forEach((branchId)=>keysToDelete.add(this.buildCacheKey({
130
+ userId,
131
+ companyId,
132
+ branchId
133
+ })));
134
+ const remainingKeys = trackedKeys.filter((key)=>!keysToDelete.has(key));
135
+ if (remainingKeys.length) {
136
+ await this.cacheManager.set(trackedKeysKey, remainingKeys, this.TTL);
137
+ } else {
138
+ keysToDelete.add(trackedKeysKey);
105
139
  }
106
140
  }
107
- await Promise.all(keysToDelete.map((key)=>this.cacheManager.del(key)));
141
+ await Promise.all([
142
+ ...keysToDelete
143
+ ].map((key)=>this.cacheManager.del(key)));
108
144
  }
109
145
  async invalidateUsers(userIds, companyId, branchIds) {
110
146
  if (userIds.length === 0) {
@@ -113,39 +149,19 @@ export class PermissionCacheService {
113
149
  const results = await Promise.allSettled(userIds.map((userId)=>this.invalidateUser(userId, companyId, branchIds)));
114
150
  return results.filter((r)=>r.status === 'fulfilled').length;
115
151
  }
116
- async invalidateRole(_roleId, userIds, companyId, branchIds) {
117
- if (userIds.length === 0) {
118
- return 0;
119
- }
120
- return await this.invalidateUsers(userIds, companyId, branchIds);
121
- }
122
- constructor(cacheManager){
152
+ constructor(cacheManager, guardConfig){
123
153
  _define_property(this, "cacheManager", void 0);
124
- _define_property(this, "TTL", void 0); // 1 hour
125
- _define_property(this, "ACTION_CODE_TTL", void 0); // 2 hours for action codes (less frequent changes)
126
- _define_property(this, "CACHE_PREFIX", void 0);
127
- _define_property(this, "MY_PERMISSIONS_PREFIX", void 0);
128
- _define_property(this, "ACTION_CODE_PREFIX", void 0);
154
+ _define_property(this, "guardConfig", void 0);
155
+ _define_property(this, "TRACKED_KEYS_PREFIX", void 0);
156
+ _define_property(this, "TRACKED_SCOPES_PREFIX", void 0);
157
+ _define_property(this, "TTL", void 0);
129
158
  this.cacheManager = cacheManager;
130
- this.TTL = 3600000;
131
- this.ACTION_CODE_TTL = 7200000;
132
- this.CACHE_PREFIX = 'permissions';
133
- this.MY_PERMISSIONS_PREFIX = 'my-permissions';
134
- this.ACTION_CODE_PREFIX = 'action-codes';
159
+ this.guardConfig = guardConfig;
160
+ this.TRACKED_KEYS_PREFIX = 'permission-tracked-keys';
161
+ this.TRACKED_SCOPES_PREFIX = 'permission-tracked-scopes';
162
+ this.TTL = this.parseDurationMs(envConfig.getJwtConfig().refreshExpiration);
135
163
  }
136
164
  }
137
- _ts_decorate([
138
- LogAction({
139
- action: 'permissionCache.setPermissions',
140
- module: 'iam'
141
- }),
142
- _ts_metadata("design:type", Function),
143
- _ts_metadata("design:paramtypes", [
144
- typeof PermissionCacheKeyOptions === "undefined" ? Object : PermissionCacheKeyOptions,
145
- Array
146
- ]),
147
- _ts_metadata("design:returntype", Promise)
148
- ], PermissionCacheService.prototype, "setPermissions", null);
149
165
  _ts_decorate([
150
166
  LogAction({
151
167
  action: 'permissionCache.setMyPermissions',
@@ -158,18 +174,6 @@ _ts_decorate([
158
174
  ]),
159
175
  _ts_metadata("design:returntype", Promise)
160
176
  ], PermissionCacheService.prototype, "setMyPermissions", null);
161
- _ts_decorate([
162
- LogAction({
163
- action: 'permissionCache.setActionCodeMap',
164
- module: 'iam'
165
- }),
166
- _ts_metadata("design:type", Function),
167
- _ts_metadata("design:paramtypes", [
168
- typeof Record === "undefined" ? Object : Record,
169
- String
170
- ]),
171
- _ts_metadata("design:returntype", Promise)
172
- ], PermissionCacheService.prototype, "setActionCodeMap", null);
173
177
  _ts_decorate([
174
178
  LogAction({
175
179
  action: 'permissionCache.invalidateUser',
@@ -196,25 +200,14 @@ _ts_decorate([
196
200
  ]),
197
201
  _ts_metadata("design:returntype", Promise)
198
202
  ], PermissionCacheService.prototype, "invalidateUsers", null);
199
- _ts_decorate([
200
- LogAction({
201
- action: 'permissionCache.invalidateRole',
202
- module: 'iam'
203
- }),
204
- _ts_metadata("design:type", Function),
205
- _ts_metadata("design:paramtypes", [
206
- String,
207
- Array,
208
- Object,
209
- Array
210
- ]),
211
- _ts_metadata("design:returntype", Promise)
212
- ], PermissionCacheService.prototype, "invalidateRole", null);
213
203
  PermissionCacheService = _ts_decorate([
214
204
  Injectable(),
215
205
  _ts_param(0, Inject('CACHE_INSTANCE')),
206
+ _ts_param(1, Optional()),
207
+ _ts_param(1, Inject(PERMISSION_GUARD_CONFIG)),
216
208
  _ts_metadata("design:type", Function),
217
209
  _ts_metadata("design:paramtypes", [
218
- typeof HybridCache === "undefined" ? Object : HybridCache
210
+ typeof HybridCache === "undefined" ? Object : HybridCache,
211
+ typeof PermissionGuardConfig === "undefined" ? Object : PermissionGuardConfig
219
212
  ])
220
213
  ], PermissionCacheService);
@@ -0,0 +1,248 @@
1
+ import { buildPermissionCacheKey } from '@flusys/nestjs-shared';
2
+ import { PermissionCacheService } from './permission-cache.service';
3
+ function buildCacheData(overrides = {}) {
4
+ return {
5
+ frontendActions: [],
6
+ backendCodes: [],
7
+ ...overrides
8
+ };
9
+ }
10
+ describe('PermissionCacheService', ()=>{
11
+ let mockCacheManager;
12
+ beforeEach(()=>{
13
+ mockCacheManager = {
14
+ get: jest.fn(),
15
+ set: jest.fn(),
16
+ del: jest.fn()
17
+ };
18
+ });
19
+ function buildService(guardConfig) {
20
+ return new PermissionCacheService(mockCacheManager, guardConfig);
21
+ }
22
+ describe('setMyPermissions / getMyPermissions', ()=>{
23
+ it('stores data under the user-scoped key and tracks it', async ()=>{
24
+ const service = buildService();
25
+ mockCacheManager.get.mockResolvedValue(undefined);
26
+ const data = buildCacheData({
27
+ backendCodes: [
28
+ 'user.read'
29
+ ]
30
+ });
31
+ await service.setMyPermissions({
32
+ userId: 'user-1'
33
+ }, data);
34
+ const expectedKey = buildPermissionCacheKey({
35
+ userId: 'user-1'
36
+ });
37
+ expect(mockCacheManager.set).toHaveBeenCalledWith(expectedKey, data, expect.any(Number));
38
+ // tracked keys + tracked scopes get written too
39
+ expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys:user:user-1'), [
40
+ expectedKey
41
+ ], expect.any(Number));
42
+ expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'), [
43
+ 'none'
44
+ ], expect.any(Number));
45
+ });
46
+ it('does not duplicate an already-tracked key', async ()=>{
47
+ const service = buildService();
48
+ const expectedKey = buildPermissionCacheKey({
49
+ userId: 'user-1'
50
+ });
51
+ mockCacheManager.get.mockImplementation(async (key)=>{
52
+ if (key.includes('permission-tracked-keys')) return [
53
+ expectedKey
54
+ ];
55
+ if (key.includes('permission-tracked-scopes')) return [
56
+ 'none'
57
+ ];
58
+ return undefined;
59
+ });
60
+ await service.setMyPermissions({
61
+ userId: 'user-1'
62
+ }, buildCacheData());
63
+ const trackedKeysCalls = mockCacheManager.set.mock.calls.filter(([key])=>String(key).includes('permission-tracked-keys'));
64
+ expect(trackedKeysCalls).toHaveLength(0);
65
+ });
66
+ it('returns cached data on a hit', async ()=>{
67
+ const service = buildService();
68
+ const data = buildCacheData({
69
+ backendCodes: [
70
+ 'a.b'
71
+ ]
72
+ });
73
+ mockCacheManager.get.mockResolvedValue(data);
74
+ const result = await service.getMyPermissions({
75
+ userId: 'user-1'
76
+ });
77
+ expect(result).toBe(data);
78
+ });
79
+ it('returns null on a cache miss', async ()=>{
80
+ const service = buildService();
81
+ mockCacheManager.get.mockResolvedValue(undefined);
82
+ const result = await service.getMyPermissions({
83
+ userId: 'user-1'
84
+ });
85
+ expect(result).toBeNull();
86
+ });
87
+ it('returns null (not throw) when the cache backend errors', async ()=>{
88
+ const service = buildService();
89
+ mockCacheManager.get.mockRejectedValue(new Error('cache down'));
90
+ const result = await service.getMyPermissions({
91
+ userId: 'user-1'
92
+ });
93
+ expect(result).toBeNull();
94
+ });
95
+ it('uses the company-scoped key when guard config enables company scope', async ()=>{
96
+ const guardConfig = {
97
+ enableCompanyFeature: true
98
+ };
99
+ const service = buildService(guardConfig);
100
+ mockCacheManager.get.mockResolvedValue(undefined);
101
+ await service.setMyPermissions({
102
+ userId: 'user-1',
103
+ companyId: 'company-1'
104
+ }, buildCacheData());
105
+ const expectedKey = buildPermissionCacheKey({
106
+ userId: 'user-1',
107
+ companyId: 'company-1'
108
+ }, guardConfig);
109
+ expect(mockCacheManager.set).toHaveBeenCalledWith(expectedKey, expect.anything(), expect.any(Number));
110
+ });
111
+ });
112
+ describe('invalidateUser', ()=>{
113
+ it('invalidates only the given company scope when companyId is provided', async ()=>{
114
+ const service = buildService();
115
+ mockCacheManager.get.mockImplementation(async (key)=>{
116
+ if (key.includes('permission-tracked-keys')) return [
117
+ 'tracked-key-1',
118
+ 'tracked-key-2'
119
+ ];
120
+ return undefined;
121
+ });
122
+ await service.invalidateUser('user-1', 'company-1');
123
+ expect(mockCacheManager.del).toHaveBeenCalledWith('tracked-key-1');
124
+ expect(mockCacheManager.del).toHaveBeenCalledWith('tracked-key-2');
125
+ expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys:user:user-1:company:company-1'));
126
+ // no company scope given -> tracked-scopes key should not be touched in this branch
127
+ expect(mockCacheManager.del).not.toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes'));
128
+ });
129
+ it('invalidates only the requested branch keys, keeping remaining branches tracked', async ()=>{
130
+ // company-scoped key format only differentiates by branchId when the guard config
131
+ // enables the company feature — otherwise every branch maps to the same cache key.
132
+ const guardConfig = {
133
+ enableCompanyFeature: true
134
+ };
135
+ const service = buildService(guardConfig);
136
+ const keptKey = buildPermissionCacheKey({
137
+ userId: 'user-1',
138
+ companyId: 'company-1',
139
+ branchId: 'branch-keep'
140
+ }, guardConfig);
141
+ mockCacheManager.get.mockImplementation(async (key)=>{
142
+ if (key.includes('permission-tracked-keys')) return [
143
+ keptKey,
144
+ 'some-other-tracked-key'
145
+ ];
146
+ return undefined;
147
+ });
148
+ // companyId given directly invokes invalidateScope (bypasses the tracked-scopes fan-out)
149
+ await service.invalidateUser('user-1', 'company-1', [
150
+ 'branch-a'
151
+ ]);
152
+ // the specific branch key gets deleted
153
+ const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
154
+ expect(deletedKeys).toContain(buildPermissionCacheKey({
155
+ userId: 'user-1',
156
+ companyId: 'company-1',
157
+ branchId: 'branch-a'
158
+ }, guardConfig));
159
+ // remaining tracked keys get re-saved rather than the tracked-keys index being wiped
160
+ expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys'), expect.arrayContaining([
161
+ keptKey,
162
+ 'some-other-tracked-key'
163
+ ]), expect.any(Number));
164
+ });
165
+ it('deletes the tracked-keys index entirely when no remaining keys are left after branch filtering', async ()=>{
166
+ const guardConfig = {
167
+ enableCompanyFeature: true
168
+ };
169
+ const service = buildService(guardConfig);
170
+ const onlyKey = buildPermissionCacheKey({
171
+ userId: 'user-1',
172
+ companyId: 'company-1',
173
+ branchId: 'branch-a'
174
+ }, guardConfig);
175
+ mockCacheManager.get.mockImplementation(async (key)=>{
176
+ if (key.includes('permission-tracked-keys')) return [
177
+ onlyKey
178
+ ];
179
+ return undefined;
180
+ });
181
+ await service.invalidateUser('user-1', 'company-1', [
182
+ 'branch-a'
183
+ ]);
184
+ const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
185
+ expect(deletedKeys).toContain(onlyKey);
186
+ expect(deletedKeys.some((k)=>String(k).includes('permission-tracked-keys'))).toBe(true);
187
+ });
188
+ it('without a companyId, invalidates every tracked scope for the user and clears the scopes index', async ()=>{
189
+ const service = buildService();
190
+ mockCacheManager.get.mockImplementation(async (key)=>{
191
+ if (key.includes('permission-tracked-scopes')) return [
192
+ 'none',
193
+ 'company-a'
194
+ ];
195
+ if (key.includes('company:none')) return [
196
+ 'key-none-1'
197
+ ];
198
+ if (key.includes('company:company-a')) return [
199
+ 'key-company-a-1'
200
+ ];
201
+ return undefined;
202
+ });
203
+ await service.invalidateUser('user-1');
204
+ expect(mockCacheManager.del).toHaveBeenCalledWith('key-none-1');
205
+ expect(mockCacheManager.del).toHaveBeenCalledWith('key-company-a-1');
206
+ expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'));
207
+ });
208
+ it('treats a cache read error for tracked scopes as an empty list (no throw)', async ()=>{
209
+ const service = buildService();
210
+ mockCacheManager.get.mockRejectedValue(new Error('down'));
211
+ await expect(service.invalidateUser('user-1')).resolves.toBeUndefined();
212
+ expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'));
213
+ });
214
+ });
215
+ describe('invalidateUsers', ()=>{
216
+ it('returns 0 immediately for an empty user list', async ()=>{
217
+ const service = buildService();
218
+ const result = await service.invalidateUsers([]);
219
+ expect(result).toBe(0);
220
+ expect(mockCacheManager.get).not.toHaveBeenCalled();
221
+ });
222
+ it('invalidates every user and returns the count of successful invalidations', async ()=>{
223
+ const service = buildService();
224
+ mockCacheManager.get.mockResolvedValue(undefined);
225
+ const result = await service.invalidateUsers([
226
+ 'user-1',
227
+ 'user-2',
228
+ 'user-3'
229
+ ], 'company-1');
230
+ expect(result).toBe(3);
231
+ });
232
+ it('does not let one user failing prevent the others from being counted', async ()=>{
233
+ const service = buildService();
234
+ let callCount = 0;
235
+ mockCacheManager.get.mockImplementation(async ()=>{
236
+ callCount += 1;
237
+ if (callCount === 1) throw new Error('boom');
238
+ return undefined;
239
+ });
240
+ const result = await service.invalidateUsers([
241
+ 'user-1',
242
+ 'user-2'
243
+ ], 'company-1');
244
+ // invalidateUser swallows its own read errors, so both still resolve successfully
245
+ expect(result).toBe(2);
246
+ });
247
+ });
248
+ });