diff --git a/src/lib/permissions/models/permission.model.ts b/src/lib/permissions/models/permission.model.ts index 0c4ed8c..d14a282 100644 --- a/src/lib/permissions/models/permission.model.ts +++ b/src/lib/permissions/models/permission.model.ts @@ -2,5 +2,9 @@ import { ValidationFn } from './permissions-router-data.model'; export interface IqserPermission { name: string; - validationFunction?: ValidationFn; + validationFn?: ValidationFn; +} + +export interface IqserPermissionsObject { + [name: string]: IqserPermission; } diff --git a/src/lib/permissions/models/permissions-router-data.model.ts b/src/lib/permissions/models/permissions-router-data.model.ts index a937de6..d194852 100644 --- a/src/lib/permissions/models/permissions-router-data.model.ts +++ b/src/lib/permissions/models/permissions-router-data.model.ts @@ -1,8 +1,7 @@ import { ActivatedRouteSnapshot, NavigationExtras, Route, RouterStateSnapshot } from '@angular/router'; export interface IqserPermissionsRouterData { - only?: string | string[] | OnlyFn; - except?: string | string[] | ExceptFn; + allow: string | string[] | AllowFn; redirectTo?: RedirectTo | RedirectToFn; } @@ -11,22 +10,30 @@ export interface IqserRedirectToNavigationParameters { navigationExtras?: NavigationExtras | NavigationExtrasFn; } -export type OnlyFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => string | string[]; -export type ExceptFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => string | string[]; +export type AllowFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => string | string[]; export type RedirectTo = | string - | { [name: string]: IqserRedirectToNavigationParameters | string | RedirectToFn } - | IqserRedirectToNavigationParameters; + | string[] + | IqserRedirectToNavigationParameters + | { [name: string]: IqserRedirectToNavigationParameters | string | RedirectToFn }; -export type RedirectToFn = ( - rejectedPermissionName?: string, - route?: ActivatedRouteSnapshot | Route, - state?: RouterStateSnapshot, -) => RedirectTo; +export type RedirectToFn = (failedPermission: string, route?: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => RedirectTo; export type NavigationCommandsFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => any[]; export type NavigationExtrasFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => NavigationExtras; -export type ValidationFn = (name?: string, store?: any) => Promise | boolean | string[]; +export type ValidationFn = (name: string, store: Record) => Promise | boolean | string[]; + +export type IqserActivatedRouteSnapshot = ActivatedRouteSnapshot & { + data: { + permissions: IqserPermissionsRouterData; + }; +}; + +export type IqserRoute = Route & { + data: { + permissions: IqserPermissionsRouterData; + }; +}; export const DEFAULT_REDIRECT_KEY = 'default'; diff --git a/src/lib/permissions/models/role.model.ts b/src/lib/permissions/models/role.model.ts index 5a5a84d..291c1e2 100644 --- a/src/lib/permissions/models/role.model.ts +++ b/src/lib/permissions/models/role.model.ts @@ -2,5 +2,5 @@ import { ValidationFn } from './permissions-router-data.model'; export interface IqserRole { name: string; - validationFunction: ValidationFn | string[]; + validationFn: ValidationFn | string[]; } diff --git a/src/lib/permissions/permissions.directive.spec.ts b/src/lib/permissions/permissions.directive.spec.ts new file mode 100644 index 0000000..f729f6a --- /dev/null +++ b/src/lib/permissions/permissions.directive.spec.ts @@ -0,0 +1,863 @@ +import { Component, Type } from '@angular/core'; +import { IqserPermissionsModule } from '.'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { IqserPermissionsService } from './services/permissions.service'; +import { IqserRolesService } from './services/roles.service'; + +const ADMIN = 'ADMIN' as const; +const GUEST = 'GUEST' as const; + +function detectChanges(fixture: ComponentFixture) { + tick(); + fixture.detectChanges(); +} + +let fixture: ComponentFixture; +let permissionsService: IqserPermissionsService; +let rolesService: IqserRolesService; + +function configureTestBed(component: Type) { + TestBed.configureTestingModule({ declarations: [component], imports: [IqserPermissionsModule.forRoot()] }); + fixture = TestBed.createComponent(component); + detectChanges(fixture); + permissionsService = fixture.debugElement.injector.get(IqserPermissionsService); + rolesService = fixture.debugElement.injector.get(IqserRolesService); +} + +describe('Permission directive angular', () => { + @Component({ + template: ` +
123
+
`, + }) + class TestComponent {} + + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Should show the component', fakeAsync(() => { + permissionsService.load([ADMIN, GUEST]); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + })); + + it('Should not show the component', fakeAsync(() => { + permissionsService.load([GUEST]); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + })); + + it('Should show component when permission added', fakeAsync(() => { + permissionsService.load([GUEST]); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add(ADMIN); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('123'); + })); + + it('Should hide component when permission removed', fakeAsync(() => { + permissionsService.load([ADMIN, GUEST]); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('123'); + + permissionsService.remove(ADMIN); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + })); +}); + +describe('Permission directive angular roles', () => { + @Component({ + template: ` +
123
+
`, + }) + class TestComponent {} + + const awesomePermissions = 'AWESOME'; + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Should show the component when key of role is the same', fakeAsync(() => { + rolesService.add('ADMIN', [awesomePermissions]); + permissionsService.add(awesomePermissions); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + })); + + it('should show the component when permissions array is the same ', fakeAsync(() => { + rolesService.add('ADMIN', [awesomePermissions]); + permissionsService.add(awesomePermissions); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + })); + + it('should hide the component when user deletes all roles', fakeAsync(() => { + permissionsService.add(awesomePermissions); + rolesService.add('ADMIN', [awesomePermissions]); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + + rolesService.clear(); + detectChanges(fixture); + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('should hide the component when user deletes one role', fakeAsync(() => { + permissionsService.add(awesomePermissions); + rolesService.add('ADMIN', [awesomePermissions]); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + + rolesService.remove('ADMIN'); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); +}); + +describe('Permission directive angular roles array', () => { + @Component({ + template: ` +
123
+
`, + }) + class TestComponent {} + + const awesomePermission = 'AWESOME'; + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Should show the component when key of role is the same', fakeAsync(() => { + permissionsService.add(awesomePermission); + rolesService.add('ADMIN', [awesomePermission]); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + })); + + it('should show the component when there is permission ', fakeAsync(() => { + permissionsService.add(awesomePermission); + rolesService.add('ADMIN', ['AWESOME']); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + })); + + it('should hide the component when user deletes all roles', fakeAsync(() => { + permissionsService.add(awesomePermission); + rolesService.add('ADMIN', [awesomePermission]); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + + rolesService.clear(); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('should hide the component when user deletes one roles', fakeAsync(() => { + permissionsService.add(awesomePermission); + rolesService.add('ADMIN', [awesomePermission]); + detectChanges(fixture); + + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toBeTruthy(); + expect(content.innerHTML).toEqual('123'); + + rolesService.remove('ADMIN'); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); +}); + +describe('Permission directive angular testing different selectors *iqserPermissions', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent {} + + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Should show the component when key of role is the same', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('AWESOME'); + rolesService.add('ADMIN', ['AWESOME']); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should hide the component when key of role is the same', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + rolesService.add('GG', ['Awsesome']); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); +}); + +describe('Permission directive angular testing different async functions in roles', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent {} + + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Should show the component when promise returns truthy value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + rolesService.add('ADMIN', () => true); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when promise returns truthy value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + rolesService.add('ADMIN', () => false); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('Should show the component when promise returns truthy value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + rolesService.add('ADMIN', () => Promise.resolve(true)); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when promise rejects', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + rolesService.add('ADMIN', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); +}); + +describe('Permission directive angular testing different async functions in roles via array', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent {} + + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Should show the component when promise returns truthy value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + rolesService.add('ADMIN', () => Promise.resolve(true)); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when promise returns false value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + rolesService.add('ADMIN', () => Promise.resolve(false)); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('Should not show the component when promise rejects', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + rolesService.add('ADMIN', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('Should show the component when one of the promises fulfills ', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + rolesService.add('ADMIN', () => Promise.reject()); + rolesService.add('GUEST', () => Promise.resolve(true)); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should show the component when one of the promises fulfills with 0 value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + rolesService.add('ADMIN', () => { + return Promise.reject(); + }); + + rolesService.add('GUEST', () => { + return Promise.resolve(); + }); + + detectChanges(fixture); + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when all promises fails', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + rolesService.add('ADMIN', () => { + return Promise.reject(); + }); + + rolesService.add('GUEST', () => { + return Promise.reject(); + }); + + detectChanges(fixture); + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('Should show the component when one of promises returns true', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + rolesService.add('GUEST', () => { + return true; + }); + + rolesService.add('ADMIN', () => { + return Promise.reject(); + }); + + detectChanges(fixture); + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should show the component when 1 passes second fails', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + rolesService.add('ADMIN', () => { + return Promise.reject(); + }); + permissionsService.add('AWESOME'); + rolesService.add('GUEST', ['AWESOME']); + + detectChanges(fixture); + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should show the component when one rejects but another one fulfils', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + rolesService.add('ADMIN', () => { + return Promise.reject(); + }); + permissionsService.add('AWESOME'); + rolesService.add('GUEST', ['AWESOME']); + + detectChanges(fixture); + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); +}); + +describe('Permission directive angular testing different async functions in permissions via array', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent {} + + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Should show the component when promise returns truthy value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => true); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when promise returns false value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => false); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('Should show the component when promise returns truthy value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.resolve(true)); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when promise rejects', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('Should show the component when one of the promises fulfills ', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.resolve()); + permissionsService.add('GUEST', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should show the component when one of the promises fulfills with 0 value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.resolve()); + permissionsService.add('GUEST', () => Promise.resolve()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when all promises fails', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.reject()); + permissionsService.add('GUEST', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('Should show the component when one of promises returns true', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('GUEST', () => true); + permissionsService.add('ADMIN', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when all promises fails', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.reject()); + permissionsService.add('GUEST', () => Promise.resolve(true)); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should show the component when one rejects but another one fulfils', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.reject()); + permissionsService.add('GUEST', () => true); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should show the component when one rejects but another one fulfils', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => true); + permissionsService.add('GUEST', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should show the component when functions with name and store fulfils', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', (name, store) => { + expect(name).toBeTruthy(); + expect(store[name!].name).toBeTruthy(); + return name === 'ADMIN'; + }); + + permissionsService.add('GUEST', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); +}); + +describe('Permission directive angular testing different async functions in permissions via string', () => { + @Component({ + template: `
+
123
+
`, + }) + class TestComponent {} + + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Should show the component when promise returns truthy value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => true); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when promise returns false value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => false); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('Should show the component when promise returns truthy value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.resolve(true)); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when promise rejects', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('Should show the component when one of the promises fulfills ', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.resolve()); + permissionsService.add('GUEST', () => Promise.resolve(true)); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should show the component when one of the promises fulfills with 0 value', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.resolve()); + permissionsService.add('GUEST', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when all promises fails', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.reject()); + permissionsService.add('GUEST', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toEqual(null); + })); + + it('Should show the component when one of promises returns true', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('GUEST', () => Promise.reject()); + permissionsService.add('ADMIN', () => true); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should not show the component when all promises fails', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => Promise.resolve(true)); + permissionsService.add('GUEST', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should show the component when one rejects but another one fulfils', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + + permissionsService.add('ADMIN', () => true); + permissionsService.add('GUEST', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); + + it('Should show the component when functions with name and store fulfils', fakeAsync(() => { + const content = fixture.debugElement.nativeElement.querySelector('div'); + expect(content).toEqual(null); + permissionsService.add('ADMIN', (name, store) => { + expect(name).toBeTruthy(); + expect(store[name!].name).toBeTruthy(); + return name === 'ADMIN'; + }); + + permissionsService.add('GUEST', () => Promise.reject()); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual('
123
'); + })); +}); + +describe('Permissions directive testing else block', () => { + @Component({ + template: ` +
main
+ + +
elseBlock
+
+ + +
thenBlock
+
+ `, + }) + class TestComponent {} + + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Should fail and show else block', () => { + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual(`elseBlock`); + }); + + it('Should add element remove element and show then block', fakeAsync(() => { + rolesService.add('FAILED_BLOCK', () => true); + detectChanges(fixture); + + const content3 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content3).toBeTruthy(); + expect(content3.innerHTML).toEqual('main'); + + rolesService.remove('FAILED_BLOCK'); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual(`elseBlock`); + })); +}); + +describe('Permissions directive testing then block', () => { + @Component({ + template: ` +
main
+ + +
elseBlock
+
+ + +
thenBlock
+
+ `, + }) + class TestComponent {} + + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Should fail and show then block', fakeAsync(() => { + rolesService.add('THEN_BLOCK', () => true); + detectChanges(fixture); + + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual(`thenBlock`); + })); +}); + +describe('Permissions directive when no permission specified should return true', () => { + @Component({ + template: ` + +
123
+
+ `, + }) + class TestComponent {} + + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Except and only should success and show then block', () => { + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual(`123`); + }); +}); + +describe('Permissions directive when no permission specified as array should return true', () => { + @Component({ + template: ` + +
123
+
+ `, + }) + class TestComponent {} + + beforeEach(fakeAsync(() => configureTestBed(TestComponent))); + + it('Except and only should success and show then block', () => { + const content2 = fixture.debugElement.nativeElement.querySelector('div'); + expect(content2).toBeTruthy(); + expect(content2.innerHTML).toEqual(`123`); + }); +}); diff --git a/src/lib/permissions/permissions.directive.ts b/src/lib/permissions/permissions.directive.ts index 26d6d95..8dcf617 100644 --- a/src/lib/permissions/permissions.directive.ts +++ b/src/lib/permissions/permissions.directive.ts @@ -1,205 +1,147 @@ import { - ChangeDetectorRef, Directive, + EmbeddedViewRef, EventEmitter, Input, - OnChanges, OnDestroy, OnInit, Output, - SimpleChanges, TemplateRef, ViewContainerRef, + ɵstringify as stringify, } from '@angular/core'; -import { merge, Subscription } from 'rxjs'; -import { skip, take } from 'rxjs/operators'; +import { merge, Subject, Subscription, switchMap } from 'rxjs'; +import { tap } from 'rxjs/operators'; -import { isBoolean, notEmptyValue } from './utils'; +import { notEmpty } from './utils'; import { IqserRolesService } from './services/roles.service'; import { IqserPermissionsService } from './services/permissions.service'; +type NgTemplate = TemplateRef; + @Directive({ - selector: '[ngxPermissionsOnly],[ngxPermissionsExcept]', + selector: '[iqserPermissions]', }) -export class NgxPermissionsDirective implements OnInit, OnDestroy, OnChanges { - @Input() ngxPermissionsOnly!: string | string[]; - @Input() ngxPermissionsOnlyThen!: TemplateRef; - @Input() ngxPermissionsOnlyElse!: TemplateRef; +export class IqserPermissionsDirective implements OnDestroy, OnInit { + /** + * Assert the correct type of the expression bound to the `iqserPermissions` input within the template. + * + * The presence of this static field is a signal to the Ivy template type check compiler that + * when the `IqserPermissionsDirective` structural directive renders its template, the type of the expression bound + * to `iqserPermissions` should be narrowed in some way. + * For `iqserPermissions`, the binding expression itself is used to + * narrow its type, which allows the strictNullChecks feature of TypeScript to work with `IqserPermissionsDirective`. + */ + static ngTemplateGuard_iqserPermissions: 'binding'; - @Input() ngxPermissionsExcept!: string | string[]; - @Input() ngxPermissionsExceptElse!: TemplateRef; - @Input() ngxPermissionsExceptThen!: TemplateRef; + @Output() readonly permissionsAuthorized = new EventEmitter(); + @Output() readonly permissionsUnauthorized = new EventEmitter(); - @Input() ngxPermissionsThen!: TemplateRef; - @Input() ngxPermissionsElse!: TemplateRef; + #permissions?: string | string[]; + #thenTemplateRef: TemplateRef; + #elseTemplateRef?: TemplateRef; + #thenViewRef?: EmbeddedViewRef; + #elseViewRef?: EmbeddedViewRef; - @Output() permissionsAuthorized = new EventEmitter(); - @Output() permissionsUnauthorized = new EventEmitter(); - - private initPermissionSubscription?: Subscription; - // skip first run cause merge will fire twice - private firstMergeUnusedRun = 1; - private currentAuthorizedState?: boolean; + readonly #updateView = new Subject(); + readonly #subscription = new Subscription(); constructor( - private permissionsService: IqserPermissionsService, - private rolesService: IqserRolesService, - private viewContainer: ViewContainerRef, - private changeDetector: ChangeDetectorRef, - private templateRef: TemplateRef, - ) {} - - ngOnInit(): void { - this.viewContainer.clear(); - this.initPermissionSubscription = this.validateExceptOnlyPermissions(); + private readonly _permissionsService: IqserPermissionsService, + private readonly _rolesService: IqserRolesService, + private readonly _viewContainer: ViewContainerRef, + templateRef: TemplateRef, + ) { + this.#thenTemplateRef = templateRef; + this.#subscription = this.#updateView.pipe(switchMap(() => this.#waitForRolesAndPermissions())).subscribe(); } - ngOnChanges(changes: SimpleChanges): void { - const onlyChanges = changes['ngxPermissionsOnly']; - const exceptChanges = changes['ngxPermissionsExcept']; - if (onlyChanges || exceptChanges) { - // Due to bug when you pass empty array - if (onlyChanges && onlyChanges.firstChange) { - return; - } - if (exceptChanges && exceptChanges.firstChange) { - return; - } + @Input() + set iqserPermissions(value: string | string[]) { + this.#permissions = value; + this.#updateView.next(); + } - merge(this.permissionsService.permissions$, this.rolesService.roles$) - .pipe(skip(this.firstMergeUnusedRun), take(1)) - .subscribe(() => { - if (notEmptyValue(this.ngxPermissionsExcept)) { - this.validateExceptAndOnlyPermissions(); - return; - } + @Input() + set iqserPermissionsThen(template: NgTemplate) { + assertTemplate('iqserPermissionsThen', template); + this.#thenTemplateRef = template; + this.#thenViewRef = undefined; + this.#updateView.next(); + } - if (notEmptyValue(this.ngxPermissionsOnly)) { - this.validateOnlyPermissions(); - return; - } + @Input() + set iqserPermissionsElse(template: NgTemplate) { + assertTemplate('iqserPermissionsElse', template); + this.#elseTemplateRef = template; + this.#elseViewRef = undefined; + this.#updateView.next(); + } - this.handleAuthorisedPermission(this.getAuthorisedTemplates()); - }); - } + /** + This assures that when the directive has an empty input (such as [iqserPermissions]="") the view is updated + */ + ngOnInit() { + this.#updateView.next(); } ngOnDestroy(): void { - if (this.initPermissionSubscription) { - this.initPermissionSubscription.unsubscribe(); - } + this.#subscription.unsubscribe(); } - private validateExceptOnlyPermissions(): Subscription { - return merge(this.permissionsService.permissions$, this.rolesService.roles$) - .pipe(skip(this.firstMergeUnusedRun)) - .subscribe(() => { - if (notEmptyValue(this.ngxPermissionsExcept)) { - this.validateExceptAndOnlyPermissions(); - return; - } - - if (notEmptyValue(this.ngxPermissionsOnly)) { - this.validateOnlyPermissions(); - return; - } - this.handleAuthorisedPermission(this.getAuthorisedTemplates()); - }); + #waitForRolesAndPermissions() { + return merge(this._permissionsService.permissions$, this._rolesService.roles$).pipe( + tap(() => (notEmpty(this.#permissions) ? this.#validate() : this.#showThenBlock())), + ); } - private validateExceptAndOnlyPermissions(): void { - Promise.all([ - this.permissionsService.hasPermission(this.ngxPermissionsExcept), - this.rolesService.hasOnlyRoles(this.ngxPermissionsExcept), - ]) - .then(([hasPermission, hasRole]) => { - if (hasPermission || hasRole) { - this.handleUnauthorisedPermission(this.ngxPermissionsExceptElse || this.ngxPermissionsElse); - return; - } - - if (!!this.ngxPermissionsOnly) { - throw false; - } - - this.handleAuthorisedPermission(this.ngxPermissionsExceptThen || this.ngxPermissionsThen || this.templateRef); - }) - .catch(() => { - if (!!this.ngxPermissionsOnly) { - this.validateOnlyPermissions(); - } else { - this.handleAuthorisedPermission(this.ngxPermissionsExceptThen || this.ngxPermissionsThen || this.templateRef); - } - }); - } - - private validateOnlyPermissions(): void { - Promise.all([ - this.permissionsService.hasPermission(this.ngxPermissionsOnly), - this.rolesService.hasOnlyRoles(this.ngxPermissionsOnly), - ]) + #validate(): void { + Promise.all([this._permissionsService.has(this.#permissions), this._rolesService.has(this.#permissions)]) .then(([hasPermissions, hasRoles]) => { if (hasPermissions || hasRoles) { - this.handleAuthorisedPermission(this.ngxPermissionsOnlyThen || this.ngxPermissionsThen || this.templateRef); - } else { - this.handleUnauthorisedPermission(this.ngxPermissionsOnlyElse || this.ngxPermissionsElse); + return this.#showThenBlock(); } + + return this.#showElseBlock(); }) - .catch(() => { - this.handleUnauthorisedPermission(this.ngxPermissionsOnlyElse || this.ngxPermissionsElse); - }); + .catch(() => this.#showElseBlock()); } - private handleUnauthorisedPermission(template: TemplateRef): void { - if (isBoolean(this.currentAuthorizedState) && !this.currentAuthorizedState) { + #showElseBlock() { + if (this.#elseViewRef) { return; } - this.currentAuthorizedState = false; this.permissionsUnauthorized.emit(); - - if (!this.elseBlockDefined()) { - return; - } else { - this.showTemplateBlockInView(template); - } + this.#thenViewRef = undefined; + this.#elseViewRef = this.#showTemplate(this.#elseTemplateRef); } - private handleAuthorisedPermission(template: TemplateRef): void { - if (isBoolean(this.currentAuthorizedState) && this.currentAuthorizedState) { + #showThenBlock() { + if (this.#thenViewRef) { return; } - this.currentAuthorizedState = true; this.permissionsAuthorized.emit(); - - if (!this.thenBlockDefined()) { - return; - } else { - this.showTemplateBlockInView(template); - } + this.#elseViewRef = undefined; + this.#thenViewRef = this.#showTemplate(this.#thenTemplateRef); } - private showTemplateBlockInView(template: TemplateRef): void { - this.viewContainer.clear(); + #showTemplate(template?: NgTemplate) { + this._viewContainer.clear(); + if (!template) { return; } - this.viewContainer.createEmbeddedView(template); - this.changeDetector.markForCheck(); - } - - private getAuthorisedTemplates(): TemplateRef { - return this.ngxPermissionsOnlyThen || this.ngxPermissionsExceptThen || this.ngxPermissionsThen || this.templateRef; - } - - private elseBlockDefined(): boolean { - return !!this.ngxPermissionsExceptElse || !!this.ngxPermissionsElse; - } - - private thenBlockDefined() { - return !!this.ngxPermissionsExceptThen || !!this.ngxPermissionsThen; + return this._viewContainer.createEmbeddedView(template); + } +} + +function assertTemplate(property: string, templateRef: TemplateRef): void { + const isTemplateRefOrNull = !!(!templateRef || templateRef.createEmbeddedView); + if (!isTemplateRefOrNull) { + throw new Error(`${property} must be a TemplateRef, but received '${stringify(templateRef)}'.`); } } diff --git a/src/lib/permissions/permissions.module.ts b/src/lib/permissions/permissions.module.ts index 15b5155..e151458 100644 --- a/src/lib/permissions/permissions.module.ts +++ b/src/lib/permissions/permissions.module.ts @@ -1,11 +1,12 @@ import { NgModule, Optional } from '@angular/core'; -import { NgxPermissionsDirective } from './permissions.directive'; +import { IqserPermissionsDirective } from './permissions.directive'; import { IqserPermissionsService } from './services/permissions.service'; import { IqserPermissionsGuard } from './services/permissions-guard.service'; import { IqserRolesService } from './services/roles.service'; @NgModule({ - declarations: [NgxPermissionsDirective], + declarations: [IqserPermissionsDirective], + exports: [IqserPermissionsDirective], }) export class IqserPermissionsModule { constructor(@Optional() permissionsService: IqserPermissionsService) { diff --git a/src/lib/permissions/services/permissions-guard.service.spec.ts b/src/lib/permissions/services/permissions-guard.service.spec.ts new file mode 100644 index 0000000..b6e8fcf --- /dev/null +++ b/src/lib/permissions/services/permissions-guard.service.spec.ts @@ -0,0 +1,462 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { IqserActivatedRouteSnapshot, IqserPermissionsModule } from '..'; +import { IqserPermissionsService } from './permissions.service'; +import { IqserRolesService } from './roles.service'; +import { IqserPermissionsGuard } from './permissions-guard.service'; + +const ADMIN = 'ADMIN' as const; + +const defaultRouterState = {} as RouterStateSnapshot; +const defaultRouter: Partial = { + navigate: () => Promise.resolve(true), +}; + +let router: Router; +let routerNavigationSpy: jest.SpyInstance; +let testRoute: Partial; +let permissionGuard: IqserPermissionsGuard; +let permissionsService: IqserPermissionsService; + +function configureTestBed() { + router = { ...defaultRouter } as Router; + routerNavigationSpy = jest.spyOn(router, 'navigate'); + + TestBed.configureTestingModule({ + imports: [IqserPermissionsModule.forRoot()], + providers: [ + { + provide: Router, + useValue: router, + }, + ], + }); + + permissionGuard = TestBed.inject(IqserPermissionsGuard); + permissionsService = TestBed.inject(IqserPermissionsService); +} + +describe('Permissions guard', () => { + beforeEach(async () => { + configureTestBed(); + permissionsService.add([ADMIN]); + }); + + it('should create an instance', () => { + expect(permissionGuard).toBeTruthy(); + }); + + it('should return true when only fulfils', async () => { + testRoute = { + data: { + permissions: { + allow: ADMIN, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(routerNavigationSpy).not.toHaveBeenCalled(); + expect(result).toEqual(true); + }); + + it('should return true when only is empty array', async () => { + testRoute = { + data: { + permissions: { + allow: [], + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(routerNavigationSpy).not.toHaveBeenCalled(); + expect(result).toEqual(true); + }); + + it('should return true when no permissions specified', async () => { + testRoute = {}; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(routerNavigationSpy).not.toHaveBeenCalled(); + expect(result).toEqual(true); + }); + + it('should return false when only doesnt match', async () => { + testRoute = { + data: { + permissions: { + allow: 'DOESNT MATCH', + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(routerNavigationSpy).not.toHaveBeenCalled(); + expect(result).toEqual(false); + }); + + it('should return false when no permission match', async () => { + testRoute = { + data: { + permissions: { + allow: ['DOESNT MATCH', 'DOESNT MATCH 2'], + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(routerNavigationSpy).not.toHaveBeenCalled(); + expect(result).toEqual(false); + }); + + it('should return false when only doesnt match and navigate to 404', async () => { + testRoute = { + data: { + permissions: { + allow: 'DOESNT MATCH', + redirectTo: './404', + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['./404']); + }); + + it('should return false when only doesnt match and navigate to array 404', async () => { + testRoute = { + data: { + permissions: { + allow: 'DOESNT MATCH', + redirectTo: ['./404'], + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['./404']); + }); + + it('should return false when only doesnt match and navigate to redirectTo function', async () => { + testRoute = { + data: { + permissions: { + allow: 'DOESNT MATCH', + redirectTo: () => ['./403'], + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['./403']); + }); +}); + +describe('Permissions guard dynamically', () => { + beforeEach(async () => { + configureTestBed(); + permissionsService.add(ADMIN); + }); + + it('should return true when only function matches', async () => { + testRoute = { + params: { + id: 44, + }, + data: { + permissions: { + allow: route => { + if ((route as ActivatedRouteSnapshot).params['id'] === 44) { + return [ADMIN]; + } + + return 'notManager'; + }, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(routerNavigationSpy).not.toHaveBeenCalled(); + expect(result).toEqual(true); + }); + + it('should return false when only function is called and should redirect', async () => { + testRoute = { + params: { + id: 100, + }, + data: { + permissions: { + allow: route => { + if ((route as ActivatedRouteSnapshot).params['id'] === 44) { + return [ADMIN]; + } + + return 'notManager'; + }, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(routerNavigationSpy).not.toHaveBeenCalled(); + expect(result).toEqual(false); + }); +}); + +describe('Permissions guard dynamic redirectTo', () => { + beforeEach(() => { + configureTestBed(); + permissionsService.add(ADMIN); + }); + + it('should redirect to parameters from navigationCommands and navigationExtras', async () => { + testRoute = { + data: { + permissions: { + allow: 'TIED', + redirectTo: { + navigationCommands: ['123'], + navigationExtras: { + skipLocationChange: true, + }, + }, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['123'], { skipLocationChange: true }); + }); + + it('should redirect to function parameters from navigationCommands and navigationExtras', async () => { + testRoute = { + data: { + permissions: { + allow: 'TIED', + redirectTo: { + navigationCommands: () => ['123'], + navigationExtras: () => ({ + skipLocationChange: true, + }), + }, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['123'], { skipLocationChange: true }); + }); +}); + +describe('Permissions guard with multiple redirectTo rules', () => { + beforeEach(() => { + configureTestBed(); + permissionsService.add('canReadAgenda'); + }); + + it('should fail on canEditAgenda and redirect to dashboard', async () => { + testRoute = { + data: { + permissions: { + allow: ['canReadAgenda', 'canEditAgenda', 'canRun'], + redirectTo: { + canReadAgenda: 'agendaList', + canEditAgenda: 'dashboard', + default: 'login', + }, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['dashboard']); + }); + + it('should redirect to dashboard when canEditAgenda fails', async () => { + testRoute = { + data: { + permissions: { + allow: ['canReadAgenda', 'canEditAgenda', 'canRun'], + redirectTo: { + canReadAgenda: 'agendaList', + canEditAgenda: () => 'dashboard', + default: 'login', + }, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['dashboard']); + }); + + it('should redirect to 123 when canEditAgenda fails with navigation parameters', async () => { + testRoute = { + data: { + permissions: { + allow: ['canEditAgenda', 'canRun'], + redirectTo: { + canReadAgenda: 'agendaList', + canEditAgenda: { + navigationCommands: ['123'], + navigationExtras: { + skipLocationChange: true, + }, + }, + default: 'login', + }, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['123'], { skipLocationChange: true }); + }); + + it('should redirect to 123 when canEditAgenda returns parameters from function', async () => { + testRoute = { + data: { + permissions: { + allow: ['canReadAgenda', 'canEditAgenda', 'canRun'], + redirectTo: { + canReadAgenda: 'agendaList', + canEditAgenda: () => { + return { + navigationCommands: ['123'], + navigationExtras: { + skipLocationChange: true, + }, + }; + }, + default: 'login', + }, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['123'], { skipLocationChange: true }); + }); + + it('should redirect to default when a permission fails with no redirection rule', async () => { + permissionsService.add(['canEditAgenda']); + + testRoute = { + data: { + permissions: { + allow: ['canEditAgenda', 'Can run'], + redirectTo: { + canEditAgenda: 'dashboard', + default: 'login', + }, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['login']); + expect(routerNavigationSpy).toHaveBeenCalledTimes(1); + }); + + it('should activate path when nothing fails', async () => { + permissionsService.add('canEditAgenda'); + + testRoute = { + data: { + permissions: { + allow: ['canEditAgenda'], + redirectTo: { + canReadAgenda: 'agendaList', + canEditAgenda: 'dashboard', + canRun: 'run', + default: 'login', + }, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(true); + expect(routerNavigationSpy).not.toHaveBeenCalled(); + }); +}); + +describe('Permissions guard redirectTo as function', () => { + beforeEach(() => { + configureTestBed(); + permissionsService.add('canReadAgenda'); + }); + + it('should dynamically redirect', async () => { + testRoute = { + data: { + permissions: { + allow: ['canRun'], + redirectTo: failedPermission => failedPermission, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(false); + expect(routerNavigationSpy).toHaveBeenCalledWith(['canRun']); + expect(routerNavigationSpy).toHaveBeenCalledTimes(1); + }); + + it('should allow to pass when at least one of parameters allow passing', async () => { + testRoute = { + data: { + permissions: { + allow: ['canReadAgenda', 'CAN_SWIM'], + redirectTo: () => 'login', + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(true); + expect(routerNavigationSpy).not.toHaveBeenCalled(); + }); +}); + +describe('Role guard with redirectTo as function', () => { + let roleService: IqserRolesService; + + beforeEach(() => { + configureTestBed(); + roleService = TestBed.inject(IqserRolesService); + permissionsService.add('canReadAgenda'); + permissionsService.add('AWESOME'); + roleService.add('ADMIN', ['AWESOME', 'canReadAgenda']); + }); + + it('should dynamically pass if one satisfies', async () => { + roleService.add('RUN', ['BLABLA', 'BLABLA2']); + + testRoute = { + data: { + permissions: { + allow: ['RUN', 'AWESOME'], + redirectTo: failedPermission => failedPermission, + }, + }, + }; + + const result = await permissionGuard.canActivate(testRoute as ActivatedRouteSnapshot, defaultRouterState); + expect(result).toEqual(true); + expect(routerNavigationSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/permissions/services/permissions-guard.service.ts b/src/lib/permissions/services/permissions-guard.service.ts index 952367e..5be0828 100644 --- a/src/lib/permissions/services/permissions-guard.service.ts +++ b/src/lib/permissions/services/permissions-guard.service.ts @@ -10,27 +10,23 @@ import { RouterStateSnapshot, } from '@angular/router'; -import { firstValueFrom, forkJoin, from, Observable, of } from 'rxjs'; +import { firstValueFrom, forkJoin, from, of } from 'rxjs'; import { first, mergeMap, tap } from 'rxjs/operators'; import { DEFAULT_REDIRECT_KEY, - ExceptFn, IqserPermissionsRouterData, - IqserRedirectToNavigationParameters, NavigationCommandsFn, NavigationExtrasFn, - OnlyFn, RedirectTo, RedirectToFn, } from '../models/permissions-router-data.model'; import { IqserPermissionsService } from './permissions.service'; import { IqserRolesService } from './roles.service'; -import { isFunction, isPlainObject, transformStringToArray } from '../utils'; +import { isFunction, isRedirectWithParameters, isString, transformPermission } from '../utils'; export interface IqserPermissionsData { - only?: string | string[]; - except?: string | string[]; + allow: string | string[]; redirectTo?: RedirectTo | RedirectToFn; } @@ -42,167 +38,69 @@ export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateC private readonly _router: Router, ) {} - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise | boolean { - return this.#hasPermissions(route, state); + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + return this.#checkPermissions(route, state); } - canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | Promise | boolean { - return this.#hasPermissions(childRoute, state); + canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + return this.#checkPermissions(childRoute, state); } - canLoad(route: Route): boolean | Observable | Promise { - return this.#hasPermissions(route); + canLoad(route: Route) { + return this.#checkPermissions(route); } - passingOnlyPermissionsValidation( - permissions: IqserPermissionsData, - route: ActivatedRouteSnapshot | Route, - state?: RouterStateSnapshot, - ): Promise { - if ( - isFunction(permissions.redirectTo) || - (isPlainObject(permissions.redirectTo) && !this.#isRedirectionWithParameters(permissions.redirectTo)) - ) { - return this.#onlyRedirectCheck(permissions, route, state); - } - return this.#checkOnlyPermissions(permissions, route, state); - } - - #hasPermissions(route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) { - const routeDataPermissions = !!route && route.data ? (route.data['permissions'] as IqserPermissionsRouterData) : {}; - const permissions = this.#transformPermission(routeDataPermissions, route, state); - - if (this.#isParameterAvailable(permissions.except)) { - return this.#passingExceptPermissionsValidation(permissions, route, state); + #validate(permissions: IqserPermissionsData, route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) { + if (isFunction(permissions.redirectTo) || !isRedirectWithParameters(permissions.redirectTo)) { + return this.#checkRedirect(permissions, route, state); } - if (this.#isParameterAvailable(permissions.only)) { - return this.passingOnlyPermissionsValidation(permissions, route, state); + return this.#validatePermissions(permissions, route, state); + } + + #checkPermissions(route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) { + const routePermissions = route?.data ? (route.data['permissions'] as IqserPermissionsRouterData) : undefined; + if (!routePermissions) { + return Promise.resolve(true); } - return true; - } + const permissions = transformPermission(routePermissions, route, state); - #transformPermission( - permissions: IqserPermissionsRouterData, - route: ActivatedRouteSnapshot | Route, - state?: RouterStateSnapshot, - ): IqserPermissionsData { - const only = isFunction(permissions.only) ? permissions.only(route, state) : transformStringToArray(permissions.only); - const except = isFunction(permissions.except) - ? permissions.except(route, state) - : transformStringToArray(permissions.except); - const redirectTo = permissions.redirectTo; - - return { - only, - except, - redirectTo, - }; - } - - #isParameterAvailable(permission?: string | string[]) { - return !!permission && permission.length > 0; - } - - #passingExceptPermissionsValidation( - permissions: IqserPermissionsData, - route: ActivatedRouteSnapshot | Route, - state?: RouterStateSnapshot, - ): Promise { - if ( - !!permissions.redirectTo && - (isFunction(permissions.redirectTo) || - (isPlainObject(permissions.redirectTo) && !this.#isRedirectionWithParameters(permissions.redirectTo))) - ) { - let failedPermission = ''; - - const res = from(permissions.except ?? []).pipe( - mergeMap(permissionsExcept => { - return forkJoin([ - this._permissionsService.hasPermission(permissionsExcept), - this._rolesService.hasOnlyRoles(permissionsExcept), - ]).pipe( - tap(hasPermissions => { - const dontHavePermissions = hasPermissions.every(hasPermission => hasPermission === false); - - if (!dontHavePermissions) { - failedPermission = permissionsExcept; - } - }), - ); - }), - first(hasPermissions => hasPermissions.some(hasPermission => hasPermission === true), false), - mergeMap(isAllFalse => { - if (!!failedPermission) { - this.#handleRedirectOfFailedPermission(permissions, failedPermission, route, state); - - return of(false); - } - - if (!isAllFalse && permissions.only) { - return this.#onlyRedirectCheck(permissions, route, state); - } - - return of(!isAllFalse); - }), - ); - - return firstValueFrom(res); + if (permissions?.allow?.length > 0) { + return this.#validate(permissions, route, state); } - return Promise.all([ - this._permissionsService.hasPermission(permissions.except), - this._rolesService.hasOnlyRoles(permissions.except), - ]).then(([hasPermission, hasRoles]) => { - if (hasPermission || hasRoles) { - if (permissions.redirectTo) { - this.#redirectToAnotherRoute(permissions.redirectTo, route, state); - } - - return false; - } - - if (permissions.only) { - return this.#checkOnlyPermissions(permissions, route, state); - } - return true; - }); + return Promise.resolve(true); } #redirectToAnotherRoute( permissionRedirectTo: RedirectTo | RedirectToFn, route: ActivatedRouteSnapshot | Route, + failedPermissionName: string, state?: RouterStateSnapshot, - failedPermissionName?: string, - ): void { + ) { const redirectTo = isFunction(permissionRedirectTo) ? permissionRedirectTo(failedPermissionName, route, state) : permissionRedirectTo; - if (this.#isRedirectionWithParameters(redirectTo)) { + if (isRedirectWithParameters(redirectTo)) { redirectTo.navigationCommands = this.#transformNavigationCommands(redirectTo.navigationCommands, route, state); redirectTo.navigationExtras = this.#transformNavigationExtras(redirectTo.navigationExtras ?? {}, route, state); - this._router.navigate(redirectTo.navigationCommands, redirectTo.navigationExtras); - return; + return this._router.navigate(redirectTo.navigationCommands, redirectTo.navigationExtras); } if (Array.isArray(redirectTo)) { - this._router.navigate(redirectTo); - } else { - this._router.navigate([redirectTo]); + return this._router.navigate(redirectTo); } - } - #isRedirectionWithParameters(object: any | IqserRedirectToNavigationParameters): object is IqserRedirectToNavigationParameters { - return isPlainObject(object) && (!!object.navigationCommands || !!object.navigationExtras); + return this._router.navigate([redirectTo]); } #transformNavigationCommands( navigationCommands: any[] | NavigationCommandsFn, route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot, - ): any[] { + ) { return isFunction(navigationCommands) ? navigationCommands(route, state) : navigationCommands; } @@ -214,24 +112,21 @@ export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateC return isFunction(navigationExtras) ? navigationExtras(route, state) : navigationExtras; } - #onlyRedirectCheck( - permissions: IqserPermissionsData, - route: ActivatedRouteSnapshot | Route, - state?: RouterStateSnapshot, - ): Promise { - let failedPermission = ''; + #checkRedirect(permissions: IqserPermissionsData, route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) { + if (!permissions.allow || permissions.allow.length === 0) { + return Promise.resolve(true); + } - const res = from(permissions.only ?? []).pipe( - mergeMap(permissionsOnly => { - return forkJoin([ - this._permissionsService.hasPermission(permissionsOnly), - this._rolesService.hasOnlyRoles(permissionsOnly), - ]).pipe( + let failedPermission = ''; + const res = from(permissions.allow).pipe( + mergeMap(permission => { + return forkJoin([this._permissionsService.has(permission), this._rolesService.has(permission)]).pipe( tap(hasPermissions => { const failed = hasPermissions.every(hasPermission => hasPermission === false); if (failed) { - failedPermission = permissionsOnly; + failedPermission = permission; + console.log(`Permission ${permission} is not allowed`); } }), ); @@ -247,16 +142,18 @@ export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateC if (isFunction(permissions.redirectTo)) { if (pass) { return of(true); - } else { - this.#handleRedirectOfFailedPermission(permissions, failedPermission, route, state); - return of(false); } - } else { - if (!!failedPermission) { - this.#handleRedirectOfFailedPermission(permissions, failedPermission, route, state); - } - return of(!pass); + + const redirectHandle = this.#handleRedirectOfFailedPermission(permissions, failedPermission, route, state); + return redirectHandle.then(() => false); } + + if (!!failedPermission) { + const redirectHandle = this.#handleRedirectOfFailedPermission(permissions, failedPermission, route, state); + return redirectHandle.then(() => !pass); + } + + return of(!pass); }), ); @@ -269,29 +166,44 @@ export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateC route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot, ) { - if (this.#isFailedPermissionPropertyOfRedirectTo(permissions, failedPermission)) { - if (!isFunction(permissions.redirectTo)) { - // @ts-ignore - this.#redirectToAnotherRoute(permissions.redirectTo[failedPermission], route, state, failedPermission); - } - } else { - if (isFunction(permissions.redirectTo)) { - this.#redirectToAnotherRoute(permissions.redirectTo, route, state, failedPermission); - } else { - if (permissions.redirectTo) { - // @ts-ignore - this.#redirectToAnotherRoute(permissions.redirectTo[DEFAULT_REDIRECT_KEY], route, state, failedPermission); - } - } + const failedPermissionRedirectTo = this.#getFailedPermissionRedirectTo(permissions.redirectTo, failedPermission); + + if (failedPermissionRedirectTo) { + return this.#redirectToAnotherRoute(failedPermissionRedirectTo, route, failedPermission, state); } + + if (isFunction(permissions.redirectTo) || isString(permissions.redirectTo) || Array.isArray(permissions.redirectTo)) { + return this.#redirectToAnotherRoute(permissions.redirectTo, route, failedPermission, state); + } + + if (permissions.redirectTo && !isRedirectWithParameters(permissions.redirectTo)) { + const defaultRoute = permissions.redirectTo[DEFAULT_REDIRECT_KEY]; + + if (!defaultRoute) { + return Promise.resolve(false); + } + + return this.#redirectToAnotherRoute(defaultRoute, route, failedPermission, state); + } + + return Promise.resolve(false); } - #isFailedPermissionPropertyOfRedirectTo(permissions: IqserPermissionsData, failedPermission: string): boolean { - // @ts-ignore - return !!permissions.redirectTo && permissions.redirectTo[failedPermission]; + #getFailedPermissionRedirectTo(redirectTo: RedirectTo | RedirectToFn | undefined, failedPermission: string) { + if ( + !!redirectTo && + !isFunction(redirectTo) && + !isString(redirectTo) && + !isRedirectWithParameters(redirectTo) && + !Array.isArray(redirectTo) + ) { + return redirectTo[failedPermission]; + } + + return undefined; } - #checkOnlyPermissions( + #validatePermissions( purePermissions: IqserPermissionsData, route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot, @@ -300,19 +212,19 @@ export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateC ...purePermissions, }; - return Promise.all([ - this._permissionsService.hasPermission(permissions.only), - this._rolesService.hasOnlyRoles(permissions.only), - ]).then(([hasPermission, hasRole]) => { - if (hasPermission || hasRole) { - return true; - } + return Promise.all([this._permissionsService.has(permissions.allow), this._rolesService.has(permissions.allow)]).then( + ([hasPermission, hasRole]) => { + if (hasPermission || hasRole) { + return true; + } - if (permissions.redirectTo) { - this.#redirectToAnotherRoute(permissions.redirectTo, route, state); - } + if (permissions.redirectTo) { + const redirect = this.#redirectToAnotherRoute(permissions.redirectTo, route, permissions.allow[0], state); + return redirect.then(() => false); + } - return false; - }); + return false; + }, + ); } } diff --git a/src/lib/permissions/services/permissions.service.spec.ts b/src/lib/permissions/services/permissions.service.spec.ts new file mode 100644 index 0000000..f8f993f --- /dev/null +++ b/src/lib/permissions/services/permissions.service.spec.ts @@ -0,0 +1,157 @@ +import { TestBed } from '@angular/core/testing'; +import { IqserPermissionsModule } from '../permissions.module'; +import { IqserPermissionsService } from './permissions.service'; + +const ADMIN = 'ADMIN' as const; +const GUEST = 'GUEST' as const; + +describe('Permissions Service', () => { + let permissionsService: IqserPermissionsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [IqserPermissionsModule.forRoot()], + }); + + permissionsService = TestBed.inject(IqserPermissionsService); + }); + + it('should create an instance', () => { + expect(permissionsService).toBeTruthy(); + }); + + it('should add permission to permissions object', () => { + expect(permissionsService.get(ADMIN)).toBeFalsy(); + permissionsService.add(ADMIN); + expect(permissionsService.get(ADMIN)).toBeTruthy(); + }); + + it('should remove permission from permissions object', () => { + expect(permissionsService.get(ADMIN)).toBeFalsy(); + permissionsService.add(ADMIN); + expect(permissionsService.get(ADMIN)).toBeTruthy(); + permissionsService.remove(ADMIN); + expect(permissionsService.get(ADMIN)).toBeFalsy(); + }); + + it('should remove all permissions from permissions object', () => { + expect(Object.keys(permissionsService.get()).length).toEqual(0); + + permissionsService.add(ADMIN); + permissionsService.add(GUEST); + expect(Object.keys(permissionsService.get()).length).toEqual(2); + + permissionsService.clear(); + expect(Object.keys(permissionsService.get()).length).toEqual(0); + }); + + it('should add multiple permissions', () => { + expect(Object.keys(permissionsService.get()).length).toEqual(0); + + permissionsService.add([ADMIN, GUEST]); + expect(Object.keys(permissionsService.get()).length).toEqual(2); + expect(permissionsService.get()).toEqual({ + ADMIN: { name: 'ADMIN' }, + GUEST: { name: 'GUEST' }, + }); + }); + + it('should return true when permission name is present in permissions object', async () => { + expect(Object.keys(permissionsService.get()).length).toEqual(0); + + permissionsService.add([ADMIN, GUEST]); + expect(Object.keys(permissionsService.get()).length).toEqual(2); + + let result = await permissionsService.has('ADMIN'); + expect(result).toEqual(true); + + result = await permissionsService.has('SHOULDNOTHAVEROLE'); + expect(result).toEqual(false); + + result = await permissionsService.has(['ADMIN']); + expect(result).toEqual(true); + + result = await permissionsService.has(['ADMIN', 'IRIISISTABLE']); + expect(result).toEqual(true); + }); + + it('should return true when permission function return true', async () => { + expect(Object.keys(permissionsService.get()).length).toEqual(0); + + permissionsService.add(ADMIN, () => true); + expect(Object.keys(permissionsService.get()).length).toEqual(1); + + let result = await permissionsService.has('ADMIN'); + expect(result).toEqual(true); + + permissionsService.add(GUEST, () => false); + expect(Object.keys(permissionsService.get()).length).toEqual(2); + + result = await permissionsService.has('GUEST'); + expect(result).toEqual(false); + + permissionsService.add('TEST1', () => Promise.resolve(true)); + expect(Object.keys(permissionsService.get()).length).toEqual(3); + + result = await permissionsService.has('TEST1'); + expect(result).toEqual(true); + + permissionsService.add('TEST2', () => Promise.resolve(false)); + expect(Object.keys(permissionsService.get()).length).toEqual(4); + + result = await permissionsService.has('TEST2'); + expect(result).toEqual(false); + }); + + // TODO: permissions array with function should not be allowed + it('should return true when permissions array function return true', async () => { + expect(Object.keys(permissionsService.get()).length).toEqual(0); + + permissionsService.add([ADMIN], () => true); + expect(Object.keys(permissionsService.get()).length).toEqual(1); + + let result = await permissionsService.has('ADMIN'); + expect(result).toEqual(true); + + permissionsService.add([GUEST], () => false); + expect(Object.keys(permissionsService.get()).length).toEqual(2); + + result = await permissionsService.has('GUEST'); + expect(result).toEqual(false); + + permissionsService.add(['TEST1'], () => Promise.resolve(true)); + expect(Object.keys(permissionsService.get()).length).toEqual(3); + + result = await permissionsService.has('TEST1'); + expect(result).toEqual(true); + + permissionsService.add(['TEST9'], () => Promise.resolve(false)); + expect(Object.keys(permissionsService.get()).length).toEqual(4); + + result = await permissionsService.has(['TEST9']); + expect(result).toEqual(false); + }); + + it('should call validationFn with permission name and store', async () => { + permissionsService.add('TEST11', (name, store) => { + expect(name).toEqual('TEST11'); + expect(store['TEST11']).toBeTruthy(); + return Promise.resolve(true); + }); + + expect(Object.keys(permissionsService.get()).length).toEqual(1); + + const result = await permissionsService.has(['TEST11']); + expect(result).toEqual(true); + }); + + it('should return true when called with empty parameters', async () => { + const result = await permissionsService.has(''); + expect(result).toEqual(true); + }); + + it('should return true when called with empty array', async () => { + const result = await permissionsService.has([]); + expect(result).toEqual(true); + }); +}); diff --git a/src/lib/permissions/services/permissions.service.ts b/src/lib/permissions/services/permissions.service.ts index 69d73a6..244bf14 100644 --- a/src/lib/permissions/services/permissions.service.ts +++ b/src/lib/permissions/services/permissions.service.ts @@ -1,15 +1,11 @@ import { Injectable } from '@angular/core'; -import { BehaviorSubject, from, Observable, of } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, from, Observable, of } from 'rxjs'; import { catchError, first, map, mergeAll, switchMap } from 'rxjs/operators'; -import { isBoolean, isFunction, transformStringToArray } from '../utils'; +import { isFunction, toArray } from '../utils'; import { ValidationFn } from '../models/permissions-router-data.model'; -import { IqserPermission } from '../models/permission.model'; - -export interface IqserPermissionsObject { - [name: string]: IqserPermission; -} +import { IqserPermission, IqserPermissionsObject } from '../models/permission.model'; @Injectable() export class IqserPermissionsService { @@ -23,103 +19,85 @@ export class IqserPermissionsService { /** * Remove all permissions from permissions source */ - public flushPermissions(): void { + clear(): void { this.#permissions$.next({}); } - public hasPermission(permission?: string | string[]): Promise { + has(permission?: string | string[]): Promise { if (!permission || (Array.isArray(permission) && permission.length === 0)) { return Promise.resolve(true); } - permission = transformStringToArray(permission); - return this.hasArrayPermission(permission); + permission = toArray(permission); + return this.#hasArray(permission); } - public loadPermissions(permissions: string[], validationFunction?: ValidationFn): void { - const newPermissions = permissions.reduce((source, name) => this.reducePermission(source, name, validationFunction), {}); + load(permissions: string[], validationFn?: ValidationFn): void { + const newPermissions = permissions.reduce((source, name) => this.#reduce(source, name, validationFn), {}); this.#permissions$.next(newPermissions); } - public addPermission(permission: string | string[], validationFunction?: ValidationFn): void { - if (Array.isArray(permission)) { - const permissions = permission.reduce( - (source, name) => this.reducePermission(source, name, validationFunction), - this.#permissions$.value, - ); + add(permission: string | string[], validationFn?: ValidationFn) { + const permissions = toArray(permission).reduce( + (source, name) => this.#reduce(source, name, validationFn), + this.#permissions$.value, + ); - this.#permissions$.next(permissions); - } else { - const permissions = this.reducePermission(this.#permissions$.value, permission, validationFunction); - - this.#permissions$.next(permissions); - } + return this.#permissions$.next(permissions); } - public removePermission(permissionName: string): void { - const permissions = { - ...this.#permissions$.value, - }; - delete permissions[permissionName]; + remove(name: string): void { + const permissions = { ...this.#permissions$.value }; + delete permissions[name]; this.#permissions$.next(permissions); } - public getPermission(name: string): IqserPermission | undefined { - return this.#permissions$.value[name]; + get(): IqserPermissionsObject; + get(name: string): IqserPermission | undefined; + get(name?: string): IqserPermission | IqserPermissionsObject | undefined { + return name ? this.#permissions$.value[name] : this.#permissions$.value; } - public getPermissions(): IqserPermissionsObject { - return this.#permissions$.value; - } - - private reducePermission(source: IqserPermissionsObject, name: string, validationFunction?: ValidationFn): IqserPermissionsObject { - if (!!validationFunction && isFunction(validationFunction)) { - return { - ...source, - [name]: { name, validationFunction }, - }; + #reduce(source: IqserPermissionsObject, name: string, validationFn?: ValidationFn): IqserPermissionsObject { + if (!!validationFn && isFunction(validationFn)) { + return { ...source, [name]: { name, validationFn } }; } - return { - ...source, - [name]: { name }, - }; + + return { ...source, [name]: { name } }; } - private hasArrayPermission(permissions: string[]): Promise { + #hasArray(permissions: string[]): Promise { const promises = permissions.map(key => { - if (this.hasPermissionValidationFunction(key)) { - const validationFunction = this.#permissions$.value[key].validationFunction; + if (this.#hasValidationFn(key)) { + const validationFunction = this.#permissions$.value[key].validationFn; if (!validationFunction) { return of(false); } const immutableValue = { ...this.#permissions$.value }; return of(null).pipe( - map(() => validationFunction(key, immutableValue)), - switchMap(promise => (isBoolean(promise) ? of(promise) : promise)), + switchMap(async () => validationFunction(key, immutableValue)), catchError(() => of(false)), ); } - // check for name of the permission if there is no validation function return of(!!this.#permissions$.value[key]); }); - return from(promises) - .pipe( - mergeAll(), - first(data => data !== false, false), - map(data => data !== false), - ) - .toPromise() - .then((data: any) => data); + const res = from(promises).pipe( + mergeAll(), + first(data => data !== false, false), + map(data => data !== false), + ); + + return firstValueFrom(res); } - private hasPermissionValidationFunction(key: string): boolean { + #hasValidationFn(key: string): boolean { return ( !!this.#permissions$.value[key] && - !!this.#permissions$.value[key].validationFunction && - isFunction(this.#permissions$.value[key].validationFunction) + !!this.#permissions$.value[key].validationFn && + isFunction(this.#permissions$.value[key].validationFn) ); } } diff --git a/src/lib/permissions/services/roles.service.spec.ts b/src/lib/permissions/services/roles.service.spec.ts new file mode 100644 index 0000000..e1c904b --- /dev/null +++ b/src/lib/permissions/services/roles.service.spec.ts @@ -0,0 +1,209 @@ +import { TestBed } from '@angular/core/testing'; +import { ValidationFn } from '../models/permissions-router-data.model'; +import { IqserRolesService } from './roles.service'; +import { IqserPermissionsService } from './permissions.service'; +import { IqserPermissionsModule } from '../permissions.module'; +import { IqserRole } from '../models/role.model'; + +const ADMIN = 'ADMIN' as const; +const GUEST = 'GUEST' as const; + +describe('Roles Service', () => { + let rolesService: IqserRolesService; + let permissionsService: IqserPermissionsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [IqserPermissionsModule.forRoot()], + }); + + rolesService = TestBed.inject(IqserRolesService); + permissionsService = TestBed.inject(IqserPermissionsService); + }); + + it('should create an instance', () => { + expect(rolesService).toBeTruthy(); + }); + + it('should add role to role object', () => { + expect(rolesService.get(ADMIN)).toBeFalsy(); + + rolesService.add(ADMIN, ['edit', 'remove']); + + expect(rolesService.get(ADMIN)).toBeTruthy(); + expect(rolesService.get()).toEqual({ ADMIN: { name: ADMIN, validationFn: ['edit', 'remove'] } }); + }); + + it('should remove role from role object', () => { + expect(rolesService.get(ADMIN)).toBeFalsy(); + + rolesService.add(ADMIN, ['edit', 'remove']); + expect(rolesService.get(ADMIN)).toBeTruthy(); + + rolesService.remove(ADMIN); + expect(rolesService.get(ADMIN)).toBeFalsy(); + }); + + it('should remove all roles from object', () => { + expect(Object.keys(rolesService.get()).length).toEqual(0); + + rolesService.add(ADMIN, ['edit', 'remove']); + rolesService.add(GUEST, ['edit', 'remove']); + expect(Object.keys(rolesService.get()).length).toEqual(2); + + rolesService.clear(); + expect(Object.keys(rolesService.get()).length).toEqual(0); + }); + + it('should add multiple roles', () => { + expect(Object.keys(rolesService.get()).length).toEqual(0); + rolesService.add({ + ADMIN: ['Nice'], + GUEST: ['Awesome'], + }); + + expect(Object.keys(rolesService.get()).length).toEqual(2); + expect(rolesService.get()).toEqual({ + ADMIN: { name: ADMIN, validationFn: ['Nice'] }, + GUEST: { name: GUEST, validationFn: ['Awesome'] }, + }); + }); + + it('return true when role name is present in Roles object', async () => { + expect(Object.keys(rolesService.get()).length).toEqual(0); + + rolesService.add({ + ADMIN: ['Nice'], + GUEST: ['Awesome'], + }); + + expect(Object.keys(rolesService.get()).length).toEqual(2); + + let result = await rolesService.has(ADMIN); + expect(result).toEqual(true); + + result = await rolesService.has('SHOULDNOTHAVEROLE'); + expect(result).toEqual(false); + + result = await rolesService.has([ADMIN, 'IRIISISTABLE']); + expect(result).toEqual(true); + }); + + it('return true when role permission name is present in Roles object', async () => { + expect(Object.keys(rolesService.get()).length).toEqual(0); + + rolesService.add({ + ADMIN: ['Nice'], + GUEST: ['Awesome'], + }); + + expect(Object.keys(rolesService.get()).length).toEqual(2); + let result = await rolesService.has(ADMIN); + expect(result).toEqual(true); + + result = await rolesService.has([ADMIN, 'IRRISISTABLE']); + expect(result).toEqual(true); + + result = await rolesService.has('SHOULDNOTHAVEROLE'); + expect(result).toEqual(false); + + result = await rolesService.has(['SHOULDNOTHAVEROLE']); + expect(result).toEqual(false); + }); + + it('should return role when requested with has role', () => { + rolesService.add('role', () => true); + const role = rolesService.get('role'); + + if (!role) { + return expect(role).toBeTruthy(); + } + + expect(role.name).toBe('role'); + + const validationFn = role.validationFn as ValidationFn; + expect(validationFn(role.name, rolesService.get())).toEqual(true); + }); + + it('should return true when checking with empty permission(not specified)', async () => { + const result = await rolesService.has(''); + expect(result).toEqual(true); + }); + + it('should return false when permission array is empty', async () => { + const result = await rolesService.has('Empty'); + expect(result).toEqual(false); + }); + + it('should return false when role is not specified in the list', async () => { + rolesService.add('test', ['One']); + + const result = await rolesService.has('nice'); + expect(result).toBe(false); + }); + + it('should return true when passing empty array', async () => { + rolesService.add('test', ['One']); + + const result = await rolesService.has([]); + expect(result).toBe(true); + }); + + it('should add permissions to roles automatically', async () => { + rolesService.add('test', ['one', 'two']); + + const result = await rolesService.has('test'); + expect(result).toBe(true); + }); + + it('should remove roles and permissions add the same time', async () => { + rolesService.add('test', ['one', 'two']); + + let result = await rolesService.has('test'); + expect(result).toBe(true); + result = await permissionsService.has('one'); + expect(result).toBe(true); + + rolesService.clear(); + permissionsService.clear(); + + result = await rolesService.has('test'); + expect(result).toBe(false); + result = await permissionsService.has('one'); + expect(result).toBe(false); + }); + + it('should remove all permissions and roles', () => { + expect(Object.keys(rolesService.get()).length).toEqual(0); + + rolesService.add(ADMIN, ['edit', 'remove']); + rolesService.add(GUEST, ['edit1', 'remove2']); + + expect(Object.keys(rolesService.get()).length).toEqual(2); + expect(Object.keys(permissionsService.get()).length).toEqual(4); + + rolesService.clear(); + permissionsService.clear(); + + expect(Object.keys(rolesService.get()).length).toEqual(0); + expect(Object.keys(permissionsService.get()).length).toEqual(0); + }); + + it('should add multiple roles with permissions', () => { + expect(Object.keys(rolesService.get()).length).toEqual(0); + + rolesService.add({ + ADMIN: ['Nice'], + GUEST: ['Awesome', 'Another awesome'], + }); + + expect(Object.keys(rolesService.get()).length).toEqual(2); + expect(rolesService.get()).toEqual({ + ADMIN: { name: ADMIN, validationFn: ['Nice'] }, + GUEST: { name: GUEST, validationFn: ['Awesome', 'Another awesome'] }, + }); + + expect(Object.keys(rolesService.get()).length).toEqual(2); + expect(Object.keys(permissionsService.get()).length).toEqual(3); + }); +}); diff --git a/src/lib/permissions/services/roles.service.ts b/src/lib/permissions/services/roles.service.ts index 9f9942f..1e48b1b 100644 --- a/src/lib/permissions/services/roles.service.ts +++ b/src/lib/permissions/services/roles.service.ts @@ -6,7 +6,7 @@ import { catchError, every, first, map, mergeAll, mergeMap, switchMap } from 'rx import { IqserPermissionsService } from './permissions.service'; import { ValidationFn } from '../models/permissions-router-data.model'; import { IqserRole } from '../models/role.model'; -import { isBoolean, isFunction, isPromise, transformStringToArray } from '../utils'; +import { isFunction, isString, toArray } from '../utils'; export interface IqserRolesObject { [name: string]: IqserRole; @@ -21,86 +21,76 @@ export class IqserRolesService { this.roles$ = this.#roles$.asObservable(); } - addRole(name: string, validationFunction: ValidationFn | string[]) { - const roles = { - ...this.#roles$.value, - [name]: { name, validationFunction }, - }; - this.#roles$.next(roles); + add(role: string, validationFn: ValidationFn | string[]): void; + add(rolesObj: Record | string[]>): void; + add(role: string | Record | string[]>, validationFn?: ValidationFn | string[]) { + if (isString(role) && validationFn) { + return this.#add(role, validationFn); + } + + if (typeof role === 'object') { + return Object.keys(role).forEach(key => this.#add(key, role[key])); + } + + throw new Error('Invalid add role arguments'); } - addRoleWithPermissions(name: string, permissions: string[]) { - this._permissionsService.addPermission(permissions); - this.addRole(name, permissions); - } - - addRoles(rolesObj: { [name: string]: ValidationFn | string[] }) { - Object.keys(rolesObj).forEach((key, index) => { - this.addRole(key, rolesObj[key]); - }); - } - - addRolesWithPermissions(rolesObj: { [name: string]: string[] }) { - Object.keys(rolesObj).forEach((key, index) => { - this.addRoleWithPermissions(key, rolesObj[key]); - }); - } - - flushRoles() { + clear() { this.#roles$.next({}); } - flushRolesAndPermissions() { - this.flushRoles(); - this._permissionsService.flushPermissions(); - } - - removeRole(roleName: string) { - const roles = { - ...this.#roles$.value, - }; - delete roles[roleName]; + remove(role: string) { + const roles = { ...this.#roles$.value }; + delete roles[role]; this.#roles$.next(roles); } - getRoles(): IqserRolesObject { - return this.#roles$.value; + get(): IqserRolesObject; + + get(role: string): IqserRole | undefined; + + get(role?: string): IqserRolesObject | IqserRole | undefined { + return role ? this.#roles$.value[role] : this.#roles$.value; } - getRole(name: string): IqserRole | undefined { - return this.#roles$.value[name]; - } - - hasOnlyRoles(names?: string | string[]): Promise { + has(names?: string | string[]): Promise { const isNamesEmpty = !names || (Array.isArray(names) && names.length === 0); if (isNamesEmpty) { return Promise.resolve(true); } - names = transformStringToArray(names); + names = toArray(names); - return Promise.all([this.#hasRoleKey(names), this.#hasRolePermission(this.#roles$.value, names)]).then( - ([hasRoles, hasPermissions]) => { - return !!hasRoles || !!hasPermissions; - }, - ); + return Promise.all([this.#hasRoleKey(names), this.#hasPermission(names)]).then(([hasRoles, hasPermissions]) => { + return !!hasRoles || !!hasPermissions; + }); + } + + #add(role: string, validationFn: ValidationFn | string[]) { + const roles: IqserRolesObject = { + ...this.#roles$.value, + [role]: { name: role, validationFn }, + }; + + if (Array.isArray(validationFn)) { + this._permissionsService.add(validationFn); + } + + return this.#roles$.next(roles); } #hasRoleKey(roleName: string[]): Promise { const promises = roleName.map(key => { - const hasValidationFunction = - !!this.#roles$.value[key] && - !!this.#roles$.value[key].validationFunction && - isFunction(this.#roles$.value[key].validationFunction); + const role = this.#roles$.value[key]; + const hasValidationFn = !!role && !!role.validationFn; - if (hasValidationFunction && !isPromise(this.#roles$.value[key].validationFunction)) { - const validationFunction = this.#roles$.value[key].validationFunction as ValidationFn; + if (hasValidationFn && isFunction>(role.validationFn)) { + const validationFn = role.validationFn; const immutableValue = { ...this.#roles$.value }; return of(null).pipe( - map(() => validationFunction(key, immutableValue)), - switchMap(promise => (isBoolean(promise) ? of(promise) : promise)), + switchMap(async () => validationFn(key, immutableValue)), catchError(() => of(false)), ); } @@ -117,12 +107,14 @@ export class IqserRolesService { return firstValueFrom(res); } - #hasRolePermission(roles: IqserRolesObject, roleNames: string[]): Promise { + #hasPermission(roleNames: string[]): Promise { const res = from(roleNames).pipe( mergeMap(key => { - if (roles[key] && Array.isArray(roles[key].validationFunction)) { - return from(roles[key].validationFunction).pipe( - mergeMap(permission => this._permissionsService.hasPermission(permission)), + const role = this.#roles$.value[key]; + + if (role && !isFunction>(role.validationFn)) { + return from(role.validationFn).pipe( + mergeMap(permission => this._permissionsService.has(permission)), every(hasPermission => hasPermission === true), ); } diff --git a/src/lib/permissions/utils.ts b/src/lib/permissions/utils.ts index 67d5049..f1b6f9e 100644 --- a/src/lib/permissions/utils.ts +++ b/src/lib/permissions/utils.ts @@ -1,38 +1,49 @@ -export function isFunction(value: any): value is T { +import { AllowFn, IqserPermissionsRouterData, IqserRedirectToNavigationParameters } from './models/permissions-router-data.model'; +import { ActivatedRouteSnapshot, Route, RouterStateSnapshot } from '@angular/router'; +import { IqserPermissionsData } from './services/permissions-guard.service'; + +export function isFunction(value: unknown): value is T { return typeof value === 'function'; } -export function isPlainObject(value: any): boolean { +export function isPlainObject(value: unknown): boolean { if (Object.prototype.toString.call(value) !== '[object Object]') { return false; - } else { - const prototype = Object.getPrototypeOf(value); - return prototype === null || prototype === Object.prototype; } + + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype === Object.prototype; } -export function isString(value: any): value is string { +export function isString(value: unknown): value is string { return !!value && typeof value === 'string'; } -export function isBoolean(value: any): value is boolean { - return typeof value === 'boolean'; +export function notEmpty(value?: string | string[]): boolean { + return Array.isArray(value) ? value.length > 0 : !!value; } -export function isPromise(promise: any) { - return Object.prototype.toString.call(promise) === '[object Promise]'; -} - -export function notEmptyValue(value: string | string[]): boolean { - if (Array.isArray(value)) { - return value.length > 0; - } - return !!value; -} - -export function transformStringToArray(value?: string | string[]): string[] { +export function toArray(value?: string | string[]): string[] { if (isString(value)) { return [value]; } return value ?? []; } + +export function isRedirectWithParameters(object: any | IqserRedirectToNavigationParameters): object is IqserRedirectToNavigationParameters { + return isPlainObject(object) && (!!object.navigationCommands || !!object.navigationExtras); +} + +export function transformPermission( + permissions: IqserPermissionsRouterData, + route: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, +): IqserPermissionsData { + const only = isFunction(permissions.allow) ? permissions.allow(route, state) : toArray(permissions.allow); + const redirectTo = permissions.redirectTo; + + return { + allow: only, + redirectTo, + }; +} diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..423b1f2 --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/spec", + "types": ["jest", "node"], + "esModuleInterop": true + }, + "include": ["./src/lib/**/*.spec.ts", "./src/lib/**/*.d.ts"] +}