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