From ff02c6acc4957ca83576186776f4915dca821b53 Mon Sep 17 00:00:00 2001 From: Denys Vuika Date: Fri, 13 Jul 2018 04:42:54 +0100 Subject: [PATCH] extension api: permission fixes (#507) * fix multiple targets * permission fixes * tests and code fixes * remove famous fdescribe * and the missing semicolon of course * even more tests --- src/app/extensions/extension.service.spec.ts | 637 +++++++++++++++++++ src/app/extensions/extension.service.ts | 189 +++--- 2 files changed, 743 insertions(+), 83 deletions(-) create mode 100644 src/app/extensions/extension.service.spec.ts diff --git a/src/app/extensions/extension.service.spec.ts b/src/app/extensions/extension.service.spec.ts new file mode 100644 index 000000000..e0819e4c8 --- /dev/null +++ b/src/app/extensions/extension.service.spec.ts @@ -0,0 +1,637 @@ +/*! + * @license + * Alfresco Example Content Application + * + * Copyright (C) 2005 - 2018 Alfresco Software Limited + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + */ + +import { TestBed } from '@angular/core/testing'; +import { AppTestingModule } from '../testing/app-testing.module'; +import { ExtensionService } from './extension.service'; +import { AppConfigService } from '@alfresco/adf-core'; +import { Store } from '@ngrx/store'; +import { AppStore } from '../store/states'; +import { ContentActionType } from './content-action.extension'; + +describe('ExtensionService', () => { + let config: AppConfigService; + let extensions: ExtensionService; + let store: Store; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [AppTestingModule] + }); + + extensions = TestBed.get(ExtensionService); + store = TestBed.get(Store); + + config = TestBed.get(AppConfigService); + config.config['extensions'] = {}; + }); + + describe('auth guards', () => { + let guard1; + let guard2; + + beforeEach(() => { + guard1 = {}; + guard2 = {}; + + extensions.authGuards['guard1'] = guard1; + extensions.authGuards['guard2'] = guard2; + + extensions.init(); + }); + + it('should fetch auth guards by ids', () => { + const guards = extensions.getAuthGuards(['guard2', 'guard1']); + + expect(guards.length).toBe(2); + expect(guards[0]).toEqual(guard1); + expect(guards[1]).toEqual(guard2); + }); + + it('should not fetch auth guards for missing ids', () => { + const guards = extensions.getAuthGuards(null); + expect(guards).toEqual([]); + }); + + it('should fetch only known guards', () => { + const guards = extensions.getAuthGuards(['missing', 'guard1']); + + expect(guards.length).toBe(1); + expect(guards[0]).toEqual(guard1); + }); + }); + + describe('components', () => { + let component1; + + beforeEach(() => { + component1 = {}; + + extensions.components['component-1'] = component1; + extensions.init(); + }); + + it('should fetch registered component', () => { + const component = extensions.getComponentById('component-1'); + expect(component).toEqual(component1); + }); + + it('should not fetch registered component', () => { + const component = extensions.getComponentById('missing'); + expect(component).toBeFalsy(); + }); + }); + + describe('routes', () => { + let component1, component2; + let guard1; + + beforeEach(() => { + config.config.extensions = { + core: { + routes: [ + { + id: 'aca:routes/about', + path: 'ext/about', + component: 'aca:components/about', + layout: 'aca:layouts/main', + auth: ['aca:auth'], + data: { + title: 'Custom About' + } + } + ] + } + }; + + component1 = {}; + component2 = {}; + extensions.components['aca:components/about'] = component1; + extensions.components['aca:layouts/main'] = component2; + + guard1 = {}; + extensions.authGuards['aca:auth'] = guard1; + + extensions.init(); + }); + + it('should load routes from the config', () => { + expect(extensions.routes.length).toBe(1); + }); + + it('should find a route by id', () => { + const route = extensions.getRouteById('aca:routes/about'); + expect(route).toBeTruthy(); + expect(route.path).toBe('ext/about'); + }); + + it('should not find a route by id', () => { + const route = extensions.getRouteById('some-route'); + expect(route).toBeFalsy(); + }); + + it('should build application routes', () => { + const routes = extensions.getApplicationRoutes(); + + expect(routes.length).toBe(1); + + const route = routes[0]; + expect(route.path).toBe('ext/about'); + expect(route.component).toEqual(component2); + expect(route.canActivateChild).toEqual([guard1]); + expect(route.canActivate).toEqual([guard1]); + expect(route.children.length).toBe(1); + expect(route.children[0].path).toBe(''); + expect(route.children[0].component).toEqual(component1); + }); + }); + + describe('actions', () => { + beforeEach(() => { + config.config.extensions = { + core: { + actions: [ + { + id: 'aca:actions/create-folder', + type: 'CREATE_FOLDER', + payload: 'folder-name' + } + ] + } + }; + }); + + it('should load actions from the config', () => { + extensions.init(); + expect(extensions.actions.length).toBe(1); + }); + + it('should have an empty action list if config provides nothing', () => { + config.config.extensions = {}; + extensions.init(); + + expect(extensions.actions).toEqual([]); + }); + + it('should find action by id', () => { + extensions.init(); + + const action = extensions.getActionById( + 'aca:actions/create-folder' + ); + expect(action).toBeTruthy(); + expect(action.type).toBe('CREATE_FOLDER'); + expect(action.payload).toBe('folder-name'); + }); + + it('should not find action by id', () => { + extensions.init(); + + const action = extensions.getActionById('missing'); + expect(action).toBeFalsy(); + }); + + it('should run the action via store', () => { + extensions.init(); + spyOn(store, 'dispatch').and.stub(); + + extensions.runActionById('aca:actions/create-folder'); + expect(store.dispatch).toHaveBeenCalledWith({ + type: 'CREATE_FOLDER', + payload: 'folder-name' + }); + }); + + it('should not use store if action is missing', () => { + extensions.init(); + spyOn(store, 'dispatch').and.stub(); + + extensions.runActionById('missing'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('content actions', () => { + it('should load content actions from the config', () => { + config.config.extensions = { + core: { + features: { + content: { + actions: [ + { + id: 'aca:toolbar/separator-1', + order: 1, + type: 'separator' + }, + { + id: 'aca:toolbar/separator-2', + order: 2, + type: 'separator' + } + ] + } + } + } + }; + + extensions.init(); + expect(extensions.contentActions.length).toBe(2); + }); + + it('should have an empty content action list if config is empty', () => { + config.config.extensions = {}; + extensions.init(); + expect(extensions.contentActions).toEqual([]); + }); + + it('should sort content actions by order', () => { + config.config.extensions = { + core: { + features: { + content: { + actions: [ + { + id: 'aca:toolbar/separator-2', + order: 2, + type: 'separator' + }, + { + id: 'aca:toolbar/separator-1', + order: 1, + type: 'separator' + } + ] + } + } + } + }; + + extensions.init(); + expect(extensions.contentActions.length).toBe(2); + expect(extensions.contentActions[0].id).toBe( + 'aca:toolbar/separator-1' + ); + expect(extensions.contentActions[1].id).toBe( + 'aca:toolbar/separator-2' + ); + }); + }); + + describe('open with', () => { + it('should load [open with] actions for the viewer', () => { + config.config.extensions = { + core: { + features: { + viewer: { + 'open-with': [ + { + disabled: false, + id: 'aca:viewer/action1', + order: 100, + icon: 'build', + title: 'Snackbar', + action: 'aca:actions/info' + } + ] + } + } + } + }; + + extensions.init(); + expect(extensions.openWithActions.length).toBe(1); + }); + + it('should have an empty [open with] list if config is empty', () => { + config.config.extensions = {}; + extensions.init(); + expect(extensions.openWithActions).toEqual([]); + }); + + it('should load only enabled [open with] actions for the viewer', () => { + config.config.extensions = { + core: { + features: { + viewer: { + 'open-with': [ + { + id: 'aca:viewer/action2', + order: 200, + icon: 'build', + title: 'Snackbar', + action: 'aca:actions/info' + }, + { + disabled: true, + id: 'aca:viewer/action1', + order: 100, + icon: 'build', + title: 'Snackbar', + action: 'aca:actions/info' + } + ] + } + } + } + }; + + extensions.init(); + expect(extensions.openWithActions.length).toBe(1); + expect(extensions.openWithActions[0].id).toBe('aca:viewer/action2'); + }); + + it('should sort [open with] actions by order', () => { + config.config.extensions = { + core: { + features: { + viewer: { + 'open-with': [ + { + id: 'aca:viewer/action2', + order: 200, + icon: 'build', + title: 'Snackbar', + action: 'aca:actions/info' + }, + { + id: 'aca:viewer/action1', + order: 100, + icon: 'build', + title: 'Snackbar', + action: 'aca:actions/info' + } + ] + } + } + } + }; + + extensions.init(); + expect(extensions.openWithActions.length).toBe(2); + expect(extensions.openWithActions[0].id).toBe('aca:viewer/action1'); + expect(extensions.openWithActions[1].id).toBe('aca:viewer/action2'); + }); + }); + + describe('create', () => { + it('should load [create] actions from config', () => { + config.config.extensions = { + core: { + features: { + create: [ + { + disabled: false, + id: 'aca:create/folder', + order: 100, + icon: 'create_new_folder', + title: 'ext: Create Folder', + target: { + permissions: ['create'], + action: 'aca:actions/create-folder' + } + } + ] + } + } + }; + + extensions.init(); + expect(extensions.createActions.length).toBe(1); + }); + + it('should have an empty [create] actions if config is empty', () => { + config.config.extensions = {}; + extensions.init(); + expect(extensions.createActions).toEqual([]); + }); + + it('should sort [create] actions by order', () => { + config.config.extensions = { + core: { + features: { + create: [ + { + id: 'aca:create/folder', + order: 100, + icon: 'create_new_folder', + title: 'ext: Create Folder', + target: { + permissions: ['create'], + action: 'aca:actions/create-folder' + } + }, + { + id: 'aca:create/folder-2', + order: 10, + icon: 'create_new_folder', + title: 'ext: Create Folder', + target: { + permissions: ['create'], + action: 'aca:actions/create-folder' + } + } + ] + } + } + }; + + extensions.init(); + expect(extensions.createActions.length).toBe(2); + expect(extensions.createActions[0].id).toBe('aca:create/folder-2'); + expect(extensions.createActions[1].id).toBe('aca:create/folder'); + }); + }); + + describe('expressions', () => { + it('should eval static value', () => { + const value = extensions.runExpression('hello world'); + expect(value).toBe('hello world'); + }); + + it('should eval string as an expression', () => { + const value = extensions.runExpression('$( "hello world" )'); + expect(value).toBe('hello world'); + }); + + it('should eval expression with no context', () => { + const value = extensions.runExpression('$( 1 + 1 )'); + expect(value).toBe(2); + }); + + it('should eval expression with context', () => { + const context = { + a: 'hey', + b: 'there' + }; + const expression = '$( context.a + " " + context.b + "!" )'; + const value = extensions.runExpression(expression, context); + expect(value).toBe('hey there!'); + }); + }); + + describe('permissions', () => { + it('should approve node permission', () => { + const node: any = { + allowableOperations: ['create'] + }; + + expect(extensions.nodeHasPermission(node, 'create')).toBeTruthy(); + }); + + it('should not approve node permission', () => { + const node: any = { + allowableOperations: ['create'] + }; + + expect(extensions.nodeHasPermission(node, 'update')).toBeFalsy(); + }); + + it('should not approve node permission when node missing property', () => { + const node: any = { + allowableOperations: null + }; + + expect(extensions.nodeHasPermission(node, 'update')).toBeFalsy(); + }); + + it('should require node to check permission', () => { + expect(extensions.nodeHasPermission(null, 'create')).toBeFalsy(); + }); + + it('should require permission value to check', () => { + const node: any = { + allowableOperations: ['create'] + }; + expect(extensions.nodeHasPermission(node, null)).toBeFalsy(); + }); + + it('should approve multiple permissions', () => { + const node: any = { + allowableOperations: ['create', 'update', 'delete'] + }; + expect( + extensions.nodeHasPermissions(node, ['create', 'delete']) + ).toBeTruthy(); + }); + + it('should require node to check multiple permissions', () => { + expect(extensions.nodeHasPermissions(null, ['create'])).toBeFalsy(); + }); + + it('should require multiple permissions to check', () => { + const node: any = { + allowableOperations: ['create', 'update', 'delete'] + }; + expect(extensions.nodeHasPermissions(node, null)).toBeFalsy(); + }); + }); + + describe('sorting', () => { + it('should sort by provided order', () => { + const sorted = [ + { id: '1', order: 10 }, + { id: '2', order: 1 }, + { id: '3', order: 5 } + ].sort(extensions.sortByOrder); + + expect(sorted[0].id).toBe('2'); + expect(sorted[1].id).toBe('3'); + expect(sorted[2].id).toBe('1'); + }); + + it('should use implicit order', () => { + const sorted = [ + { id: '3'}, + { id: '2' }, + { id: '1', order: 1 } + ].sort(extensions.sortByOrder); + + expect(sorted[0].id).toBe('1'); + expect(sorted[1].id).toBe('3'); + expect(sorted[2].id).toBe('2'); + }); + }); + + describe('filtering', () => { + it('should filter out disabled items', () => { + const items = [ + { id: 1, disabled: true }, + { id: 2 }, + { id: 3, disabled: true } + ].filter(extensions.filterEnabled); + + expect(items.length).toBe(1); + expect(items[0].id).toBe(2); + }); + }); + + it('should reduce duplicate separators', () => { + const actions = [ + { id: '1', type: ContentActionType.button }, + { id: '2', type: ContentActionType.separator }, + { id: '3', type: ContentActionType.separator }, + { id: '4', type: ContentActionType.separator }, + { id: '5', type: ContentActionType.button } + ]; + + const result = actions.reduce(extensions.reduceSeparators, []); + expect(result.length).toBe(3); + expect(result[0].id).toBe('1'); + expect(result[1].id).toBe('2'); + expect(result[2].id).toBe('5'); + }); + + it('should trim trailing separators', () => { + const actions = [ + { id: '1', type: ContentActionType.button }, + { id: '2', type: ContentActionType.separator } + ]; + + const result = actions.reduce(extensions.reduceSeparators, []); + expect(result.length).toBe(1); + expect(result[0].id).toBe('1'); + }); + + it('should reduce empty menus', () => { + const actions = [ + { id: '1', type: ContentActionType.button }, + { + id: '2', + type: ContentActionType.menu + }, + { + id: '3', + type: ContentActionType.menu, + children: [{ id: '3-1', type: ContentActionType.button }] + } + ]; + + const result = actions.reduce(extensions.reduceEmptyMenus, []); + + expect(result.length).toBe(2); + expect(result[0].id).toBe('1'); + expect(result[1].id).toBe('3'); + }); +}); diff --git a/src/app/extensions/extension.service.ts b/src/app/extensions/extension.service.ts index adcf40f2c..40e7c10b0 100644 --- a/src/app/extensions/extension.service.ts +++ b/src/app/extensions/extension.service.ts @@ -27,7 +27,10 @@ import { Injectable, Type } from '@angular/core'; import { RouteExtension } from './route.extension'; import { ActionExtension } from './action.extension'; import { AppConfigService } from '@alfresco/adf-core'; -import { ContentActionExtension, ContentActionType } from './content-action.extension'; +import { + ContentActionExtension, + ContentActionType +} from './content-action.extension'; import { OpenWithExtension } from './open-with.extension'; import { AppStore } from '../store/states'; import { Store } from '@ngrx/store'; @@ -133,8 +136,10 @@ export class ExtensionService { return []; } - getAuthGuards(ids: string[] = []): Array> { - return ids.map(id => this.authGuards[id]); + getAuthGuards(ids: string[]): Array> { + return (ids || []) + .map(id => this.authGuards[id]) + .filter(guard => guard); } getComponentById(id: string): Type<{}> { @@ -207,15 +212,25 @@ export class ExtensionService { return this.contentActions .filter(this.filterEnabled) .filter(action => this.filterByTarget(nodes, action)) - .filter(action => this.filterByPermission(nodes, action, parentNode)) + .filter(action => + this.filterByPermission(nodes, action, parentNode) + ) .reduce(this.reduceSeparators, []) .map(action => { if (action.type === ContentActionType.menu) { const copy = this.copyAction(action); if (copy.children && copy.children.length > 0) { copy.children = copy.children - .filter(childAction => this.filterByTarget(nodes, childAction)) - .filter(childAction => this.filterByPermission(nodes, childAction, parentNode)) + .filter(childAction => + this.filterByTarget(nodes, childAction) + ) + .filter(childAction => + this.filterByPermission( + nodes, + childAction, + parentNode + ) + ) .reduce(this.reduceSeparators, []); } return copy; @@ -225,66 +240,7 @@ export class ExtensionService { .reduce(this.reduceEmptyMenus, []); } - // todo: support multiple selected nodes - private filterByPermission( - nodes: MinimalNodeEntity[], - action: ContentActionExtension, - parentNode: Node - ): boolean { - if (!action) { - return false; - } - - if (!action.target) { - return action.type === ContentActionType.separator - || action.type === ContentActionType.menu; - } - - const permissions = action.target.permissions; - - if (!permissions || permissions.length === 0) { - return true; - } - - return permissions.some(permission => { - if (permission.startsWith('parent.')) { - if (parentNode) { - const parentQuery = permission.split('.')[1]; - return this.nodeHasPermissions(parentNode, [parentQuery]); - } - return false; - } - - if (nodes && nodes.length > 0) { - return this.nodeHasPermissions( - nodes[0].entry, - permissions - ); - } - - return true; - }); - - return true; - } - - private nodeHasPermissions( - node: Node, - permissions: string[] = [] - ): boolean { - if ( - node && - node.allowableOperations && - node.allowableOperations.length > 0 - ) { - return permissions.some(permission => - node.allowableOperations.includes(permission) - ); - } - return false; - } - - private reduceSeparators( + reduceSeparators( acc: ContentActionExtension[], el: ContentActionExtension, i: number, @@ -299,17 +255,19 @@ export class ExtensionService { ) { return acc; } - } - // remove trailing separator - if (i === arr.length - 1) { - if (el.type === ContentActionType.separator) { - return acc; + + // remove trailing separator + if (i === arr.length - 1) { + if (el.type === ContentActionType.separator) { + return acc; + } } } + return acc.concat(el); } - private reduceEmptyMenus( + reduceEmptyMenus( acc: ContentActionExtension[], el: ContentActionExtension ): ContentActionExtension[] { @@ -321,7 +279,7 @@ export class ExtensionService { return acc.concat(el); } - private sortByOrder( + sortByOrder( a: { order?: number | undefined }, b: { order?: number | undefined } ) { @@ -330,20 +288,20 @@ export class ExtensionService { return left - right; } - private filterEnabled(entry: { disabled?: boolean }): boolean { + filterEnabled(entry: { disabled?: boolean }): boolean { return !entry.disabled; } - private copyAction( - action: ContentActionExtension - ): ContentActionExtension { + copyAction(action: ContentActionExtension): ContentActionExtension { return { ...action, - children: (action.children || []).map(child => this.copyAction(child)) + children: (action.children || []).map(child => + this.copyAction(child) + ) }; } - private filterByTarget( + filterByTarget( nodes: MinimalNodeEntity[], action: ContentActionExtension ): boolean { @@ -358,9 +316,9 @@ export class ExtensionService { ); } - const types = action.target.types; + const types = action.target.types || []; - if (!types || types.length === 0) { + if (types.length === 0) { return true; } @@ -369,12 +327,14 @@ export class ExtensionService { if (type === 'folder') { return action.target.multiple ? nodes.some(node => node.entry.isFolder) - : nodes.every(node => node.entry.isFolder); + : nodes.length === 1 && + nodes.every(node => node.entry.isFolder); } if (type === 'file') { return action.target.multiple ? nodes.some(node => node.entry.isFile) - : nodes.every(node => node.entry.isFile); + : nodes.length === 1 && + nodes.every(node => node.entry.isFile); } return false; }); @@ -382,4 +342,67 @@ export class ExtensionService { return false; } + + filterByPermission( + nodes: MinimalNodeEntity[], + action: ContentActionExtension, + parentNode: Node + ): boolean { + if (!action) { + return false; + } + + if (!action.target) { + return ( + action.type === ContentActionType.separator || + action.type === ContentActionType.menu + ); + } + + const permissions = action.target.permissions || []; + + if (permissions.length === 0) { + return true; + } + + return permissions.some(permission => { + if (permission.startsWith('parent.')) { + if (parentNode) { + const parentQuery = permission.split('.')[1]; + return this.nodeHasPermission(parentNode, parentQuery); + } + return false; + } + + if (nodes && nodes.length > 0) { + return action.target.multiple + ? nodes.some(node => + this.nodeHasPermission(node.entry, permission) + ) + : nodes.length === 1 && + nodes.every(node => + this.nodeHasPermission(node.entry, permission) + ); + } + + return false; + }); + } + + nodeHasPermissions(node: Node, permissions: string[]): boolean { + if (node && permissions && permissions.length > 0) { + return permissions.some(permission => + this.nodeHasPermission(node, permission) + ); + } + return false; + } + + nodeHasPermission(node: Node, permission: string): boolean { + if (node && permission) { + const allowableOperations = node.allowableOperations || []; + return allowableOperations.includes(permission); + } + return false; + } }