@flusys/nestjs-iam 7.1.0 → 8.0.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 (175) hide show
  1. package/config/index.d.ts +3 -3
  2. package/config/message-keys.d.ts +2 -0
  3. package/controllers/action.controller.d.ts +4 -4
  4. package/controllers/company-action-permission.controller.d.ts +2 -2
  5. package/controllers/index.d.ts +6 -6
  6. package/controllers/my-permission.controller.d.ts +3 -3
  7. package/controllers/role-permission.controller.d.ts +5 -4
  8. package/controllers/role.controller.d.ts +2 -2
  9. package/controllers/user-action-permission.controller.d.ts +5 -4
  10. package/docs/iam-swagger.config.d.ts +2 -2
  11. package/docs/index.d.ts +1 -1
  12. package/dtos/action.dto.d.ts +2 -2
  13. package/dtos/index.d.ts +3 -3
  14. package/dtos/permission.dto.d.ts +16 -0
  15. package/entities/action.entity.d.ts +2 -2
  16. package/entities/index.d.ts +11 -11
  17. package/entities/permission-with-company.entity.d.ts +1 -1
  18. package/entities/role-with-company.entity.d.ts +1 -1
  19. package/entities/role.entity.d.ts +1 -1
  20. package/entities/user-iam-permission.entity.d.ts +1 -1
  21. package/enums/index.d.ts +4 -4
  22. package/fesm/284.js +1316 -0
  23. package/fesm/867.js +3801 -0
  24. package/fesm/chunks/364.js +86 -0
  25. package/fesm/config/index.js +135 -3
  26. package/fesm/controllers/index.js +18 -7
  27. package/fesm/docs/index.js +240 -1
  28. package/fesm/dtos/index.js +1168 -3
  29. package/fesm/entities/index.js +670 -31
  30. package/fesm/enums/index.js +97 -4
  31. package/fesm/helpers/index.js +125 -2
  32. package/fesm/index.js +818 -11
  33. package/fesm/interfaces/index.js +17 -3
  34. package/fesm/modules/index.js +259 -1
  35. package/fesm/runtime.js +134 -0
  36. package/fesm/services/index.js +43 -6
  37. package/fesm/types/index.js +15 -1
  38. package/helpers/company-access.helper.d.ts +2 -2
  39. package/helpers/index.d.ts +2 -2
  40. package/helpers/permission-mode.helper.d.ts +1 -1
  41. package/index.d.ts +11 -11
  42. package/interfaces/action.interface.d.ts +2 -2
  43. package/interfaces/iam-module-options.interface.d.ts +3 -3
  44. package/interfaces/index.d.ts +3 -3
  45. package/modules/iam.module.d.ts +2 -2
  46. package/modules/index.d.ts +1 -1
  47. package/package.json +30 -37
  48. package/services/action.service.d.ts +9 -9
  49. package/services/iam-config.service.d.ts +4 -4
  50. package/services/iam-datasource.service.d.ts +3 -3
  51. package/services/index.d.ts +6 -6
  52. package/services/permission-cache.service.d.ts +5 -5
  53. package/services/permission.service.d.ts +6 -4
  54. package/services/role.service.d.ts +8 -8
  55. package/types/index.d.ts +1 -1
  56. package/cjs/config/event-actions.js +0 -36
  57. package/cjs/config/iam.constants.js +0 -11
  58. package/cjs/config/index.js +0 -20
  59. package/cjs/config/message-keys.js +0 -71
  60. package/cjs/controllers/action.controller.js +0 -174
  61. package/cjs/controllers/action.controller.spec.js +0 -79
  62. package/cjs/controllers/company-action-permission.controller.js +0 -119
  63. package/cjs/controllers/company-action-permission.controller.spec.js +0 -65
  64. package/cjs/controllers/index.js +0 -23
  65. package/cjs/controllers/my-permission.controller.js +0 -147
  66. package/cjs/controllers/my-permission.controller.spec.js +0 -128
  67. package/cjs/controllers/role-permission.controller.js +0 -191
  68. package/cjs/controllers/role-permission.controller.spec.js +0 -140
  69. package/cjs/controllers/role.controller.js +0 -102
  70. package/cjs/controllers/role.controller.spec.js +0 -86
  71. package/cjs/controllers/user-action-permission.controller.js +0 -129
  72. package/cjs/controllers/user-action-permission.controller.spec.js +0 -68
  73. package/cjs/docs/iam-swagger.config.js +0 -209
  74. package/cjs/docs/iam-swagger.config.spec.js +0 -99
  75. package/cjs/docs/index.js +0 -18
  76. package/cjs/dtos/action.dto.js +0 -282
  77. package/cjs/dtos/index.js +0 -21
  78. package/cjs/dtos/permission.dto.js +0 -637
  79. package/cjs/dtos/role.dto.js +0 -155
  80. package/cjs/entities/action.entity.js +0 -138
  81. package/cjs/entities/index.js +0 -80
  82. package/cjs/entities/index.spec.js +0 -56
  83. package/cjs/entities/permission-base.entity.js +0 -107
  84. package/cjs/entities/permission-with-company.entity.js +0 -100
  85. package/cjs/entities/role-base.entity.js +0 -80
  86. package/cjs/entities/role-with-company.entity.js +0 -55
  87. package/cjs/entities/role.entity.js +0 -25
  88. package/cjs/entities/user-iam-permission.entity.js +0 -43
  89. package/cjs/enums/action-type.enum.js +0 -22
  90. package/cjs/enums/iam-entity-type.enum.js +0 -17
  91. package/cjs/enums/iam-permission-type.enum.js +0 -17
  92. package/cjs/enums/index.js +0 -21
  93. package/cjs/enums/permission-type.enum.js +0 -16
  94. package/cjs/helpers/company-access.helper.js +0 -23
  95. package/cjs/helpers/company-access.helper.spec.js +0 -68
  96. package/cjs/helpers/index.js +0 -19
  97. package/cjs/helpers/permission-mode.helper.js +0 -49
  98. package/cjs/helpers/permission-mode.helper.spec.js +0 -54
  99. package/cjs/index.js +0 -28
  100. package/cjs/interfaces/action.interface.js +0 -4
  101. package/cjs/interfaces/iam-module-options.interface.js +0 -4
  102. package/cjs/interfaces/index.js +0 -20
  103. package/cjs/interfaces/role.interface.js +0 -7
  104. package/cjs/modules/iam.module.js +0 -186
  105. package/cjs/modules/iam.module.spec.js +0 -282
  106. package/cjs/modules/index.js +0 -18
  107. package/cjs/services/action.service.js +0 -337
  108. package/cjs/services/action.service.spec.js +0 -351
  109. package/cjs/services/iam-config.service.js +0 -87
  110. package/cjs/services/iam-config.service.spec.js +0 -110
  111. package/cjs/services/iam-datasource.service.js +0 -129
  112. package/cjs/services/iam-datasource.service.spec.js +0 -110
  113. package/cjs/services/index.js +0 -23
  114. package/cjs/services/permission-cache.service.js +0 -544
  115. package/cjs/services/permission-cache.service.spec.js +0 -718
  116. package/cjs/services/permission.service.js +0 -914
  117. package/cjs/services/permission.service.spec.js +0 -918
  118. package/cjs/services/role.service.js +0 -224
  119. package/cjs/services/role.service.spec.js +0 -347
  120. package/cjs/types/index.js +0 -18
  121. package/cjs/types/logic-node.type.js +0 -30
  122. package/fesm/config/event-actions.js +0 -15
  123. package/fesm/config/iam.constants.js +0 -1
  124. package/fesm/config/message-keys.js +0 -35
  125. package/fesm/controllers/action.controller.js +0 -164
  126. package/fesm/controllers/action.controller.spec.js +0 -75
  127. package/fesm/controllers/company-action-permission.controller.js +0 -109
  128. package/fesm/controllers/company-action-permission.controller.spec.js +0 -61
  129. package/fesm/controllers/my-permission.controller.js +0 -137
  130. package/fesm/controllers/my-permission.controller.spec.js +0 -124
  131. package/fesm/controllers/role-permission.controller.js +0 -181
  132. package/fesm/controllers/role-permission.controller.spec.js +0 -136
  133. package/fesm/controllers/role.controller.js +0 -92
  134. package/fesm/controllers/role.controller.spec.js +0 -82
  135. package/fesm/controllers/user-action-permission.controller.js +0 -119
  136. package/fesm/controllers/user-action-permission.controller.spec.js +0 -64
  137. package/fesm/docs/iam-swagger.config.js +0 -199
  138. package/fesm/docs/iam-swagger.config.spec.js +0 -95
  139. package/fesm/dtos/action.dto.js +0 -252
  140. package/fesm/dtos/permission.dto.js +0 -571
  141. package/fesm/dtos/role.dto.js +0 -134
  142. package/fesm/entities/action.entity.js +0 -128
  143. package/fesm/entities/index.spec.js +0 -52
  144. package/fesm/entities/permission-base.entity.js +0 -100
  145. package/fesm/entities/permission-with-company.entity.js +0 -90
  146. package/fesm/entities/role-base.entity.js +0 -70
  147. package/fesm/entities/role-with-company.entity.js +0 -45
  148. package/fesm/entities/role.entity.js +0 -15
  149. package/fesm/entities/user-iam-permission.entity.js +0 -33
  150. package/fesm/enums/action-type.enum.js +0 -12
  151. package/fesm/enums/iam-entity-type.enum.js +0 -7
  152. package/fesm/enums/iam-permission-type.enum.js +0 -7
  153. package/fesm/enums/permission-type.enum.js +0 -6
  154. package/fesm/helpers/company-access.helper.js +0 -18
  155. package/fesm/helpers/company-access.helper.spec.js +0 -64
  156. package/fesm/helpers/permission-mode.helper.js +0 -49
  157. package/fesm/helpers/permission-mode.helper.spec.js +0 -50
  158. package/fesm/interfaces/action.interface.js +0 -3
  159. package/fesm/interfaces/iam-module-options.interface.js +0 -3
  160. package/fesm/interfaces/role.interface.js +0 -4
  161. package/fesm/modules/iam.module.js +0 -176
  162. package/fesm/modules/iam.module.spec.js +0 -278
  163. package/fesm/services/action.service.js +0 -327
  164. package/fesm/services/action.service.spec.js +0 -347
  165. package/fesm/services/iam-config.service.js +0 -77
  166. package/fesm/services/iam-config.service.spec.js +0 -106
  167. package/fesm/services/iam-datasource.service.js +0 -78
  168. package/fesm/services/iam-datasource.service.spec.js +0 -106
  169. package/fesm/services/permission-cache.service.js +0 -527
  170. package/fesm/services/permission-cache.service.spec.js +0 -714
  171. package/fesm/services/permission.service.js +0 -904
  172. package/fesm/services/permission.service.spec.js +0 -914
  173. package/fesm/services/role.service.js +0 -214
  174. package/fesm/services/role.service.spec.js +0 -343
  175. package/fesm/types/logic-node.type.js +0 -27
