@flusys/nestjs-iam 6.0.2 → 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 (35) hide show
  1. package/cjs/controllers/action.controller.spec.js +79 -0
  2. package/cjs/controllers/company-action-permission.controller.spec.js +65 -0
  3. package/cjs/controllers/my-permission.controller.spec.js +56 -0
  4. package/cjs/controllers/role-permission.controller.spec.js +140 -0
  5. package/cjs/controllers/role.controller.spec.js +86 -0
  6. package/cjs/controllers/user-action-permission.controller.spec.js +68 -0
  7. package/cjs/docs/iam-swagger.config.spec.js +99 -0
  8. package/cjs/entities/index.spec.js +56 -0
  9. package/cjs/helpers/company-access.helper.spec.js +68 -0
  10. package/cjs/helpers/permission-mode.helper.spec.js +54 -0
  11. package/cjs/modules/iam.module.spec.js +293 -0
  12. package/cjs/services/action.service.spec.js +314 -0
  13. package/cjs/services/iam-config.service.spec.js +110 -0
  14. package/cjs/services/iam-datasource.service.spec.js +117 -0
  15. package/cjs/services/permission-cache.service.spec.js +252 -0
  16. package/cjs/services/permission.service.spec.js +967 -0
  17. package/cjs/services/role.service.spec.js +312 -0
  18. package/fesm/controllers/action.controller.spec.js +75 -0
  19. package/fesm/controllers/company-action-permission.controller.spec.js +61 -0
  20. package/fesm/controllers/my-permission.controller.spec.js +52 -0
  21. package/fesm/controllers/role-permission.controller.spec.js +136 -0
  22. package/fesm/controllers/role.controller.spec.js +82 -0
  23. package/fesm/controllers/user-action-permission.controller.spec.js +64 -0
  24. package/fesm/docs/iam-swagger.config.spec.js +95 -0
  25. package/fesm/entities/index.spec.js +52 -0
  26. package/fesm/helpers/company-access.helper.spec.js +64 -0
  27. package/fesm/helpers/permission-mode.helper.spec.js +50 -0
  28. package/fesm/modules/iam.module.spec.js +289 -0
  29. package/fesm/services/action.service.spec.js +310 -0
  30. package/fesm/services/iam-config.service.spec.js +106 -0
  31. package/fesm/services/iam-datasource.service.spec.js +113 -0
  32. package/fesm/services/permission-cache.service.spec.js +248 -0
  33. package/fesm/services/permission.service.spec.js +963 -0
  34. package/fesm/services/role.service.spec.js +308 -0
  35. package/package.json +3 -3
