diff --git a/.codacy.yml b/.codacy.yml index 6b0d239..17069ab 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -9,3 +9,6 @@ exclude_paths: - "spec/**/*" - ".github/**/*" - "babel.config.js" + - "jest.config.js" + - "jest.setup.js" + - "jest.logger.js" diff --git a/UnattendedMode.d.ts b/UnattendedMode.d.ts new file mode 100644 index 0000000..96adf3a --- /dev/null +++ b/UnattendedMode.d.ts @@ -0,0 +1,11 @@ +import { DataContext } from "./types"; +import {ConfigurationBase} from '@themost/common'; + +export declare interface ConfigurableApplication { + getConfiguration(): ConfigurationBase +} + +export declare function executeInUnattendedMode(context: DataContext, func: (callback: (err?: Error) => void) => void, callback: (err?: Error) => void): void; +export declare function executeInUnattendedModeAsync(context: DataContext, func: () => Promise): Promise; +export declare function enableUnattendedExecution(app: ConfigurableApplication, account?: string): void; +export declare function disableUnattendedExecution(app: ConfigurableApplication): void; diff --git a/UnattendedMode.js b/UnattendedMode.js new file mode 100644 index 0000000..462bee2 --- /dev/null +++ b/UnattendedMode.js @@ -0,0 +1,110 @@ +var unattendedMode = Symbol('unattendedMode'); +var {RandomUtils} = require('@themost/common'); + +/** + * Execute a callable function with elevated privileges in unattended mode. + * @param {import('./types').DataContext} context + * @param {function(function(Error?): void)} callable + * @param {function(Error?): void} callback + * @returns {void} + */ +function executeInUnattendedMode(context, callable, callback) { + if (typeof callable !== 'function') { + return callback(new Error('Unattended mode requires a callable function')); + } + // if the context is already in unattended mode + if (context[unattendedMode]) { + try { + // execute callable function + void callable(function (err) { + return callback(err); + }); + } catch (err) { + return callback(err); + } + } else { + var interactiveUser; + try { + const account = context.getConfiguration().getSourceAt('settings/auth/unattendedExecutionAccount'); + if (account == null) { + return callback(new Error('The unattended execution account is not defined. The operation cannot be completed.')); + } + // enter unattended mode + context[unattendedMode] = true; + // get interactive user + if (context.user) { + interactiveUser = Object.assign({}, context.user); + // set interactive user + context.interactiveUser = interactiveUser; + } + if (account) { + context.user = {name: account, authenticationType: 'Basic'}; + } + void callable(function (err) { + // restore user + if (interactiveUser) { + context.user = Object.assign({}, interactiveUser); + } + delete context.interactiveUser; + // exit unattended mode + delete context[unattendedMode]; + return callback(err); + }); + } catch (err) { + // restore user + if (interactiveUser) { + context.user = Object.assign({}, interactiveUser); + } + delete context.interactiveUser; + // exit unattended mode + delete context[unattendedMode]; + return callback(err); + } + } +} + +/** + * Execute a callable function with elevated privileges in unattended mode. + * @param {import('./types').DataContext} context + * @param {function(): Promise} callable + * @returns {Promise} + */ +function executeInUnattendedModeAsync(context, callable) { + return new Promise((resolve, reject) => { + void executeInUnattendedMode(context, function (cb) { + return callable().then(function () { + return cb(); + }).catch(function (err) { + return cb(err); + }); + }, function (err) { + if (err) { + return reject(err); + } + return resolve(); + }); + }); +} + +/** + * Enables unattended mode + * @param {{getConfiguration(): import('@themost/common').ConfigurationBase}} app + * @param {string=} executionAccount + */ +function enableUnattendedExecution(app, executionAccount) { + app.getConfiguration().setSourceAt('settings/auth/unattendedExecutionAccount', executionAccount || RandomUtils.randomChars(14)); +} +/** + * Disables unattended mode + * @param {{getConfiguration(): import('@themost/common').ConfigurationBase}} app + */ +function disableUnattendedExecution(app) { + app.getConfiguration().setSourceAt('settings/auth/unattendedExecutionAccount', null); +} + +module.exports = { + executeInUnattendedMode, + executeInUnattendedModeAsync, + enableUnattendedExecution, + disableUnattendedExecution +} diff --git a/index.d.ts b/index.d.ts index 8f49d06..b6ad3c5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -19,3 +19,4 @@ export * from './has-parent-junction'; export * from './data-listeners'; export * from './data-associations'; export * from './data-application'; +export * from './UnattendedMode'; diff --git a/index.js b/index.js index 4b3bf47..715733d 100644 --- a/index.js +++ b/index.js @@ -86,6 +86,8 @@ var { DataObjectAssociationListener, DataObjectMultiAssociationError } = require('./data-associations'); var { DataApplication } = require('./data-application'); +var { executeInUnattendedMode, executeInUnattendedModeAsync, enableUnattendedExecution, disableUnattendedExecution } = require('./UnattendedMode'); + module.exports = { TypeParser, PrivilegeType, @@ -165,6 +167,10 @@ module.exports = { EntitySetKind, ODataModelBuilder, ODataConventionModelBuilder, - EntitySetSchemaLoaderStrategy + EntitySetSchemaLoaderStrategy, + executeInUnattendedMode, + executeInUnattendedModeAsync, + enableUnattendedExecution, + disableUnattendedExecution }; diff --git a/jest.config.js b/jest.config.js index d58e4b4..f63c2ee 100644 --- a/jest.config.js +++ b/jest.config.js @@ -65,7 +65,6 @@ module.exports = { // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. maxWorkers: 1, - // An array of directory names to be searched recursively up from the requiring module's location // moduleDirectories: [ // "node_modules" @@ -80,14 +79,12 @@ module.exports = { // "json", // "node" // ], - // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - // moduleNameMapper: { - // '^@themost/query$': [ - // '/modules/query/src/index' - // ] - // }, - + moduleNameMapper: { + '^@themost/data$': [ + '/index' + ] + }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // modulePathIgnorePatterns: [], diff --git a/jest.setup.js b/jest.setup.js index 97c8c68..c999f93 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,5 +1,6 @@ const {TraceUtils} = require('@themost/common'); const JestLogger = require('./jest.logger'); +// noinspection JSCheckFunctionSignatures TraceUtils.useLogger(new JestLogger()); /* global jest */ jest.setTimeout(30000); diff --git a/package-lock.json b/package-lock.json index 4ee56df..f12c74a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@themost/data", - "version": "2.14.3", + "version": "2.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@themost/data", - "version": "2.14.3", + "version": "2.15.0", "license": "BSD-3-Clause", "dependencies": { "@themost/events": "^1.0.5", diff --git a/package.json b/package.json index 77ff0aa..f03dc5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@themost/data", - "version": "2.14.3", + "version": "2.15.0", "description": "MOST Web Framework Codename Blueshift - Data module", "main": "index.js", "scripts": { diff --git a/spec/DataModel.upsert.spec.ts b/spec/DataModel.upsert.spec.ts index bfb2559..d360d9a 100644 --- a/spec/DataModel.upsert.spec.ts +++ b/spec/DataModel.upsert.spec.ts @@ -12,7 +12,7 @@ describe('DataModel.upsert', () => { return done(); }); afterAll(async () => { - await context.finalize(); + await context.finalizeAsync(); await app.finalize(); }); it('should use upsert() to insert or update a single item', async () => { diff --git a/spec/TestApplication.ts b/spec/TestApplication.ts index d4f7441..8a55a2b 100644 --- a/spec/TestApplication.ts +++ b/spec/TestApplication.ts @@ -77,6 +77,9 @@ export class TestApplication extends IApplication { await service.finalize(); } } + async finalizeAsync(): Promise { + await this.finalize(); + } } diff --git a/spec/UnattendedMode.spec.ts b/spec/UnattendedMode.spec.ts new file mode 100644 index 0000000..27c99dc --- /dev/null +++ b/spec/UnattendedMode.spec.ts @@ -0,0 +1,99 @@ +import {TestApplication, TestApplication2} from './TestApplication'; +import { + DataContext, + disableUnattendedExecution, + enableUnattendedExecution, + executeInUnattendedModeAsync +} from '@themost/data'; + +describe('UnattendedMode', () => { + let app: TestApplication; + let context: DataContext; + beforeAll((done) => { + app = new TestApplication2(); + context = app.createContext(); + return done(); + }); + afterAll(async () => { + await context.finalizeAsync(); + await app.finalize(); + }); + it('should execute in unattended mode', async () => { + // set context user + context.user = { + name: 'angela.parry@example.com' + }; + const items = await context.model('Order').where( + (x) => x.orderedItem.name === 'Razer Blade (2013)' + ).getItems(); + expect(items.length).toBeFalsy(); + await executeInUnattendedModeAsync(context, async () => { + expect(context.interactiveUser).toBeTruthy(); + expect(context.interactiveUser.name).toEqual('angela.parry@example.com'); + const items = await context.model('Order').where( + (x) => x.orderedItem.name === 'Razer Blade (2013)' + ).getItems(); + expect(items.length).toBeTruthy(); + }); + expect(context.interactiveUser).toBeFalsy(); + }); + + it('should execute in unattended mode and get error', async () => { + // set context user + context.user = { + name: 'angela.parry@example.com' + }; + await expect(executeInUnattendedModeAsync(context, async () => { + throw new Error('Custom error'); + })).rejects.toThrow(); + expect(context.interactiveUser).toBeFalsy(); + expect(context.user.name).toEqual('angela.parry@example.com'); + }); + + it('should execute in unattended mode in series', async () => { + // set context user + context.user = { + name: 'angela.parry@example.com' + }; + await executeInUnattendedModeAsync(context, async () => { + expect(context.interactiveUser).toBeTruthy(); + const items = await context.model('Order').where( + (x) => x.orderedItem.name === 'Razer Blade (2013)' + ).getItems(); + expect(items.length).toBeTruthy(); + await executeInUnattendedModeAsync(context, async () => { + expect(context.interactiveUser).toBeTruthy(); + expect(context.interactiveUser.name).toEqual('angela.parry@example.com'); + const items = await context.model('Order').where( + (x) => x.orderedItem.name === 'Sony VAIO Flip 15' + ).getItems(); + expect(items.length).toBeTruthy(); + }); + }); + expect(context.interactiveUser).toBeFalsy(); + expect(context.user.name).toEqual('angela.parry@example.com'); + }); + + it('should disable unattended execution', async () => { + // set context user + context.user = { + name: 'angela.parry@example.com' + }; + disableUnattendedExecution(app); + expect(app.getConfiguration().getSourceAt('settings/auth/unattendedExecutionAccount')).toBeFalsy(); + await expect(executeInUnattendedModeAsync(context, async () => { + await context.model('Order').where( + (x) => x.orderedItem.name === 'Sony VAIO Flip 15' + ).getItems(); + })).rejects.toThrow('The unattended execution account is not defined. The operation cannot be completed.'); + expect(context.interactiveUser).toBeFalsy(); + enableUnattendedExecution(app); + expect(app.getConfiguration().getSourceAt('settings/auth/unattendedExecutionAccount')).toBeTruthy(); + await executeInUnattendedModeAsync(context, async () => { + const items = await context.model('Order').where( + (x) => x.orderedItem.name === 'Razer Blade (2013)' + ).getItems(); + expect(items.length).toBeTruthy(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index df486f2..8084348 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,9 +18,12 @@ "dom" ], "paths": { + "@themost/data": [ + "./index" + ] } }, "exclude": [ "node_modules" ] -} \ No newline at end of file +}