@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.
- package/cjs/controllers/action.controller.spec.js +79 -0
- 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.spec.js +68 -0
- package/cjs/docs/iam-swagger.config.spec.js +99 -0
- package/cjs/entities/index.spec.js +56 -0
- 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.spec.js +293 -0
- 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.spec.js +252 -0
- package/cjs/services/permission.service.spec.js +967 -0
- package/cjs/services/role.service.spec.js +312 -0
- package/fesm/controllers/action.controller.spec.js +75 -0
- 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.spec.js +64 -0
- package/fesm/docs/iam-swagger.config.spec.js +95 -0
- package/fesm/entities/index.spec.js +52 -0
- 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.spec.js +289 -0
- 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.spec.js +248 -0
- package/fesm/services/permission.service.spec.js +963 -0
- package/fesm/services/role.service.spec.js +308 -0
- package/package.json +3 -3
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { IAMPermissionMode } from '../enums/permission-type.enum';
|
|
2
|
+
import { IAMConfigService } from './iam-config.service';
|
|
3
|
+
describe('IAMConfigService', ()=>{
|
|
4
|
+
describe('with no injected options (defaults)', ()=>{
|
|
5
|
+
let service;
|
|
6
|
+
beforeEach(()=>{
|
|
7
|
+
service = new IAMConfigService();
|
|
8
|
+
});
|
|
9
|
+
it('defaults to single database mode', ()=>{
|
|
10
|
+
expect(service.getDatabaseMode()).toBe('single');
|
|
11
|
+
expect(service.isMultiTenant()).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it('defaults company feature to disabled', ()=>{
|
|
14
|
+
expect(service.isCompanyFeatureEnabled()).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
it('defaults permission mode to FULL', ()=>{
|
|
17
|
+
expect(service.getPermissionMode()).toBe(IAMPermissionMode.FULL);
|
|
18
|
+
expect(service.isRbacEnabled()).toBe(true);
|
|
19
|
+
expect(service.isDirectPermissionEnabled()).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it('returns the fallback options object from getOptions', ()=>{
|
|
22
|
+
expect(service.getOptions()).toEqual({
|
|
23
|
+
global: false,
|
|
24
|
+
includeController: false
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('with injected options', ()=>{
|
|
29
|
+
function buildService(overrides) {
|
|
30
|
+
const options = {
|
|
31
|
+
global: false,
|
|
32
|
+
includeController: false,
|
|
33
|
+
bootstrapAppConfig: {
|
|
34
|
+
databaseMode: 'single',
|
|
35
|
+
enableCompanyFeature: false,
|
|
36
|
+
...overrides
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return new IAMConfigService(options);
|
|
40
|
+
}
|
|
41
|
+
it('reports multi-tenant mode when configured', ()=>{
|
|
42
|
+
const service = buildService({
|
|
43
|
+
databaseMode: 'multi-tenant'
|
|
44
|
+
});
|
|
45
|
+
expect(service.getDatabaseMode()).toBe('multi-tenant');
|
|
46
|
+
expect(service.isMultiTenant()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it('reports company feature enabled when configured', ()=>{
|
|
49
|
+
const service = buildService({
|
|
50
|
+
enableCompanyFeature: true
|
|
51
|
+
});
|
|
52
|
+
expect(service.isCompanyFeatureEnabled()).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
describe('permission mode: RBAC', ()=>{
|
|
55
|
+
let service;
|
|
56
|
+
beforeEach(()=>{
|
|
57
|
+
service = buildService({
|
|
58
|
+
permissionMode: 'RBAC'
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
it('enables RBAC but not direct permissions', ()=>{
|
|
62
|
+
expect(service.getPermissionMode()).toBe(IAMPermissionMode.RBAC);
|
|
63
|
+
expect(service.isRbacEnabled()).toBe(true);
|
|
64
|
+
expect(service.isDirectPermissionEnabled()).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('permission mode: DIRECT', ()=>{
|
|
68
|
+
let service;
|
|
69
|
+
beforeEach(()=>{
|
|
70
|
+
service = buildService({
|
|
71
|
+
permissionMode: 'DIRECT'
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
it('enables direct permissions but not RBAC', ()=>{
|
|
75
|
+
expect(service.getPermissionMode()).toBe(IAMPermissionMode.DIRECT);
|
|
76
|
+
expect(service.isRbacEnabled()).toBe(false);
|
|
77
|
+
expect(service.isDirectPermissionEnabled()).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('permission mode: FULL', ()=>{
|
|
81
|
+
let service;
|
|
82
|
+
beforeEach(()=>{
|
|
83
|
+
service = buildService({
|
|
84
|
+
permissionMode: 'FULL'
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
it('enables both RBAC and direct permissions', ()=>{
|
|
88
|
+
expect(service.getPermissionMode()).toBe(IAMPermissionMode.FULL);
|
|
89
|
+
expect(service.isRbacEnabled()).toBe(true);
|
|
90
|
+
expect(service.isDirectPermissionEnabled()).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
it('returns the exact injected options object from getOptions', ()=>{
|
|
94
|
+
const options = {
|
|
95
|
+
global: true,
|
|
96
|
+
includeController: true,
|
|
97
|
+
bootstrapAppConfig: {
|
|
98
|
+
databaseMode: 'single',
|
|
99
|
+
enableCompanyFeature: true
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const service = new IAMConfigService(options);
|
|
103
|
+
expect(service.getOptions()).toBe(options);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { MultiTenantDataSourceService } from '@flusys/nestjs-shared/modules';
|
|
2
|
+
import { Action } from '../entities/action.entity';
|
|
3
|
+
import { UserIamPermission } from '../entities/user-iam-permission.entity';
|
|
4
|
+
import { UserIamPermissionWithCompany } from '../entities/permission-with-company.entity';
|
|
5
|
+
import { Role } from '../entities/role.entity';
|
|
6
|
+
import { RoleWithCompany } from '../entities/role-with-company.entity';
|
|
7
|
+
import { IAMDataSourceService } from './iam-datasource.service';
|
|
8
|
+
describe('IAMDataSourceService', ()=>{
|
|
9
|
+
let mockConfigService;
|
|
10
|
+
beforeEach(()=>{
|
|
11
|
+
mockConfigService = {
|
|
12
|
+
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false),
|
|
13
|
+
getPermissionMode: jest.fn(),
|
|
14
|
+
getOptions: jest.fn().mockReturnValue({
|
|
15
|
+
global: false,
|
|
16
|
+
includeController: false
|
|
17
|
+
})
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
function buildService(request) {
|
|
21
|
+
return new IAMDataSourceService(mockConfigService, request);
|
|
22
|
+
}
|
|
23
|
+
describe('getEnableCompanyFeatureForTenant', ()=>{
|
|
24
|
+
it('uses the tenant override when defined (true)', ()=>{
|
|
25
|
+
const service = buildService();
|
|
26
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(false);
|
|
27
|
+
expect(service.getEnableCompanyFeatureForTenant({
|
|
28
|
+
enableCompanyFeature: true
|
|
29
|
+
})).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it('uses the tenant override when defined (false)', ()=>{
|
|
32
|
+
const service = buildService();
|
|
33
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
34
|
+
expect(service.getEnableCompanyFeatureForTenant({
|
|
35
|
+
enableCompanyFeature: false
|
|
36
|
+
})).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it('falls back to the config service when the tenant has no override', ()=>{
|
|
39
|
+
const service = buildService();
|
|
40
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
41
|
+
expect(service.getEnableCompanyFeatureForTenant({})).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
it('falls back to the config service when no tenant is provided', ()=>{
|
|
44
|
+
const service = buildService();
|
|
45
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
46
|
+
expect(service.getEnableCompanyFeatureForTenant(undefined)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('getEnableCompanyFeatureForCurrentTenant', ()=>{
|
|
50
|
+
it('falls back to the config service when there is no current request/tenant', ()=>{
|
|
51
|
+
const service = buildService();
|
|
52
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
53
|
+
expect(service.getEnableCompanyFeatureForCurrentTenant()).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('getIAMEntities', ()=>{
|
|
57
|
+
it('returns Action + non-company permission + role entities for RBAC mode', async ()=>{
|
|
58
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(false);
|
|
59
|
+
mockConfigService.getPermissionMode.mockReturnValue(1); // RBAC
|
|
60
|
+
const service = buildService();
|
|
61
|
+
const entities = await service.getIAMEntities();
|
|
62
|
+
expect(entities).toContain(Action);
|
|
63
|
+
expect(entities).toContain(UserIamPermission);
|
|
64
|
+
expect(entities).toContain(Role);
|
|
65
|
+
expect(entities).not.toContain(UserIamPermissionWithCompany);
|
|
66
|
+
expect(entities).not.toContain(RoleWithCompany);
|
|
67
|
+
});
|
|
68
|
+
it('returns company-scoped entities when company feature is enabled', async ()=>{
|
|
69
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
70
|
+
mockConfigService.getPermissionMode.mockReturnValue(3); // FULL
|
|
71
|
+
const service = buildService();
|
|
72
|
+
const entities = await service.getIAMEntities();
|
|
73
|
+
expect(entities).toContain(Action);
|
|
74
|
+
expect(entities).toContain(UserIamPermissionWithCompany);
|
|
75
|
+
expect(entities).toContain(RoleWithCompany);
|
|
76
|
+
expect(entities).not.toContain(UserIamPermission);
|
|
77
|
+
expect(entities).not.toContain(Role);
|
|
78
|
+
});
|
|
79
|
+
it('excludes role entities entirely in DIRECT mode', async ()=>{
|
|
80
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(false);
|
|
81
|
+
mockConfigService.getPermissionMode.mockReturnValue(2); // DIRECT
|
|
82
|
+
const service = buildService();
|
|
83
|
+
const entities = await service.getIAMEntities();
|
|
84
|
+
expect(entities).toEqual([
|
|
85
|
+
Action,
|
|
86
|
+
UserIamPermission
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('createDataSourceFromConfig', ()=>{
|
|
91
|
+
it('resolves IAM entities and delegates to the parent with them', async ()=>{
|
|
92
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(false);
|
|
93
|
+
mockConfigService.getPermissionMode.mockReturnValue(1); // RBAC
|
|
94
|
+
const service = buildService();
|
|
95
|
+
const fakeDataSource = {
|
|
96
|
+
isInitialized: true
|
|
97
|
+
};
|
|
98
|
+
const parentSpy = jest.spyOn(MultiTenantDataSourceService.prototype, 'createDataSourceFromConfig').mockResolvedValue(fakeDataSource);
|
|
99
|
+
const config = {
|
|
100
|
+
type: 'mysql',
|
|
101
|
+
host: 'localhost'
|
|
102
|
+
};
|
|
103
|
+
const result = await service.createDataSourceFromConfig(config);
|
|
104
|
+
expect(result).toBe(fakeDataSource);
|
|
105
|
+
expect(parentSpy).toHaveBeenCalledWith(config, [
|
|
106
|
+
Action,
|
|
107
|
+
UserIamPermission,
|
|
108
|
+
Role
|
|
109
|
+
]);
|
|
110
|
+
parentSpy.mockRestore();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { buildPermissionCacheKey } from '@flusys/nestjs-shared';
|
|
2
|
+
import { PermissionCacheService } from './permission-cache.service';
|
|
3
|
+
function buildCacheData(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
frontendActions: [],
|
|
6
|
+
backendCodes: [],
|
|
7
|
+
...overrides
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
describe('PermissionCacheService', ()=>{
|
|
11
|
+
let mockCacheManager;
|
|
12
|
+
beforeEach(()=>{
|
|
13
|
+
mockCacheManager = {
|
|
14
|
+
get: jest.fn(),
|
|
15
|
+
set: jest.fn(),
|
|
16
|
+
del: jest.fn()
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
function buildService(guardConfig) {
|
|
20
|
+
return new PermissionCacheService(mockCacheManager, guardConfig);
|
|
21
|
+
}
|
|
22
|
+
describe('setMyPermissions / getMyPermissions', ()=>{
|
|
23
|
+
it('stores data under the user-scoped key and tracks it', async ()=>{
|
|
24
|
+
const service = buildService();
|
|
25
|
+
mockCacheManager.get.mockResolvedValue(undefined);
|
|
26
|
+
const data = buildCacheData({
|
|
27
|
+
backendCodes: [
|
|
28
|
+
'user.read'
|
|
29
|
+
]
|
|
30
|
+
});
|
|
31
|
+
await service.setMyPermissions({
|
|
32
|
+
userId: 'user-1'
|
|
33
|
+
}, data);
|
|
34
|
+
const expectedKey = buildPermissionCacheKey({
|
|
35
|
+
userId: 'user-1'
|
|
36
|
+
});
|
|
37
|
+
expect(mockCacheManager.set).toHaveBeenCalledWith(expectedKey, data, expect.any(Number));
|
|
38
|
+
// tracked keys + tracked scopes get written too
|
|
39
|
+
expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys:user:user-1'), [
|
|
40
|
+
expectedKey
|
|
41
|
+
], expect.any(Number));
|
|
42
|
+
expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'), [
|
|
43
|
+
'none'
|
|
44
|
+
], expect.any(Number));
|
|
45
|
+
});
|
|
46
|
+
it('does not duplicate an already-tracked key', async ()=>{
|
|
47
|
+
const service = buildService();
|
|
48
|
+
const expectedKey = buildPermissionCacheKey({
|
|
49
|
+
userId: 'user-1'
|
|
50
|
+
});
|
|
51
|
+
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
52
|
+
if (key.includes('permission-tracked-keys')) return [
|
|
53
|
+
expectedKey
|
|
54
|
+
];
|
|
55
|
+
if (key.includes('permission-tracked-scopes')) return [
|
|
56
|
+
'none'
|
|
57
|
+
];
|
|
58
|
+
return undefined;
|
|
59
|
+
});
|
|
60
|
+
await service.setMyPermissions({
|
|
61
|
+
userId: 'user-1'
|
|
62
|
+
}, buildCacheData());
|
|
63
|
+
const trackedKeysCalls = mockCacheManager.set.mock.calls.filter(([key])=>String(key).includes('permission-tracked-keys'));
|
|
64
|
+
expect(trackedKeysCalls).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
it('returns cached data on a hit', async ()=>{
|
|
67
|
+
const service = buildService();
|
|
68
|
+
const data = buildCacheData({
|
|
69
|
+
backendCodes: [
|
|
70
|
+
'a.b'
|
|
71
|
+
]
|
|
72
|
+
});
|
|
73
|
+
mockCacheManager.get.mockResolvedValue(data);
|
|
74
|
+
const result = await service.getMyPermissions({
|
|
75
|
+
userId: 'user-1'
|
|
76
|
+
});
|
|
77
|
+
expect(result).toBe(data);
|
|
78
|
+
});
|
|
79
|
+
it('returns null on a cache miss', async ()=>{
|
|
80
|
+
const service = buildService();
|
|
81
|
+
mockCacheManager.get.mockResolvedValue(undefined);
|
|
82
|
+
const result = await service.getMyPermissions({
|
|
83
|
+
userId: 'user-1'
|
|
84
|
+
});
|
|
85
|
+
expect(result).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
it('returns null (not throw) when the cache backend errors', async ()=>{
|
|
88
|
+
const service = buildService();
|
|
89
|
+
mockCacheManager.get.mockRejectedValue(new Error('cache down'));
|
|
90
|
+
const result = await service.getMyPermissions({
|
|
91
|
+
userId: 'user-1'
|
|
92
|
+
});
|
|
93
|
+
expect(result).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
it('uses the company-scoped key when guard config enables company scope', async ()=>{
|
|
96
|
+
const guardConfig = {
|
|
97
|
+
enableCompanyFeature: true
|
|
98
|
+
};
|
|
99
|
+
const service = buildService(guardConfig);
|
|
100
|
+
mockCacheManager.get.mockResolvedValue(undefined);
|
|
101
|
+
await service.setMyPermissions({
|
|
102
|
+
userId: 'user-1',
|
|
103
|
+
companyId: 'company-1'
|
|
104
|
+
}, buildCacheData());
|
|
105
|
+
const expectedKey = buildPermissionCacheKey({
|
|
106
|
+
userId: 'user-1',
|
|
107
|
+
companyId: 'company-1'
|
|
108
|
+
}, guardConfig);
|
|
109
|
+
expect(mockCacheManager.set).toHaveBeenCalledWith(expectedKey, expect.anything(), expect.any(Number));
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('invalidateUser', ()=>{
|
|
113
|
+
it('invalidates only the given company scope when companyId is provided', async ()=>{
|
|
114
|
+
const service = buildService();
|
|
115
|
+
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
116
|
+
if (key.includes('permission-tracked-keys')) return [
|
|
117
|
+
'tracked-key-1',
|
|
118
|
+
'tracked-key-2'
|
|
119
|
+
];
|
|
120
|
+
return undefined;
|
|
121
|
+
});
|
|
122
|
+
await service.invalidateUser('user-1', 'company-1');
|
|
123
|
+
expect(mockCacheManager.del).toHaveBeenCalledWith('tracked-key-1');
|
|
124
|
+
expect(mockCacheManager.del).toHaveBeenCalledWith('tracked-key-2');
|
|
125
|
+
expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys:user:user-1:company:company-1'));
|
|
126
|
+
// no company scope given -> tracked-scopes key should not be touched in this branch
|
|
127
|
+
expect(mockCacheManager.del).not.toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes'));
|
|
128
|
+
});
|
|
129
|
+
it('invalidates only the requested branch keys, keeping remaining branches tracked', async ()=>{
|
|
130
|
+
// company-scoped key format only differentiates by branchId when the guard config
|
|
131
|
+
// enables the company feature — otherwise every branch maps to the same cache key.
|
|
132
|
+
const guardConfig = {
|
|
133
|
+
enableCompanyFeature: true
|
|
134
|
+
};
|
|
135
|
+
const service = buildService(guardConfig);
|
|
136
|
+
const keptKey = buildPermissionCacheKey({
|
|
137
|
+
userId: 'user-1',
|
|
138
|
+
companyId: 'company-1',
|
|
139
|
+
branchId: 'branch-keep'
|
|
140
|
+
}, guardConfig);
|
|
141
|
+
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
142
|
+
if (key.includes('permission-tracked-keys')) return [
|
|
143
|
+
keptKey,
|
|
144
|
+
'some-other-tracked-key'
|
|
145
|
+
];
|
|
146
|
+
return undefined;
|
|
147
|
+
});
|
|
148
|
+
// companyId given directly invokes invalidateScope (bypasses the tracked-scopes fan-out)
|
|
149
|
+
await service.invalidateUser('user-1', 'company-1', [
|
|
150
|
+
'branch-a'
|
|
151
|
+
]);
|
|
152
|
+
// the specific branch key gets deleted
|
|
153
|
+
const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
|
|
154
|
+
expect(deletedKeys).toContain(buildPermissionCacheKey({
|
|
155
|
+
userId: 'user-1',
|
|
156
|
+
companyId: 'company-1',
|
|
157
|
+
branchId: 'branch-a'
|
|
158
|
+
}, guardConfig));
|
|
159
|
+
// remaining tracked keys get re-saved rather than the tracked-keys index being wiped
|
|
160
|
+
expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys'), expect.arrayContaining([
|
|
161
|
+
keptKey,
|
|
162
|
+
'some-other-tracked-key'
|
|
163
|
+
]), expect.any(Number));
|
|
164
|
+
});
|
|
165
|
+
it('deletes the tracked-keys index entirely when no remaining keys are left after branch filtering', async ()=>{
|
|
166
|
+
const guardConfig = {
|
|
167
|
+
enableCompanyFeature: true
|
|
168
|
+
};
|
|
169
|
+
const service = buildService(guardConfig);
|
|
170
|
+
const onlyKey = buildPermissionCacheKey({
|
|
171
|
+
userId: 'user-1',
|
|
172
|
+
companyId: 'company-1',
|
|
173
|
+
branchId: 'branch-a'
|
|
174
|
+
}, guardConfig);
|
|
175
|
+
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
176
|
+
if (key.includes('permission-tracked-keys')) return [
|
|
177
|
+
onlyKey
|
|
178
|
+
];
|
|
179
|
+
return undefined;
|
|
180
|
+
});
|
|
181
|
+
await service.invalidateUser('user-1', 'company-1', [
|
|
182
|
+
'branch-a'
|
|
183
|
+
]);
|
|
184
|
+
const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
|
|
185
|
+
expect(deletedKeys).toContain(onlyKey);
|
|
186
|
+
expect(deletedKeys.some((k)=>String(k).includes('permission-tracked-keys'))).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
it('without a companyId, invalidates every tracked scope for the user and clears the scopes index', async ()=>{
|
|
189
|
+
const service = buildService();
|
|
190
|
+
mockCacheManager.get.mockImplementation(async (key)=>{
|
|
191
|
+
if (key.includes('permission-tracked-scopes')) return [
|
|
192
|
+
'none',
|
|
193
|
+
'company-a'
|
|
194
|
+
];
|
|
195
|
+
if (key.includes('company:none')) return [
|
|
196
|
+
'key-none-1'
|
|
197
|
+
];
|
|
198
|
+
if (key.includes('company:company-a')) return [
|
|
199
|
+
'key-company-a-1'
|
|
200
|
+
];
|
|
201
|
+
return undefined;
|
|
202
|
+
});
|
|
203
|
+
await service.invalidateUser('user-1');
|
|
204
|
+
expect(mockCacheManager.del).toHaveBeenCalledWith('key-none-1');
|
|
205
|
+
expect(mockCacheManager.del).toHaveBeenCalledWith('key-company-a-1');
|
|
206
|
+
expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'));
|
|
207
|
+
});
|
|
208
|
+
it('treats a cache read error for tracked scopes as an empty list (no throw)', async ()=>{
|
|
209
|
+
const service = buildService();
|
|
210
|
+
mockCacheManager.get.mockRejectedValue(new Error('down'));
|
|
211
|
+
await expect(service.invalidateUser('user-1')).resolves.toBeUndefined();
|
|
212
|
+
expect(mockCacheManager.del).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes:user:user-1'));
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe('invalidateUsers', ()=>{
|
|
216
|
+
it('returns 0 immediately for an empty user list', async ()=>{
|
|
217
|
+
const service = buildService();
|
|
218
|
+
const result = await service.invalidateUsers([]);
|
|
219
|
+
expect(result).toBe(0);
|
|
220
|
+
expect(mockCacheManager.get).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
it('invalidates every user and returns the count of successful invalidations', async ()=>{
|
|
223
|
+
const service = buildService();
|
|
224
|
+
mockCacheManager.get.mockResolvedValue(undefined);
|
|
225
|
+
const result = await service.invalidateUsers([
|
|
226
|
+
'user-1',
|
|
227
|
+
'user-2',
|
|
228
|
+
'user-3'
|
|
229
|
+
], 'company-1');
|
|
230
|
+
expect(result).toBe(3);
|
|
231
|
+
});
|
|
232
|
+
it('does not let one user failing prevent the others from being counted', async ()=>{
|
|
233
|
+
const service = buildService();
|
|
234
|
+
let callCount = 0;
|
|
235
|
+
mockCacheManager.get.mockImplementation(async ()=>{
|
|
236
|
+
callCount += 1;
|
|
237
|
+
if (callCount === 1) throw new Error('boom');
|
|
238
|
+
return undefined;
|
|
239
|
+
});
|
|
240
|
+
const result = await service.invalidateUsers([
|
|
241
|
+
'user-1',
|
|
242
|
+
'user-2'
|
|
243
|
+
], 'company-1');
|
|
244
|
+
// invalidateUser swallows its own read errors, so both still resolve successfully
|
|
245
|
+
expect(result).toBe(2);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|