@@ -0,0 +1,289 @@
1
+ function _define_property(obj, key, value) {
2
+ if (key in obj) {
3
+ Object.defineProperty(obj, key, {
4
+ value: value,
5
+ enumerable: true,
6
+ configurable: true,
7
+ writable: true
8
+ });
9
+ } else {
10
+ obj[key] = value;
11
+ }
12
+ return obj;
13
+ }
14
+ import { PERMISSION_GUARD_CONFIG } from '@flusys/nestjs-shared';
15
+ import { IAMModule } from './iam.module';
16
+ import { IAM_MODULE_OPTIONS } from '../config/iam.constants';
17
+ import { ActionController, CompanyActionPermissionController, MyPermissionController, RoleController, RolePermissionController, UserActionPermissionController } from '../controllers';
18
+ import { ActionService, PermissionService, RoleService } from '../services';
19
+ import { IAMConfigService } from '../services/iam-config.service';
20
+ import { IAMDataSourceService } from '../services/iam-datasource.service';
21
+ import { PermissionCacheService } from '../services/permission-cache.service';
22
+ function providerTokens(providers) {
23
+ return providers.map((p)=>typeof p === 'function' ? p : p.provide);
24
+ }
25
+ describe('IAMModule', ()=>{
26
+ describe('forRoot - controller/service registration matrix', ()=>{
27
+ it('should register no controllers by default (includeController defaults to false)', ()=>{
28
+ const dynamicModule = IAMModule.forRoot();
29
+ expect(dynamicModule.controllers).toEqual([]);
30
+ });
31
+ it('DIRECT mode: registers only UserActionPermissionController beyond the base set', ()=>{
32
+ const dynamicModule = IAMModule.forRoot({
33
+ includeController: true,
34
+ bootstrapAppConfig: {
35
+ permissionMode: 'DIRECT'
36
+ }
37
+ });
38
+ expect(dynamicModule.controllers).toEqual([
39
+ ActionController,
40
+ MyPermissionController,
41
+ UserActionPermissionController
42
+ ]);
43
+ expect(providerTokens(dynamicModule.providers)).not.toContain(RoleService);
44
+ });
45
+ it('RBAC mode: registers RoleController + RolePermissionController but not UserActionPermissionController', ()=>{
46
+ const dynamicModule = IAMModule.forRoot({
47
+ includeController: true,
48
+ bootstrapAppConfig: {
49
+ permissionMode: 'RBAC'
50
+ }
51
+ });
52
+ expect(dynamicModule.controllers).toEqual([
53
+ ActionController,
54
+ MyPermissionController,
55
+ RoleController,
56
+ RolePermissionController
57
+ ]);
58
+ expect(providerTokens(dynamicModule.providers)).toContain(RoleService);
59
+ });
60
+ it('FULL mode: registers every permission controller', ()=>{
61
+ const dynamicModule = IAMModule.forRoot({
62
+ includeController: true,
63
+ bootstrapAppConfig: {
64
+ permissionMode: 'FULL'
65
+ }
66
+ });
67
+ expect(dynamicModule.controllers).toEqual([
68
+ ActionController,
69
+ MyPermissionController,
70
+ RoleController,
71
+ UserActionPermissionController,
72
+ RolePermissionController
73
+ ]);
74
+ });
75
+ it('defaults to FULL mode when permissionMode is omitted', ()=>{
76
+ const dynamicModule = IAMModule.forRoot({
77
+ includeController: true
78
+ });
79
+ expect(dynamicModule.controllers).toEqual(expect.arrayContaining([
80
+ RoleController,
81
+ UserActionPermissionController,
82
+ RolePermissionController
83
+ ]));
84
+ });
85
+ it('defaults to FULL mode when permissionMode is an unrecognized string', ()=>{
86
+ const dynamicModule = IAMModule.forRoot({
87
+ includeController: true,
88
+ bootstrapAppConfig: {
89
+ permissionMode: 'BOGUS'
90
+ }
91
+ });
92
+ expect(dynamicModule.controllers).toEqual(expect.arrayContaining([
93
+ RoleController,
94
+ UserActionPermissionController,
95
+ RolePermissionController
96
+ ]));
97
+ });
98
+ it('adds CompanyActionPermissionController only when enableCompanyFeature is true', ()=>{
99
+ const withCompany = IAMModule.forRoot({
100
+ includeController: true,
101
+ bootstrapAppConfig: {
102
+ enableCompanyFeature: true
103
+ }
104
+ });
105
+ const withoutCompany = IAMModule.forRoot({
106
+ includeController: true,
107
+ bootstrapAppConfig: {
108
+ enableCompanyFeature: false
109
+ }
110
+ });
111
+ expect(withCompany.controllers).toContain(CompanyActionPermissionController);
112
+ expect(withoutCompany.controllers).not.toContain(CompanyActionPermissionController);
113
+ });
114
+ it('excludes RoleService for DIRECT mode but includes it for RBAC/FULL', ()=>{
115
+ const direct = IAMModule.forRoot({
116
+ bootstrapAppConfig: {
117
+ permissionMode: 'DIRECT'
118
+ }
119
+ });
120
+ const rbac = IAMModule.forRoot({
121
+ bootstrapAppConfig: {
122
+ permissionMode: 'RBAC'
123
+ }
124
+ });
125
+ const full = IAMModule.forRoot({
126
+ bootstrapAppConfig: {
127
+ permissionMode: 'FULL'
128
+ }
129
+ });
130
+ expect(providerTokens(direct.providers)).not.toContain(RoleService);
131
+ expect(providerTokens(rbac.providers)).toContain(RoleService);
132
+ expect(providerTokens(full.providers)).toContain(RoleService);
133
+ expect(providerTokens(direct.exports)).not.toContain(RoleService);
134
+ expect(providerTokens(rbac.exports)).toContain(RoleService);
135
+ });
136
+ it('always registers the base providers/exports (ActionService, PermissionService, PermissionCacheService)', ()=>{
137
+ const dynamicModule = IAMModule.forRoot();
138
+ expect(providerTokens(dynamicModule.providers)).toEqual(expect.arrayContaining([
139
+ IAMConfigService,
140
+ IAMDataSourceService,
141
+ ActionService,
142
+ PermissionService,
143
+ PermissionCacheService
144
+ ]));
145
+ expect(dynamicModule.exports).toEqual(expect.arrayContaining([
146
+ IAMConfigService,
147
+ IAMDataSourceService,
148
+ ActionService,
149
+ PermissionService,
150
+ PermissionCacheService,
151
+ PERMISSION_GUARD_CONFIG
152
+ ]));
153
+ });
154
+ });
155
+ describe('forRoot - options/global handling', ()=>{
156
+ it('should not set `global` on the returned module when global is false/omitted', ()=>{
157
+ const dynamicModule = IAMModule.forRoot();
158
+ expect(dynamicModule.global).toBeUndefined();
159
+ });
160
+ it('should set global: true when requested', ()=>{
161
+ const dynamicModule = IAMModule.forRoot({
162
+ global: true
163
+ });
164
+ expect(dynamicModule.global).toBe(true);
165
+ });
166
+ it('should register IAM_MODULE_OPTIONS with the given options', ()=>{
167
+ const options = {
168
+ includeController: true
169
+ };
170
+ const dynamicModule = IAMModule.forRoot(options);
171
+ const provider = dynamicModule.providers.find((p)=>p.provide === IAM_MODULE_OPTIONS);
172
+ expect(provider.useValue).toBe(options);
173
+ });
174
+ it('should build a company-aware PERMISSION_GUARD_CONFIG provider', ()=>{
175
+ const dynamicModule = IAMModule.forRoot({
176
+ bootstrapAppConfig: {
177
+ enableCompanyFeature: true
178
+ }
179
+ });
180
+ const provider = dynamicModule.providers.find((p)=>p.provide === PERMISSION_GUARD_CONFIG);
181
+ expect(provider.useValue.enableCompanyFeature).toBe(true);
182
+ expect(provider.useValue.userPermissionKeyFormat).toContain('{userId}');
183
+ expect(provider.useValue.companyPermissionKeyFormat).toContain('{companyId}');
184
+ });
185
+ });
186
+ describe('forRootAsync', ()=>{
187
+ it('mirrors the controller/service registration matrix of forRoot', ()=>{
188
+ const dynamicModule = IAMModule.forRootAsync({
189
+ includeController: true,
190
+ bootstrapAppConfig: {
191
+ permissionMode: 'DIRECT'
192
+ }
193
+ });
194
+ expect(dynamicModule.controllers).toEqual([
195
+ ActionController,
196
+ MyPermissionController,
197
+ UserActionPermissionController
198
+ ]);
199
+ });
200
+ it('should forward external imports alongside CacheModule/UtilsModule', ()=>{
201
+ let FakeImportedModule = class FakeImportedModule {
202
+ };
203
+ const dynamicModule = IAMModule.forRootAsync({
204
+ imports: [
205
+ FakeImportedModule
206
+ ]
207
+ });
208
+ expect(dynamicModule.imports).toEqual(expect.arrayContaining([
209
+ FakeImportedModule
210
+ ]));
211
+ });
212
+ it('useFactory: registers a single options provider using the given inject tokens', async ()=>{
213
+ const useFactory = jest.fn().mockResolvedValue({
214
+ foo: 'bar'
215
+ });
216
+ const dynamicModule = IAMModule.forRootAsync({
217
+ useFactory,
218
+ inject: [
219
+ 'SOME_TOKEN'
220
+ ]
221
+ });
222
+ const optionsProviders = dynamicModule.providers.filter((p)=>p.provide === IAM_MODULE_OPTIONS);
223
+ expect(optionsProviders).toHaveLength(1);
224
+ const provider = optionsProviders[0];
225
+ expect(provider.useFactory).toBe(useFactory);
226
+ expect(provider.inject).toEqual([
227
+ 'SOME_TOKEN'
228
+ ]);
229
+ });
230
+ it('useClass: registers both the class provider and an options provider that calls createIAMOptions()', async ()=>{
231
+ let MyOptionsFactory = class MyOptionsFactory {
232
+ constructor(){
233
+ _define_property(this, "createIAMOptions", jest.fn().mockResolvedValue({
234
+ foo: 'from-class'
235
+ }));
236
+ }
237
+ };
238
+ const dynamicModule = IAMModule.forRootAsync({
239
+ useClass: MyOptionsFactory
240
+ });
241
+ const classProvider = dynamicModule.providers.find((p)=>p.provide === MyOptionsFactory);
242
+ expect(classProvider.useClass).toBe(MyOptionsFactory);
243
+ const optionsProvider = dynamicModule.providers.find((p)=>p.provide === IAM_MODULE_OPTIONS);
244
+ expect(optionsProvider.inject).toEqual([
245
+ MyOptionsFactory
246
+ ]);
247
+ const instance = new MyOptionsFactory();
248
+ const resolved = await optionsProvider.useFactory(instance);
249
+ expect(instance.createIAMOptions).toHaveBeenCalled();
250
+ expect(resolved).toEqual({
251
+ foo: 'from-class'
252
+ });
253
+ });
254
+ it('useExisting: registers a single options provider injecting the existing factory', async ()=>{
255
+ let MyOptionsFactory = class MyOptionsFactory {
256
+ constructor(){
257
+ _define_property(this, "createIAMOptions", jest.fn().mockResolvedValue({
258
+ foo: 'from-existing'
259
+ }));
260
+ }
261
+ };
262
+ const dynamicModule = IAMModule.forRootAsync({
263
+ useExisting: MyOptionsFactory
264
+ });
265
+ const optionsProviders = dynamicModule.providers.filter((p)=>p.provide === IAM_MODULE_OPTIONS);
266
+ expect(optionsProviders).toHaveLength(1);
267
+ const provider = optionsProviders[0];
268
+ expect(provider.inject).toEqual([
269
+ MyOptionsFactory
270
+ ]);
271
+ const instance = new MyOptionsFactory();
272
+ const resolved = await provider.useFactory(instance);
273
+ expect(resolved).toEqual({
274
+ foo: 'from-existing'
275
+ });
276
+ });
277
+ });
278
+ describe('forFeature', ()=>{
279
+ it('should behave identically to forRoot', ()=>{
280
+ const options = {
281
+ includeController: true,
282
+ bootstrapAppConfig: {
283
+ permissionMode: 'RBAC'
284
+ }
285
+ };
286
+ expect(IAMModule.forFeature(options)).toEqual(IAMModule.forRoot(options));
287
+ });
288
+ });
289
+ });
@@ -0,0 +1,310 @@
1
+ import { Test } from '@nestjs/testing';
2
+ import { UtilsService } from '@flusys/nestjs-shared/modules';
3
+ import { createMockDataSourceProvider, createMockRepository } from '@test-utils/mocks/repository.mock';
4
+ import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
5
+ import { ActionType } from '../enums';
6
+ import { ActionService } from './action.service';
7
+ import { IAMConfigService } from './iam-config.service';
8
+ import { IAMDataSourceService } from './iam-datasource.service';
9
+ import { PermissionCacheService } from './permission-cache.service';
10
+ import { PermissionService } from './permission.service';
11
+ function buildAction(overrides = {}) {
12
+ return {
13
+ id: 'action-uuid-1',
14
+ readOnly: false,
15
+ name: 'View Products',
16
+ description: null,
17
+ code: 'products.read',
18
+ actionType: ActionType.BACKEND,
19
+ permissionLogic: null,
20
+ serial: 1,
21
+ isActive: true,
22
+ parentId: null,
23
+ createdAt: new Date(),
24
+ updatedAt: new Date(),
25
+ deletedAt: null,
26
+ createdById: null,
27
+ updatedById: null,
28
+ deletedById: null,
29
+ ...overrides
30
+ };
31
+ }
32
+ describe('ActionService', ()=>{
33
+ let service;
34
+ let mockRepo;
35
+ let mockIamConfigService;
36
+ let mockPermissionService;
37
+ let mockPermissionCacheService;
38
+ beforeEach(async ()=>{
39
+ mockRepo = createMockRepository();
40
+ const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
41
+ mockIamConfigService = {
42
+ isCompanyFeatureEnabled: jest.fn().mockReturnValue(false)
43
+ };
44
+ mockPermissionService = {
45
+ getCompanyActionIds: jest.fn(),
46
+ invalidateCompanyMembersCache: jest.fn()
47
+ };
48
+ mockPermissionCacheService = {
49
+ invalidateUsers: jest.fn()
50
+ };
51
+ const module = await Test.createTestingModule({
52
+ providers: [
53
+ ActionService,
54
+ {
55
+ provide: 'CACHE_INSTANCE',
56
+ useValue: {}
57
+ },
58
+ {
59
+ provide: UtilsService,
60
+ useValue: {}
61
+ },
62
+ {
63
+ provide: IAMConfigService,
64
+ useValue: mockIamConfigService
65
+ },
66
+ {
67
+ provide: IAMDataSourceService,
68
+ useValue: mockDataSourceProvider
69
+ },
70
+ {
71
+ provide: PermissionService,
72
+ useValue: mockPermissionService
73
+ },
74
+ {
75
+ provide: PermissionCacheService,
76
+ useValue: mockPermissionCacheService
77
+ }
78
+ ]
79
+ }).compile();
80
+ service = await module.resolve(ActionService);
81
+ });
82
+ describe('getActionsForPermission', ()=>{
83
+ it('returns all actions when the company feature is disabled', async ()=>{
84
+ mockRepo.find.mockResolvedValue([
85
+ buildAction()
86
+ ]);
87
+ const result = await service.getActionsForPermission(buildMockUser());
88
+ expect(mockPermissionService.getCompanyActionIds).not.toHaveBeenCalled();
89
+ expect(result).toHaveLength(1);
90
+ expect(result[0].code).toBe('products.read');
91
+ });
92
+ it('scopes actions to the company whitelist when the company feature is enabled', async ()=>{
93
+ mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
94
+ mockPermissionService.getCompanyActionIds.mockResolvedValue([
95
+ 'action-uuid-1'
96
+ ]);
97
+ mockRepo.find.mockResolvedValue([
98
+ buildAction()
99
+ ]);
100
+ await service.getActionsForPermission(buildMockUser(), 'company-uuid-1');
101
+ expect(mockPermissionService.getCompanyActionIds).toHaveBeenCalledWith('company-uuid-1');
102
+ expect(mockRepo.find).toHaveBeenCalledWith(expect.objectContaining({
103
+ where: {
104
+ id: expect.anything()
105
+ }
106
+ }));
107
+ });
108
+ it('short-circuits without querying when the company has no whitelisted actions', async ()=>{
109
+ mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
110
+ mockPermissionService.getCompanyActionIds.mockResolvedValue([]);
111
+ const result = await service.getActionsForPermission(buildMockUser(), 'company-uuid-1');
112
+ expect(result).toEqual([]);
113
+ expect(mockRepo.find).not.toHaveBeenCalled();
114
+ });
115
+ it('throws when no user is provided', async ()=>{
116
+ await expect(service.getActionsForPermission(null)).rejects.toThrow();
117
+ });
118
+ });
119
+ describe('getActionTree', ()=>{
120
+ it('nests child actions under their parent', async ()=>{
121
+ const parent = buildAction({
122
+ id: 'parent-1',
123
+ parentId: null,
124
+ serial: 1
125
+ });
126
+ const child = buildAction({
127
+ id: 'child-1',
128
+ parentId: 'parent-1',
129
+ serial: 2,
130
+ code: 'products.create'
131
+ });
132
+ mockRepo.createQueryBuilder().getMany.mockResolvedValue([
133
+ parent,
134
+ child
135
+ ]);
136
+ const tree = await service.getActionTree(buildMockUser());
137
+ expect(tree).toHaveLength(1);
138
+ expect(tree[0].id).toBe('parent-1');
139
+ expect(tree[0].children).toHaveLength(1);
140
+ expect(tree[0].children?.[0].id).toBe('child-1');
141
+ });
142
+ it('throws when no user is provided', async ()=>{
143
+ await expect(service.getActionTree(null)).rejects.toThrow();
144
+ });
145
+ it('treats an action with a missing/unknown parentId as a root node', async ()=>{
146
+ const orphan = buildAction({
147
+ id: 'orphan-1',
148
+ parentId: 'does-not-exist'
149
+ });
150
+ mockRepo.createQueryBuilder().getMany.mockResolvedValue([
151
+ orphan
152
+ ]);
153
+ const tree = await service.getActionTree(buildMockUser());
154
+ expect(tree).toHaveLength(1);
155
+ expect(tree[0].id).toBe('orphan-1');
156
+ });
157
+ it('excludes soft-deleted actions by default and applies search/isActive filters', async ()=>{
158
+ const query = mockRepo.createQueryBuilder();
159
+ query.getMany.mockResolvedValue([]);
160
+ await service.getActionTree(buildMockUser(), 'view', true, false);
161
+ expect(query.andWhere).toHaveBeenCalledWith('action.deletedAt IS NULL');
162
+ expect(query.andWhere).toHaveBeenCalledWith('action.isActive = :isActive', {
163
+ isActive: true
164
+ });
165
+ expect(query.andWhere).toHaveBeenCalledWith('(action.name LIKE :search OR action.code LIKE :search)', {
166
+ search: '%view%'
167
+ });
168
+ });
169
+ it('includes soft-deleted actions when withDeleted is true', async ()=>{
170
+ const query = mockRepo.createQueryBuilder();
171
+ query.getMany.mockResolvedValue([]);
172
+ await service.getActionTree(buildMockUser(), undefined, undefined, true);
173
+ expect(query.andWhere).not.toHaveBeenCalledWith('action.deletedAt IS NULL');
174
+ });
175
+ });
176
+ describe('getSelectQuery', ()=>{
177
+ it('selects the default field set when none is provided', async ()=>{
178
+ const query = mockRepo.createQueryBuilder();
179
+ await service.getSelectQuery(query, buildMockUser());
180
+ expect(query.select).toHaveBeenCalledWith(expect.arrayContaining([
181
+ 'action.code',
182
+ 'action.actionType',
183
+ 'action.permissionLogic'
184
+ ]));
185
+ });
186
+ it('uses the explicitly provided select list as-is', async ()=>{
187
+ const query = mockRepo.createQueryBuilder();
188
+ await service.getSelectQuery(query, buildMockUser(), [
189
+ 'id',
190
+ 'code'
191
+ ]);
192
+ expect(query.select).toHaveBeenCalledWith([
193
+ 'action.id',
194
+ 'action.code'
195
+ ]);
196
+ });
197
+ });
198
+ describe('getGlobalSearchQuery', ()=>{
199
+ it('filters by name, code, or description', async ()=>{
200
+ const query = mockRepo.createQueryBuilder();
201
+ await service.getGlobalSearchQuery(query, 'view');
202
+ expect(query.andWhere).toHaveBeenCalledWith('(action.name LIKE :search OR action.code LIKE :search OR action.description LIKE :search)', {
203
+ search: '%view%'
204
+ });
205
+ });
206
+ });
207
+ describe('convertEntityToResponseDto', ()=>{
208
+ it('maps every entity field onto the response shape', ()=>{
209
+ const entity = buildAction({
210
+ code: 'user.view'
211
+ });
212
+ const dto = service.convertEntityToResponseDto(entity, false);
213
+ expect(dto).toEqual(expect.objectContaining({
214
+ id: entity.id,
215
+ code: 'user.view',
216
+ actionType: ActionType.BACKEND
217
+ }));
218
+ });
219
+ });
220
+ describe('beforeDeleteOperation', ()=>{
221
+ function buildQueryRunner(permissionRepo) {
222
+ return {
223
+ manager: {
224
+ getRepository: jest.fn().mockReturnValue(permissionRepo)
225
+ }
226
+ };
227
+ }
228
+ it('does nothing when the delete DTO has no ids', async ()=>{
229
+ const permissionRepo = createMockRepository();
230
+ const queryRunner = buildQueryRunner(permissionRepo);
231
+ await service.beforeDeleteOperation({
232
+ id: [],
233
+ type: 'delete'
234
+ }, buildMockUser(), queryRunner);
235
+ expect(permissionRepo.find).not.toHaveBeenCalled();
236
+ });
237
+ it('cascades role/user/company action-permission cleanup and invalidates affected caches', async ()=>{
238
+ mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(false);
239
+ const permissionRepo = createMockRepository();
240
+ // Company feature is disabled, so companyActionRows resolves via Promise.resolve([]) and
241
+ // never calls permissionRepo.find() — only 3 real find() calls happen: role-action,
242
+ // user-action, then the role -> user cascade lookup.
243
+ permissionRepo.find.mockResolvedValueOnce([
244
+ {
245
+ sourceId: 'role-1'
246
+ }
247
+ ]) // role-action rows referencing the deleted action
248
+ .mockResolvedValueOnce([
249
+ {
250
+ userId: 'user-1'
251
+ }
252
+ ]) // direct user-action rows
253
+ .mockResolvedValueOnce([
254
+ {
255
+ userId: 'user-2'
256
+ }
257
+ ]); // users holding role-1 (cascaded lookup)
258
+ const queryRunner = buildQueryRunner(permissionRepo);
259
+ await service.beforeDeleteOperation({
260
+ id: 'action-uuid-1',
261
+ type: 'delete'
262
+ }, buildMockUser(), queryRunner);
263
+ expect(permissionRepo.delete).toHaveBeenCalledWith(expect.objectContaining({
264
+ permissionType: 'role_action'
265
+ }));
266
+ expect(permissionRepo.delete).toHaveBeenCalledWith(expect.objectContaining({
267
+ permissionType: 'user_action'
268
+ }));
269
+ expect(permissionRepo.delete).toHaveBeenCalledWith(expect.objectContaining({
270
+ permissionType: 'company_action'
271
+ }));
272
+ expect(mockPermissionCacheService.invalidateUsers).toHaveBeenCalledWith(expect.arrayContaining([
273
+ 'user-1',
274
+ 'user-2'
275
+ ]));
276
+ expect(mockPermissionService.invalidateCompanyMembersCache).not.toHaveBeenCalled();
277
+ });
278
+ it('also queries and invalidates company whitelist entries when the company feature is enabled', async ()=>{
279
+ mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
280
+ const permissionRepo = createMockRepository();
281
+ permissionRepo.find.mockResolvedValueOnce([]) // role-action rows
282
+ .mockResolvedValueOnce([]) // direct user-action rows
283
+ .mockResolvedValueOnce([
284
+ {
285
+ sourceId: 'company-1'
286
+ }
287
+ ]); // company-action rows
288
+ const queryRunner = buildQueryRunner(permissionRepo);
289
+ await service.beforeDeleteOperation({
290
+ id: [
291
+ 'action-uuid-1'
292
+ ],
293
+ type: 'delete'
294
+ }, buildMockUser(), queryRunner);
295
+ expect(mockPermissionService.invalidateCompanyMembersCache).toHaveBeenCalledWith('company-1');
296
+ });
297
+ it('does not invalidate any cache when nothing is affected', async ()=>{
298
+ const permissionRepo = createMockRepository();
299
+ permissionRepo.find.mockResolvedValue([]);
300
+ const queryRunner = buildQueryRunner(permissionRepo);
301
+ await service.beforeDeleteOperation({
302
+ id: [
303
+ 'action-uuid-1'
304
+ ],
305
+ type: 'delete'
306
+ }, buildMockUser(), queryRunner);
307
+ expect(mockPermissionCacheService.invalidateUsers).not.toHaveBeenCalled();
308
+ });
309
+ });
310
+ });