@@ -1,714 +0,0 @@
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';
10
- import { PermissionCacheService } from './permission-cache.service';
11
- function buildCacheData(overrides = {}) {
12
- return {
13
- frontendActions: [],
14
- backendCodes: [],
15
- ...overrides
16
- };
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
- }
64
- describe('PermissionCacheService', ()=>{
65
- let mockCacheManager;
66
- let mockIamConfigService;
67
- let mockPermissionRepo;
68
- let mockActionRepo;
69
- let mockRoleRepo;
70
- let mockDataSourceProvider;
71
- beforeEach(()=>{
72
- mockCacheManager = {
73
- get: jest.fn(),
74
- set: jest.fn(),
75
- del: jest.fn()
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
- };
92
- });
93
- function buildService(guardConfig) {
94
- return new PermissionCacheService(mockCacheManager, mockIamConfigService, mockDataSourceProvider, guardConfig);
95
- }
96
- describe('setMyPermissions / getMyPermissions', ()=>{
97
- it('stores data under the user-scoped key and tracks it', async ()=>{
98
- const service = buildService();
99
- mockCacheManager.get.mockResolvedValue(undefined);
100
- const data = buildCacheData({
101
- backendCodes: [
102
- 'user.read'
103
- ]
104
- });
105
- await service.setMyPermissions({
106
- userId: 'user-1'
107
- }, data);
108
- const expectedKey = buildPermissionCacheKey({
109
- userId: 'user-1'
110
- });
111
- expect(mockCacheManager.set).toHaveBeenCalledWith(expectedKey, data, expect.any(Number));
112
- // no company scope -> the raw cache key itself is tracked directly in the scopes index
113
- expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'), [
114
- expectedKey
115
- ], expect.any(Number));
116
- expect(mockCacheManager.set).not.toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys'), expect.anything(), expect.any(Number));
117
- });
118
- it('does not duplicate an already-tracked key', async ()=>{
119
- const service = buildService();
120
- const expectedKey = buildPermissionCacheKey({
121
- userId: 'user-1'
122
- });
123
- mockCacheManager.get.mockImplementation(async (key)=>{
124
- if (key.includes('permission-tracked-keys')) return [
125
- expectedKey
126
- ];
127
- if (key.includes('permission-tracked-scopes')) return [
128
- 'none'
129
- ];
130
- return undefined;
131
- });
132
- await service.setMyPermissions({
133
- userId: 'user-1'
134
- }, buildCacheData());
135
- const trackedKeysCalls = mockCacheManager.set.mock.calls.filter(([key])=>String(key).includes('permission-tracked-keys'));
136
- expect(trackedKeysCalls).toHaveLength(0);
137
- });
138
- it('returns cached data on a hit', async ()=>{
139
- const service = buildService();
140
- const data = buildCacheData({
141
- backendCodes: [
142
- 'a.b'
143
- ]
144
- });
145
- mockCacheManager.get.mockResolvedValue(data);
146
- const result = await service.getMyPermissions({
147
- userId: 'user-1'
148
- });
149
- expect(result).toBe(data);
150
- });
151
- it('returns null on a cache miss', async ()=>{
152
- const service = buildService();
153
- mockCacheManager.get.mockResolvedValue(undefined);
154
- const result = await service.getMyPermissions({
155
- userId: 'user-1'
156
- });
157
- expect(result).toBeNull();
158
- });
159
- it('returns null (not throw) when the cache backend errors', async ()=>{
160
- const service = buildService();
161
- mockCacheManager.get.mockRejectedValue(new Error('cache down'));
162
- const result = await service.getMyPermissions({
163
- userId: 'user-1'
164
- });
165
- expect(result).toBeNull();
166
- });
167
- it('uses the company-scoped key when guard config enables company scope', async ()=>{
168
- const guardConfig = {
169
- enableCompanyFeature: true
170
- };
171
- const service = buildService(guardConfig);
172
- mockCacheManager.get.mockResolvedValue(undefined);
173
- await service.setMyPermissions({
174
- userId: 'user-1',
175
- companyId: 'company-1'
176
- }, buildCacheData());
177
- const expectedKey = buildPermissionCacheKey({
178
- userId: 'user-1',
179
- companyId: 'company-1'
180
- }, guardConfig);
181
- expect(mockCacheManager.set).toHaveBeenCalledWith(expectedKey, expect.anything(), expect.any(Number));
182
- });
183
- });
184
- describe('invalidateUser', ()=>{
185
- it('invalidates only the given company scope when companyId is provided, narrowing the scopes index', async ()=>{
186
- const service = buildService();
187
- const trackedKeysKey = 'permission-tracked-keys:user:user-1:company:company-1';
188
- mockCacheManager.get.mockImplementation(async (key)=>{
189
- if (key.includes('permission-tracked-keys')) return [
190
- 'tracked-key-1',
191
- 'tracked-key-2'
192
- ];
193
- if (key.includes('permission-tracked-scopes')) return [
194
- trackedKeysKey,
195
- 'other-scope'
196
- ];
197
- return undefined;
198
- });
199
- await service.invalidateUser('user-1', 'company-1');
200
- expect(mockCacheManager.del).toHaveBeenCalledWith('tracked-key-1');
201
- expect(mockCacheManager.del).toHaveBeenCalledWith('tracked-key-2');
202
- expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys:user:user-1:company:company-1'));
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));
207
- expect(mockCacheManager.del).not.toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes'));
208
- });
209
- it('invalidates only the requested branch keys, keeping remaining branches tracked', async ()=>{
210
- const guardConfig = {
211
- enableCompanyFeature: true
212
- };
213
- const service = buildService(guardConfig);
214
- const keptKey = buildPermissionCacheKey({
215
- userId: 'user-1',
216
- companyId: 'company-1',
217
- branchId: 'branch-keep'
218
- }, guardConfig);
219
- mockCacheManager.get.mockImplementation(async (key)=>{
220
- if (key.includes('permission-tracked-keys')) return [
221
- keptKey,
222
- 'some-other-tracked-key'
223
- ];
224
- return undefined;
225
- });
226
- await service.invalidateUser('user-1', 'company-1', [
227
- 'branch-a'
228
- ]);
229
- const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
230
- expect(deletedKeys).toContain(buildPermissionCacheKey({
231
- userId: 'user-1',
232
- companyId: 'company-1',
233
- branchId: 'branch-a'
234
- }, guardConfig));
235
- expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys'), expect.arrayContaining([
236
- keptKey,
237
- 'some-other-tracked-key'
238
- ]), expect.any(Number));
239
- });
240
- it('deletes the tracked-keys index entirely when no remaining keys are left after branch filtering', async ()=>{
241
- const guardConfig = {
242
- enableCompanyFeature: true
243
- };
244
- const service = buildService(guardConfig);
245
- const onlyKey = buildPermissionCacheKey({
246
- userId: 'user-1',
247
- companyId: 'company-1',
248
- branchId: 'branch-a'
249
- }, guardConfig);
250
- mockCacheManager.get.mockImplementation(async (key)=>{
251
- if (key.includes('permission-tracked-keys')) return [
252
- onlyKey
253
- ];
254
- return undefined;
255
- });
256
- await service.invalidateUser('user-1', 'company-1', [
257
- 'branch-a'
258
- ]);
259
- const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
260
- expect(deletedKeys).toContain(onlyKey);
261
- expect(deletedKeys.some((k)=>String(k).includes('permission-tracked-keys'))).toBe(true);
262
- });
263
- it('wipes every tracked branch key when a null branchId (company-wide grant) is in the scope list', async ()=>{
264
- const guardConfig = {
265
- enableCompanyFeature: true
266
- };
267
- const service = buildService(guardConfig);
268
- const branchAKey = buildPermissionCacheKey({
269
- userId: 'user-1',
270
- companyId: 'company-1',
271
- branchId: 'branch-a'
272
- }, guardConfig);
273
- const branchBKey = buildPermissionCacheKey({
274
- userId: 'user-1',
275
- companyId: 'company-1',
276
- branchId: 'branch-b'
277
- }, guardConfig);
278
- const trackedKeysKey = 'permission-tracked-keys:user:user-1:company:company-1';
279
- mockCacheManager.get.mockImplementation(async (key)=>{
280
- if (key.includes('permission-tracked-keys')) return [
281
- branchAKey,
282
- branchBKey
283
- ];
284
- return undefined;
285
- });
286
- await service.invalidateUser('user-1', 'company-1', [
287
- null
288
- ]);
289
- const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
290
- expect(deletedKeys).toContain(branchAKey);
291
- expect(deletedKeys).toContain(branchBKey);
292
- expect(deletedKeys.some((k)=>String(k).includes(trackedKeysKey))).toBe(true);
293
- });
294
- it('without a companyId, invalidates every tracked scope for the user and clears the scopes index', async ()=>{
295
- const service = buildService();
296
- // scopes list mixes a nested tracked-keys index (company scope) with a raw cache key (untracked scope)
297
- const trackedKeysKey = 'permission-tracked-keys:user:user-1:company:company-a';
298
- const rawKey = 'my-permissions:user:user-1';
299
- mockCacheManager.get.mockImplementation(async (key)=>{
300
- if (key.includes('permission-tracked-scopes')) return [
301
- trackedKeysKey,
302
- rawKey
303
- ];
304
- if (key === trackedKeysKey) return [
305
- 'key-company-a-1'
306
- ];
307
- return undefined;
308
- });
309
- await service.invalidateUser('user-1');
310
- expect(mockCacheManager.del).toHaveBeenCalledWith('key-company-a-1');
311
- expect(mockCacheManager.del).toHaveBeenCalledWith(trackedKeysKey);
312
- expect(mockCacheManager.del).toHaveBeenCalledWith(rawKey);
313
- expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'));
314
- });
315
- it('treats a cache read error for tracked scopes as an empty list (no throw)', async ()=>{
316
- const service = buildService();
317
- mockCacheManager.get.mockRejectedValue(new Error('down'));
318
- await expect(service.invalidateUser('user-1')).resolves.toBeUndefined();
319
- expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'));
320
- });
321
- });
322
- describe('invalidateUsers', ()=>{
323
- it('returns 0 immediately for an empty user list', async ()=>{
324
- const service = buildService();
325
- const result = await service.invalidateUsers([]);
326
- expect(result).toBe(0);
327
- expect(mockCacheManager.get).not.toHaveBeenCalled();
328
- });
329
- it('invalidates every user and returns the count of successful invalidations', async ()=>{
330
- const service = buildService();
331
- mockCacheManager.get.mockResolvedValue(undefined);
332
- const result = await service.invalidateUsers([
333
- 'user-1',
334
- 'user-2',
335
- 'user-3'
336
- ], 'company-1');
337
- expect(result).toBe(3);
338
- });
339
- it('does not let one user failing prevent the others from being counted', async ()=>{
340
- const service = buildService();
341
- let callCount = 0;
342
- mockCacheManager.get.mockImplementation(async ()=>{
343
- callCount += 1;
344
- if (callCount === 1) throw new Error('boom');
345
- return undefined;
346
- });
347
- const result = await service.invalidateUsers([
348
- 'user-1',
349
- 'user-2'
350
- ], 'company-1');
351
- // invalidateUser swallows its own read errors, so both still resolve successfully
352
- expect(result).toBe(2);
353
- });
354
- });
355
- describe('getMyPermissionsResponse', ()=>{
356
- it('returns the response built directly from cached data on a cache hit', async ()=>{
357
- const service = buildService();
358
- mockCacheManager.get.mockResolvedValue(buildCacheData({
359
- frontendActions: [
360
- {
361
- id: 'action-1',
362
- code: 'user.view',
363
- name: 'View',
364
- description: null,
365
- parentId: null
366
- }
367
- ],
368
- backendCodes: [
369
- 'user.read'
370
- ]
371
- }));
372
- const result = await service.getMyPermissionsResponse('user-1', null, 'company-1');
373
- expect(result.frontendActions).toHaveLength(1);
374
- expect(result.cachedEndpoints).toBe(1);
375
- expect(mockPermissionRepo.find).not.toHaveBeenCalled();
376
- });
377
- it('fetches from the database and caches the result on a cache miss', async ()=>{
378
- const service = buildService();
379
- mockCacheManager.get.mockResolvedValue(undefined);
380
- mockPermissionRepo.find.mockResolvedValue([]);
381
- const result = await service.getMyPermissionsResponse('user-1', null, null);
382
- expect(result).toEqual({
383
- frontendActions: [],
384
- cachedEndpoints: 0
385
- });
386
- expect(mockCacheManager.set).toHaveBeenCalledWith(expect.any(String), {
387
- frontendActions: [],
388
- backendCodes: []
389
- }, expect.any(Number));
390
- });
391
- it('filters frontend actions by parentCodes when provided', async ()=>{
392
- const service = buildService();
393
- mockCacheManager.get.mockImplementation(async (key)=>{
394
- if (key.includes('permission-tracked')) return undefined;
395
- return buildCacheData({
396
- frontendActions: [
397
- {
398
- id: 'child-1',
399
- code: 'user.view',
400
- name: 'View',
401
- description: null,
402
- parentId: 'parent-1'
403
- },
404
- {
405
- id: 'child-2',
406
- code: 'other.view',
407
- name: 'View 2',
408
- description: null,
409
- parentId: 'other-parent'
410
- }
411
- ],
412
- backendCodes: []
413
- });
414
- });
415
- mockActionRepo.find.mockResolvedValue([
416
- {
417
- id: 'parent-1',
418
- code: 'user'
419
- }
420
- ]);
421
- const result = await service.getMyPermissionsResponse('user-1', null, null, [
422
- 'user'
423
- ]);
424
- expect(result.frontendActions).toHaveLength(1);
425
- expect(result.frontendActions[0].code).toBe('user.view');
426
- });
427
- it('returns an empty frontendActions list when parentCodes resolve to no known parents', async ()=>{
428
- const service = buildService();
429
- mockCacheManager.get.mockImplementation(async (key)=>{
430
- if (key.includes('permission-tracked')) return undefined;
431
- return buildCacheData({
432
- frontendActions: [
433
- {
434
- id: 'child-1',
435
- code: 'user.view',
436
- name: 'View',
437
- description: null,
438
- parentId: 'parent-1'
439
- }
440
- ],
441
- backendCodes: []
442
- });
443
- });
444
- mockActionRepo.find.mockResolvedValue([]);
445
- const result = await service.getMyPermissionsResponse('user-1', null, null, [
446
- 'unknown-parent-code'
447
- ]);
448
- expect(result.frontendActions).toEqual([]);
449
- });
450
- });
451
- describe('collectAllActionIds (permission-mode branching)', ()=>{
452
- it('RBAC mode: collects only role-derived action ids, ignoring direct user actions', async ()=>{
453
- const service = buildService();
454
- mockIamConfigService.getPermissionMode.mockReturnValue(IAMPermissionMode.RBAC);
455
- mockPermissionRepo.find.mockImplementation(async ({ where })=>{
456
- if (where.permissionType === IamPermissionType.USER_ROLE) {
457
- return [
458
- buildPermissionRow({
459
- permissionType: IamPermissionType.USER_ROLE,
460
- targetId: 'role-1'
461
- })
462
- ];
463
- }
464
- if (where.permissionType === IamPermissionType.ROLE_ACTION) {
465
- return [
466
- buildPermissionRow({
467
- permissionType: IamPermissionType.ROLE_ACTION,
468
- targetId: 'action-role-1'
469
- })
470
- ];
471
- }
472
- if (where.permissionType === IamPermissionType.USER_ACTION) {
473
- throw new Error('DIRECT lookup should not run in RBAC mode');
474
- }
475
- return [];
476
- });
477
- const result = await service.collectAllActionIds('user-1', null, null);
478
- expect(Array.from(result)).toEqual([
479
- 'action-role-1'
480
- ]);
481
- });
482
- it('DIRECT mode: collects only directly-assigned user action ids, ignoring roles', async ()=>{
483
- const service = buildService();
484
- mockIamConfigService.getPermissionMode.mockReturnValue(IAMPermissionMode.DIRECT);
485
- mockPermissionRepo.find.mockImplementation(async ({ where })=>{
486
- if (where.permissionType === IamPermissionType.USER_ROLE) {
487
- throw new Error('RBAC lookup should not run in DIRECT mode');
488
- }
489
- if (where.permissionType === IamPermissionType.USER_ACTION) {
490
- return [
491
- buildPermissionRow({
492
- permissionType: IamPermissionType.USER_ACTION,
493
- targetId: 'action-direct-1'
494
- })
495
- ];
496
- }
497
- return [];
498
- });
499
- const result = await service.collectAllActionIds('user-1', null, null);
500
- expect(Array.from(result)).toEqual([
501
- 'action-direct-1'
502
- ]);
503
- });
504
- it('FULL mode: merges role-derived and direct action ids, de-duplicating overlaps', async ()=>{
505
- const service = buildService();
506
- mockIamConfigService.getPermissionMode.mockReturnValue(IAMPermissionMode.FULL);
507
- mockPermissionRepo.find.mockImplementation(async ({ where })=>{
508
- if (where.permissionType === IamPermissionType.USER_ROLE) {
509
- return [
510
- buildPermissionRow({
511
- permissionType: IamPermissionType.USER_ROLE,
512
- targetId: 'role-1'
513
- })
514
- ];
515
- }
516
- if (where.permissionType === IamPermissionType.ROLE_ACTION) {
517
- return [
518
- buildPermissionRow({
519
- permissionType: IamPermissionType.ROLE_ACTION,
520
- targetId: 'action-shared'
521
- })
522
- ];
523
- }
524
- if (where.permissionType === IamPermissionType.USER_ACTION) {
525
- return [
526
- buildPermissionRow({
527
- permissionType: IamPermissionType.USER_ACTION,
528
- targetId: 'action-shared'
529
- }),
530
- buildPermissionRow({
531
- permissionType: IamPermissionType.USER_ACTION,
532
- targetId: 'action-direct-only'
533
- })
534
- ];
535
- }
536
- return [];
537
- });
538
- const result = await service.collectAllActionIds('user-1', null, null);
539
- expect(Array.from(result).sort()).toEqual([
540
- 'action-direct-only',
541
- 'action-shared'
542
- ]);
543
- });
544
- it('excludes expired/not-yet-valid permission rows via isValid()', async ()=>{
545
- const service = buildService();
546
- mockIamConfigService.getPermissionMode.mockReturnValue(IAMPermissionMode.DIRECT);
547
- const expired = buildPermissionRow({
548
- permissionType: IamPermissionType.USER_ACTION,
549
- targetId: 'action-expired',
550
- validUntil: new Date('2000-01-01')
551
- });
552
- const active = buildPermissionRow({
553
- permissionType: IamPermissionType.USER_ACTION,
554
- targetId: 'action-active'
555
- });
556
- mockPermissionRepo.find.mockResolvedValue([
557
- expired,
558
- active
559
- ]);
560
- const result = await service.collectAllActionIds('user-1', null, null);
561
- expect(Array.from(result)).toEqual([
562
- 'action-active'
563
- ]);
564
- });
565
- });
566
- describe('buildAndCachePermissionData (via getMyPermissionsResponse on a cache miss)', ()=>{
567
- it('splits actions into frontend/backend and caches them', async ()=>{
568
- const service = buildService();
569
- mockIamConfigService.getPermissionMode.mockReturnValue(IAMPermissionMode.DIRECT);
570
- mockPermissionRepo.find.mockResolvedValue([
571
- buildPermissionRow({
572
- permissionType: IamPermissionType.USER_ACTION,
573
- targetId: 'action-1'
574
- })
575
- ]);
576
- mockCacheManager.get.mockResolvedValue(undefined);
577
- mockActionRepo.find.mockResolvedValue([
578
- buildAction({
579
- id: 'action-1',
580
- actionType: ActionType.BOTH,
581
- code: 'user.view'
582
- })
583
- ]);
584
- const result = await service.getMyPermissionsResponse('user-1', null, null);
585
- expect(result.cachedEndpoints).toBe(1);
586
- expect(result.frontendActions).toEqual([
587
- {
588
- code: 'user.view',
589
- name: 'View Users',
590
- description: null
591
- }
592
- ]);
593
- expect(mockCacheManager.set).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
594
- backendCodes: [
595
- 'user.view'
596
- ]
597
- }), expect.any(Number));
598
- });
599
- });
600
- describe('invalidateCompanyMembersCache', ()=>{
601
- it('returns 0 without querying when the company feature is disabled', async ()=>{
602
- const service = buildService();
603
- const result = await service.invalidateCompanyMembersCache('company-1');
604
- expect(result).toBe(0);
605
- expect(mockPermissionRepo.createQueryBuilder).not.toHaveBeenCalled();
606
- });
607
- it('returns 0 when the company has no members with permissions', async ()=>{
608
- const service = buildService();
609
- mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
610
- mockRawMany(mockPermissionRepo, []);
611
- const result = await service.invalidateCompanyMembersCache('company-1');
612
- expect(result).toBe(0);
613
- });
614
- it('invalidates every distinct member user id for the company', async ()=>{
615
- const service = buildService();
616
- mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
617
- mockRawMany(mockPermissionRepo, [
618
- {
619
- userId: 'user-1'
620
- },
621
- {
622
- userId: 'user-2'
623
- }
624
- ]);
625
- mockCacheManager.get.mockResolvedValue(undefined);
626
- const result = await service.invalidateCompanyMembersCache('company-1');
627
- expect(result).toBe(2);
628
- });
629
- it('wipes every tracked cache entry per member instead of only branches with an existing permission row', async ()=>{
630
- const service = buildService();
631
- mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
632
- mockRawMany(mockPermissionRepo, [
633
- {
634
- userId: 'user-1'
635
- }
636
- ]);
637
- const trackedKeysKey = 'permission-tracked-keys:user:user-1:company:company-1';
638
- mockCacheManager.get.mockImplementation(async (key)=>{
639
- if (key.includes('permission-tracked-keys')) {
640
- return [
641
- 'cached-key-branch-untracked-by-any-permission-row'
642
- ];
643
- }
644
- return undefined;
645
- });
646
- const result = await service.invalidateCompanyMembersCache('company-1');
647
- const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
648
- expect(deletedKeys).toContain('cached-key-branch-untracked-by-any-permission-row');
649
- expect(deletedKeys).toContain(trackedKeysKey);
650
- expect(result).toBe(1);
651
- });
652
- });
653
- describe('invalidateRoleMembersCache', ()=>{
654
- it('returns 0 when the role has no members', async ()=>{
655
- const service = buildService();
656
- mockPermissionRepo.find.mockResolvedValue([]);
657
- const result = await service.invalidateRoleMembersCache('role-1');
658
- expect(result).toBe(0);
659
- expect(mockRoleRepo.findOne).not.toHaveBeenCalled();
660
- });
661
- it('invalidates every distinct user holding the role, scoped to the role company', async ()=>{
662
- const service = buildService();
663
- mockPermissionRepo.find.mockResolvedValue([
664
- buildPermissionRow({
665
- sourceId: 'user-9'
666
- }),
667
- buildPermissionRow({
668
- id: 'perm-2',
669
- sourceId: 'user-9'
670
- })
671
- ]);
672
- mockRoleRepo.findOne.mockResolvedValue({
673
- id: 'role-1',
674
- companyId: null
675
- });
676
- mockCacheManager.get.mockResolvedValue(undefined);
677
- const result = await service.invalidateRoleMembersCache('role-1');
678
- expect(mockRoleRepo.findOne).toHaveBeenCalledWith({
679
- where: {
680
- id: 'role-1'
681
- }
682
- });
683
- expect(result).toBe(1);
684
- });
685
- it('wipes every tracked cache entry for role members instead of only branches with an existing permission row', async ()=>{
686
- const service = buildService();
687
- mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
688
- mockPermissionRepo.find.mockResolvedValue([
689
- buildPermissionRow({
690
- sourceId: 'user-9'
691
- })
692
- ]);
693
- mockRoleRepo.findOne.mockResolvedValue({
694
- id: 'role-1',
695
- companyId: 'company-1'
696
- });
697
- const trackedKeysKey = 'permission-tracked-keys:user:user-9:company:company-1';
698
- mockCacheManager.get.mockImplementation(async (key)=>{
699
- if (key.includes('permission-tracked-keys')) {
700
- return [
701
- 'cached-key-branch-untracked-by-any-permission-row'
702
- ];
703
- }
704
- return undefined;
705
- });
706
- const result = await service.invalidateRoleMembersCache('role-1');
707
- expect(mockPermissionRepo.createQueryBuilder).not.toHaveBeenCalled();
708
- const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
709
- expect(deletedKeys).toContain('cached-key-branch-untracked-by-any-permission-row');
710
- expect(deletedKeys).toContain(trackedKeysKey);
711
- expect(result).toBe(1);
712
- });
713
- });
714
- });