@flusys/nestjs-iam 6.0.2 → 6.1.1
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.
- package/cjs/controllers/action.controller.spec.js +79 -0
- package/cjs/controllers/company-action-permission.controller.js +1 -1
- package/cjs/controllers/company-action-permission.controller.spec.js +65 -0
- package/cjs/controllers/my-permission.controller.spec.js +56 -0
- package/cjs/controllers/role-permission.controller.spec.js +140 -0
- package/cjs/controllers/role.controller.spec.js +86 -0
- package/cjs/controllers/user-action-permission.controller.js +1 -1
- package/cjs/controllers/user-action-permission.controller.spec.js +68 -0
- package/cjs/docs/iam-swagger.config.spec.js +99 -0
- package/cjs/dtos/permission.dto.js +7 -0
- package/cjs/entities/index.spec.js +56 -0
- package/cjs/entities/permission-base.entity.js +1 -10
- package/cjs/entities/permission-with-company.entity.js +0 -3
- package/cjs/entities/user-iam-permission.entity.js +0 -3
- package/cjs/helpers/company-access.helper.spec.js +68 -0
- package/cjs/helpers/permission-mode.helper.spec.js +54 -0
- package/cjs/modules/iam.module.js +2 -2
- package/cjs/modules/iam.module.spec.js +293 -0
- package/cjs/services/action.service.js +4 -4
- package/cjs/services/action.service.spec.js +314 -0
- package/cjs/services/iam-config.service.spec.js +110 -0
- package/cjs/services/iam-datasource.service.spec.js +117 -0
- package/cjs/services/permission-cache.service.js +363 -52
- package/cjs/services/permission-cache.service.spec.js +699 -0
- package/cjs/services/permission.service.js +92 -396
- package/cjs/services/permission.service.spec.js +692 -0
- package/cjs/services/role.service.js +1 -1
- package/cjs/services/role.service.spec.js +309 -0
- package/dtos/permission.dto.d.ts +1 -0
- package/entities/permission-base.entity.d.ts +0 -1
- package/fesm/controllers/action.controller.spec.js +75 -0
- package/fesm/controllers/company-action-permission.controller.js +1 -1
- package/fesm/controllers/company-action-permission.controller.spec.js +61 -0
- package/fesm/controllers/my-permission.controller.spec.js +52 -0
- package/fesm/controllers/role-permission.controller.spec.js +136 -0
- package/fesm/controllers/role.controller.spec.js +82 -0
- package/fesm/controllers/user-action-permission.controller.js +1 -1
- package/fesm/controllers/user-action-permission.controller.spec.js +64 -0
- package/fesm/docs/iam-swagger.config.spec.js +95 -0
- package/fesm/dtos/permission.dto.js +7 -0
- package/fesm/entities/index.spec.js +52 -0
- package/fesm/entities/permission-base.entity.js +1 -10
- package/fesm/entities/permission-with-company.entity.js +0 -3
- package/fesm/entities/user-iam-permission.entity.js +0 -3
- package/fesm/helpers/company-access.helper.spec.js +64 -0
- package/fesm/helpers/permission-mode.helper.spec.js +50 -0
- package/fesm/modules/iam.module.js +4 -4
- package/fesm/modules/iam.module.spec.js +289 -0
- package/fesm/services/action.service.js +4 -4
- package/fesm/services/action.service.spec.js +310 -0
- package/fesm/services/iam-config.service.spec.js +106 -0
- package/fesm/services/iam-datasource.service.spec.js +113 -0
- package/fesm/services/permission-cache.service.js +365 -51
- package/fesm/services/permission-cache.service.spec.js +695 -0
- package/fesm/services/permission.service.js +92 -396
- package/fesm/services/permission.service.spec.js +688 -0
- package/fesm/services/role.service.js +1 -1
- package/fesm/services/role.service.spec.js +305 -0
- package/package.json +3 -3
- package/services/permission-cache.service.d.ts +20 -3
- package/services/permission.service.d.ts +2 -16
|
@@ -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
|
+
});
|
|
@@ -75,7 +75,7 @@ export class ActionService extends ApiService {
|
|
|
75
75
|
}
|
|
76
76
|
}) : Promise.resolve([])
|
|
77
77
|
]);
|
|
78
|
-
const affectedUserIds = new Set(userActionRows.map((row)=>row.
|
|
78
|
+
const affectedUserIds = new Set(userActionRows.map((row)=>row.sourceId));
|
|
79
79
|
const affectedCompanyIds = [
|
|
80
80
|
...new Set(companyActionRows.map((row)=>row.sourceId).filter((id)=>!!id))
|
|
81
81
|
];
|
|
@@ -90,7 +90,7 @@ export class ActionService extends ApiService {
|
|
|
90
90
|
targetId: In(affectedRoleIds)
|
|
91
91
|
}
|
|
92
92
|
});
|
|
93
|
-
userRoleRows.forEach((row)=>
|
|
93
|
+
userRoleRows.forEach((row)=>affectedUserIds.add(row.sourceId));
|
|
94
94
|
}
|
|
95
95
|
await permissionRepo.delete({
|
|
96
96
|
permissionType: IamPermissionType.ROLE_ACTION,
|
|
@@ -112,7 +112,7 @@ export class ActionService extends ApiService {
|
|
|
112
112
|
affectedUserIds.size > 0 ? this.permissionCacheService.invalidateUsers([
|
|
113
113
|
...affectedUserIds
|
|
114
114
|
]) : Promise.resolve(),
|
|
115
|
-
...affectedCompanyIds.map((companyId)=>this.
|
|
115
|
+
...affectedCompanyIds.map((companyId)=>this.permissionCacheService.invalidateCompanyMembersCache(companyId))
|
|
116
116
|
]);
|
|
117
117
|
}
|
|
118
118
|
// Query Customization
|
|
@@ -184,7 +184,7 @@ export class ActionService extends ApiService {
|
|
|
184
184
|
let whereClause = {};
|
|
185
185
|
const targetCompanyId = companyId || user.companyId;
|
|
186
186
|
if (this.iamConfigService.isCompanyFeatureEnabled() && targetCompanyId) {
|
|
187
|
-
const companyActionIds = await this.permissionService.
|
|
187
|
+
const companyActionIds = await this.permissionService.getCompanyActions(targetCompanyId, true);
|
|
188
188
|
if (companyActionIds.length === 0) {
|
|
189
189
|
return [];
|
|
190
190
|
}
|
|
@@ -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
|
+
getCompanyActions: jest.fn()
|
|
46
|
+
};
|
|
47
|
+
mockPermissionCacheService = {
|
|
48
|
+
invalidateUsers: jest.fn(),
|
|
49
|
+
invalidateCompanyMembersCache: 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.getCompanyActions).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.getCompanyActions.mockResolvedValue([
|
|
95
|
+
'action-uuid-1'
|
|
96
|
+
]);
|
|
97
|
+
mockRepo.find.mockResolvedValue([
|
|
98
|
+
buildAction()
|
|
99
|
+
]);
|
|
100
|
+
await service.getActionsForPermission(buildMockUser(), 'company-uuid-1');
|
|
101
|
+
expect(mockPermissionService.getCompanyActions).toHaveBeenCalledWith('company-uuid-1', true);
|
|
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.getCompanyActions.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
|
+
sourceId: 'user-1'
|
|
251
|
+
}
|
|
252
|
+
]) // direct user-action rows
|
|
253
|
+
.mockResolvedValueOnce([
|
|
254
|
+
{
|
|
255
|
+
sourceId: '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(mockPermissionCacheService.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(mockPermissionCacheService.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
|
+
});
|