From 75d277040d7f25bb3da142dd73fa35cf1b5ee457 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Mon, 13 Nov 2023 16:51:25 +0100 Subject: [PATCH] Add basic configuring options #753 --- src/backend/middlewares/RenderingMWs.ts | 3 +- src/backend/middlewares/admin/SettingsMWs.ts | 5 +- .../model/extension/ExtensionConfigWrapper.ts | 50 +++++++++ .../model/extension/ExtensionManager.ts | 30 ++---- .../model/extension/ExtensionObject.ts | 31 ++++++ src/backend/model/extension/IExtension.ts | 33 ++++-- src/common/config/private/Config.ts | 20 +--- src/common/config/private/PrivateConfig.ts | 4 +- .../settings-entry.component.ts | 100 ++++++++++-------- .../routers/admin/SettingsRouter.ts | 3 +- .../unit/middlewares/admin/SettingsMWs.ts | 3 +- 11 files changed, 185 insertions(+), 97 deletions(-) create mode 100644 src/backend/model/extension/ExtensionConfigWrapper.ts create mode 100644 src/backend/model/extension/ExtensionObject.ts diff --git a/src/backend/middlewares/RenderingMWs.ts b/src/backend/middlewares/RenderingMWs.ts index c73af26e..404c1814 100644 --- a/src/backend/middlewares/RenderingMWs.ts +++ b/src/backend/middlewares/RenderingMWs.ts @@ -9,6 +9,7 @@ import {SharingDTO} from '../../common/entities/SharingDTO'; import {Utils} from '../../common/Utils'; import {LoggerRouter} from '../routes/LoggerRouter'; import {TAGS} from '../../common/config/public/ClientConfig'; +import {ExtensionConfigWrapper} from '../model/extension/ExtensionConfigWrapper'; const forcedDebug = process.env['NODE_ENV'] === 'debug'; @@ -107,7 +108,7 @@ export class RenderingMWs { req: Request, res: Response ): Promise { - const originalConf = await Config.original(); + const originalConf = await ExtensionConfigWrapper.original(); // These are sensitive information, do not send to the client side originalConf.Server.sessionSecret = null; const message = new Message( diff --git a/src/backend/middlewares/admin/SettingsMWs.ts b/src/backend/middlewares/admin/SettingsMWs.ts index fe688644..6f745e03 100644 --- a/src/backend/middlewares/admin/SettingsMWs.ts +++ b/src/backend/middlewares/admin/SettingsMWs.ts @@ -6,6 +6,7 @@ import {ConfigDiagnostics} from '../../model/diagnostics/ConfigDiagnostics'; import {ConfigClassBuilder} from '../../../../node_modules/typeconfig/node'; import {TAGS} from '../../../common/config/public/ClientConfig'; import {ObjectManagers} from '../../model/ObjectManagers'; +import {ExtensionConfigWrapper} from '../../model/extension/ExtensionConfigWrapper'; const LOG_TAG = '[SettingsMWs]'; @@ -28,7 +29,7 @@ export class SettingsMWs { try { let settings = req.body.settings; // Top level settings JSON const settingsPath: string = req.body.settingsPath; // Name of the top level settings - const transformer = await Config.original(); + const transformer = await ExtensionConfigWrapper.original(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore transformer[settingsPath] = settings; @@ -37,7 +38,7 @@ export class SettingsMWs { settings = ConfigClassBuilder.attachPrivateInterface(transformer[settingsPath]).toJSON({ skipTags: {secret: true} as TAGS }); - const original = await Config.original(); + const original = await ExtensionConfigWrapper.original(); // only updating explicitly set config (not saving config set by the diagnostics) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts new file mode 100644 index 00000000..59246c48 --- /dev/null +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -0,0 +1,50 @@ +import {IConfigClass} from '../../../../node_modules/typeconfig/common'; +import {Config, PrivateConfigClass} from '../../../common/config/private/Config'; +import {ConfigClassBuilder} from '../../../../node_modules/typeconfig/node'; +import {IExtensionConfig} from './IExtension'; +import {Utils} from '../../../common/Utils'; +import {ObjectManagers} from '../ObjectManagers'; + +/** + * Wraps to original config and makes sure all extension related config is loaded + */ +export class ExtensionConfigWrapper { + static async original(): Promise { + const pc = ConfigClassBuilder.attachPrivateInterface(new PrivateConfigClass()); + try { + await pc.load(); + for (const ext of Object.values(ObjectManagers.getInstance().ExtensionManager.extObjects)) { + ext.config.loadToConfig(ConfigClassBuilder.attachPrivateInterface(pc)); + } + } catch (e) { + console.error('Error during loading original config. Reverting to defaults.'); + console.error(e); + } + return pc; + } +} + +export class ExtensionConfig implements IExtensionConfig { + public template: new() => C; + + constructor(private readonly extensionId: string) { + } + + public getConfig(): C { + return Config.Extensions.configs[this.extensionId] as C; + } + + public setTemplate(template: new() => C): void { + this.template = template; + this.loadToConfig(Config); + } + + loadToConfig(config: PrivateConfigClass) { + if (!this.template) { + return; + } + const conf = ConfigClassBuilder.attachPrivateInterface(new this.template()); + conf.__loadJSONObject(Utils.clone(config.Extensions.configs[this.extensionId] || {})); + config.Extensions.configs[this.extensionId] = conf; + } +} diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 2266fcdb..3bef863b 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -3,15 +3,13 @@ import {Config} from '../../../common/config/private/Config'; import * as fs from 'fs'; import * as path from 'path'; import {IObjectManager} from '../database/IObjectManager'; -import {createLoggerWrapper, Logger} from '../../Logger'; +import {Logger} from '../../Logger'; import {IExtensionEvents, IExtensionObject, IServerExtension} from './IExtension'; import {Server} from '../../server'; import {ExtensionEvent} from './ExtensionEvent'; -import {ExpressRouterWrapper} from './ExpressRouterWrapper'; import * as express from 'express'; -import {ExtensionApp} from './ExtensionApp'; -import {ExtensionDB} from './ExtensionDB'; import {SQLConnection} from '../database/SQLConnection'; +import {ExtensionObject} from './ExtensionObject'; const LOG_TAG = '[ExtensionManager]'; @@ -20,7 +18,7 @@ export class ExtensionManager implements IObjectManager { public static EXTENSION_API_PATH = Config.Server.apiPath + '/extension'; events: IExtensionEvents; - extObjects: { [key: string]: IExtensionObject } = {}; + extObjects: { [key: string]: ExtensionObject } = {}; router: express.Router; constructor() { @@ -65,15 +63,15 @@ export class ExtensionManager implements IObjectManager { } Config.Extensions.list = fs - .readdirSync(ProjectPath.ExtensionFolder) - .filter((f): boolean => - fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() - ); + .readdirSync(ProjectPath.ExtensionFolder) + .filter((f): boolean => + fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() + ); Config.Extensions.list.sort(); Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.list)); } - private async callServerFN(fn: (ext: IServerExtension, extName: string) => Promise) { + private async callServerFN(fn: (ext: IServerExtension, extName: string) => Promise) { for (let i = 0; i < Config.Extensions.list.length; ++i) { const extName = Config.Extensions.list[i]; const extPath = path.join(ProjectPath.ExtensionFolder, extName); @@ -88,17 +86,9 @@ export class ExtensionManager implements IObjectManager { } } - private createExtensionObject(name: string): IExtensionObject { + private createExtensionObject(name: string): IExtensionObject { if (!this.extObjects[name]) { - const logger = createLoggerWrapper(`[Extension][${name}]`); - this.extObjects[name] = { - _app: new ExtensionApp(), - db: new ExtensionDB(logger), - paths: ProjectPath, - Logger: logger, - events: this.events, - RESTApi: new ExpressRouterWrapper(this.router, name, logger) - }; + this.extObjects[name] = new ExtensionObject(name, this.router, this.events); } return this.extObjects[name]; } diff --git a/src/backend/model/extension/ExtensionObject.ts b/src/backend/model/extension/ExtensionObject.ts new file mode 100644 index 00000000..f21e28ae --- /dev/null +++ b/src/backend/model/extension/ExtensionObject.ts @@ -0,0 +1,31 @@ +import {IExtensionEvents, IExtensionObject} from './IExtension'; +import {ExtensionApp} from './ExtensionApp'; +import {ExtensionConfig} from './ExtensionConfigWrapper'; +import {ExtensionDB} from './ExtensionDB'; +import {ProjectPath} from '../../ProjectPath'; +import {ExpressRouterWrapper} from './ExpressRouterWrapper'; +import {createLoggerWrapper} from '../../Logger'; +import * as express from 'express'; + +export class ExtensionObject implements IExtensionObject { + + public readonly _app; + public readonly config; + public readonly db; + public readonly paths; + public readonly Logger; + public readonly events; + public readonly RESTApi; + + constructor(public readonly extensionId: string, extensionRouter: express.Router, events: IExtensionEvents) { + const logger = createLoggerWrapper(`[Extension][${extensionId}]`); + this._app = new ExtensionApp(); + this.config = new ExtensionConfig(extensionId); + this.db = new ExtensionDB(logger); + this.paths = ProjectPath; + this.Logger = logger; + this.events = events; + this.RESTApi = new ExpressRouterWrapper(extensionRouter, extensionId, logger); + } + +} diff --git a/src/backend/model/extension/IExtension.ts b/src/backend/model/extension/IExtension.ts index b80d5d02..3375caaf 100644 --- a/src/backend/model/extension/IExtension.ts +++ b/src/backend/model/extension/IExtension.ts @@ -6,7 +6,7 @@ import {ProjectPathClass} from '../../ProjectPath'; import {ILogger} from '../../Logger'; import {UserDTO, UserRoles} from '../../../common/entities/UserDTO'; import {ParamsDictionary} from 'express-serve-static-core'; -import {Connection, EntitySchema} from 'typeorm'; +import {Connection} from 'typeorm'; export type IExtensionBeforeEventHandler = (input: { inputs: I }, event: { stopPropagation: boolean }) => Promise<{ inputs: I } | O>; @@ -66,17 +66,17 @@ export interface IExtensionApp { export interface IExtensionRESTRoute { /** * Sends a pigallery2 standard JSON object with payload or error message back to the client. - * @param paths - * @param minRole - * @param cb + * @param paths RESTapi path, relative to the extension base endpoint + * @param minRole set to null to omit auer check (ie make the endpoint public) + * @param cb function callback */ jsonResponse(paths: string[], minRole: UserRoles, cb: (params?: ParamsDictionary, body?: any, user?: UserDTO) => Promise | unknown): void; /** * Exposes a standard expressjs middleware - * @param paths - * @param minRole - * @param mw + * @param paths RESTapi path, relative to the extension base endpoint + * @param minRole set to null to omit auer check (ie make the endpoint public) + * @param mw expressjs middleware */ rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise): void; } @@ -110,13 +110,24 @@ export interface IExtensionDB { _getAllTables(): Function[]; } -export interface IExtensionObject { +export interface IExtensionConfig { + setTemplate(template: new() => C): void; + + getConfig(): C; +} + +export interface IExtensionObject { /** * Inner functionality of the app. Use this with caution. * If you want to go deeper than the standard exposed APIs, you can try doing so here. */ _app: IExtensionApp; + /** + * Create extension related configuration + */ + config: IExtensionConfig; + /** * Create new SQL tables and access SQL connection */ @@ -144,12 +155,12 @@ export interface IExtensionObject { /** * Extension interface. All extension is expected to implement and export these methods */ -export interface IServerExtension { +export interface IServerExtension { /** * Extension init function. Extension should at minimum expose this function. * @param extension */ - init(extension: IExtensionObject): Promise; + init(extension: IExtensionObject): Promise; - cleanUp?: (extension: IExtensionObject) => Promise; + cleanUp?: (extension: IExtensionObject) => Promise; } diff --git a/src/common/config/private/Config.ts b/src/common/config/private/Config.ts index 2cd38a1b..d12e5b41 100644 --- a/src/common/config/private/Config.ts +++ b/src/common/config/private/Config.ts @@ -13,7 +13,7 @@ const upTime = new Date().toISOString(); // TODO: Refactor Config to be injectable globally. // This is a bad habit to let the Config know if its in a testing env. const isTesting = process.env['NODE_ENV'] == true || ['afterEach', 'after', 'beforeEach', 'before', 'describe', 'it'] - .every((fn) => (global as any)[fn] instanceof Function); + .every((fn) => (global as any)[fn] instanceof Function); @ConfigClass & ServerConfig>({ configPath: path.join(__dirname, !isTesting ? './../../../../config.json' : './../../../../test/backend/tmp/config.json'), @@ -76,30 +76,20 @@ export class PrivateConfigClass extends ServerConfig { } this.Environment.appVersion = - require('../../../../package.json').version; + require('../../../../package.json').version; this.Environment.buildTime = - require('../../../../package.json').buildTime; + require('../../../../package.json').buildTime; this.Environment.buildCommitHash = - require('../../../../package.json').buildCommitHash; + require('../../../../package.json').buildCommitHash; this.Environment.upTime = upTime; this.Environment.isDocker = !!process.env.PI_DOCKER; } - async original(): Promise { - const pc = ConfigClassBuilder.attachPrivateInterface(new PrivateConfigClass()); - try { - await pc.load(); - } catch (e) { - console.error('Error during loading original config. Reverting to defaults.'); - console.error(e); - } - return pc; - } } export const Config = ConfigClassBuilder.attachInterface( - new PrivateConfigClass() + new PrivateConfigClass() ); try { Config.loadSync(); diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index 1eafe4d6..8c952526 100644 --- a/src/common/config/private/PrivateConfig.ts +++ b/src/common/config/private/PrivateConfig.ts @@ -1014,12 +1014,14 @@ export class ServerServiceConfig extends ClientServiceConfig { } - @SubConfigClass({softReadonly: true}) export class ServerExtensionsConfig { @ConfigProperty({volatile: true}) list: string[] = []; + @ConfigProperty({type: 'object'}) + configs: Record = {}; + @ConfigProperty({ tags: { name: $localize`Clean up unused tables`, diff --git a/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts b/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts index cdbc0dc6..5f2d2b4f 100644 --- a/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts +++ b/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts @@ -59,7 +59,7 @@ interface IState { ], }) export class SettingsEntryComponent - implements ControlValueAccessor, Validator, OnChanges { + implements ControlValueAccessor, Validator, OnChanges { name: string; required: boolean; dockerWarning: boolean; @@ -79,7 +79,10 @@ export class SettingsEntryComponent public arrayType: string; public uiType: string; newThemeModalRef: any; - iconModal: { ref?: any, error?: string }; + iconModal: { + ref?: any, + error?: string + }; @Input() noChangeDetection = false; public readonly ConfigStyle = ConfigStyle; protected readonly SortByTypes = SortByTypes; @@ -101,9 +104,9 @@ export class SettingsEntryComponent for (let i = 0; i < this.state.value?.length; ++i) { for (const k of Object.keys(this.state.value[i].__state)) { if (!Utils.equalsFilter( - this.state.value[i]?.__state[k]?.value, - this.state.default[i] ? this.state.default[i][k] : undefined, - ['default', '__propPath', '__created', '__prototype', '__rootConfig'])) { + this.state.value[i]?.__state[k]?.value, + this.state.default[i] ? this.state.default[i][k] : undefined, + ['default', '__propPath', '__created', '__prototype', '__rootConfig'])) { return true; } @@ -129,7 +132,7 @@ export class SettingsEntryComponent get defaultStr(): string { if (this.type === 'SearchQuery') { return ( - '\'' + this.searchQueryParserService.stringify(this.state.default) + '\'' + '\'' + this.searchQueryParserService.stringify(this.state.default) + '\'' ); } @@ -143,8 +146,8 @@ export class SettingsEntryComponent get StringValue(): string { if ( - this.state.type === 'array' && - (this.state.arrayType === 'string' || this.isNumberArray) + this.state.type === 'array' && + (this.state.arrayType === 'string' || this.isNumberArray) ) { return (this.state.value || []).join(';'); } @@ -162,8 +165,8 @@ export class SettingsEntryComponent set StringValue(value: string) { if ( - this.state.type === 'array' && - (this.state.arrayType === 'string' || this.isNumberArray) + this.state.type === 'array' && + (this.state.arrayType === 'string' || this.isNumberArray) ) { value = value.replace(new RegExp(',', 'g'), ';'); if (!this.allowSpaces) { @@ -172,14 +175,14 @@ export class SettingsEntryComponent this.state.value = value.split(';').filter((v: string) => v !== ''); if (this.isNumberArray) { this.state.value = this.state.value - .map((v: string) => parseFloat(v)) - .filter((v: number) => !isNaN(v)); + .map((v: string) => parseFloat(v)) + .filter((v: number) => !isNaN(v)); } return; } - if (typeof this.state.value === 'object') { this.state.value = JSON.parse(value); + return; } this.state.value = value; @@ -194,11 +197,13 @@ export class SettingsEntryComponent key: 'default', value: $localize`default` }, ...(this.state.rootConfig as any).__state.availableThemes.value - .map((th: ThemeConfig) => ({key: th.name, value: th.name}))]; + .map((th: ThemeConfig) => ({key: th.name, value: th.name}))]; } - get SelectedThemeSettings(): { theme: string } { + get SelectedThemeSettings(): { + theme: string + } { return (this.state.value as ThemeConfig[]).find(th => th.name === (this.state.rootConfig as any).__state.selectedTheme.value) || {theme: 'N/A'}; } @@ -241,16 +246,16 @@ export class SettingsEntryComponent this.uiType = CustomSettingsEntries.getFullName(this.state); } if (!this.state.isEnumType && - !this.state.isEnumArrayType && - this.type !== 'boolean' && - this.type !== 'SearchQuery' && - !CustomSettingsEntries.iS(this.state) && - this.arrayType !== 'MapLayers' && - this.arrayType !== 'NavigationLinkConfig' && - this.arrayType !== 'MapPathGroupConfig' && - this.arrayType !== 'MapPathGroupThemeConfig' && - this.arrayType !== 'JobScheduleConfig' && - this.arrayType !== 'UserConfig') { + !this.state.isEnumArrayType && + this.type !== 'boolean' && + this.type !== 'SearchQuery' && + !CustomSettingsEntries.iS(this.state) && + this.arrayType !== 'MapLayers' && + this.arrayType !== 'NavigationLinkConfig' && + this.arrayType !== 'MapPathGroupConfig' && + this.arrayType !== 'MapPathGroupThemeConfig' && + this.arrayType !== 'JobScheduleConfig' && + this.arrayType !== 'UserConfig') { this.uiType = 'StringInput'; } if (this.type === this.state.tags?.uiType) { @@ -273,18 +278,18 @@ export class SettingsEntryComponent this.name = this.state?.tags?.name; if (this.name) { this.idName = - this.GUID + this.name.toLowerCase().replace(new RegExp(' ', 'gm'), '-'); + this.GUID + this.name.toLowerCase().replace(new RegExp(' ', 'gm'), '-'); } this.isNumberArray = - this.state.arrayType === 'unsignedInt' || - this.state.arrayType === 'integer' || - this.state.arrayType === 'float' || - this.state.arrayType === 'positiveFloat'; + this.state.arrayType === 'unsignedInt' || + this.state.arrayType === 'integer' || + this.state.arrayType === 'float' || + this.state.arrayType === 'positiveFloat'; this.isNumber = - this.state.type === 'unsignedInt' || - this.state.type === 'integer' || - this.state.type === 'float' || - this.state.type === 'positiveFloat'; + this.state.type === 'unsignedInt' || + this.state.type === 'integer' || + this.state.type === 'float' || + this.state.type === 'positiveFloat'; if (this.isNumber) { @@ -306,11 +311,16 @@ export class SettingsEntryComponent } } - getOptionsView(state: IState & { optionsView?: { key: number | string; value: string | number }[] }) { + getOptionsView(state: IState & { + optionsView?: { + key: number | string; + value: string | number + }[] + }) { if (!state.optionsView) { const eClass = state.isEnumType - ? state.type - : state.arrayType; + ? state.type + : state.arrayType; if (state.tags?.uiOptions) { state.optionsView = state.tags?.uiOptions.map(o => ({ key: o, @@ -325,11 +335,11 @@ export class SettingsEntryComponent validate(): ValidationErrors { if ( - !this.required || - (this.state && - typeof this.state.value !== 'undefined' && - this.state.value !== null && - this.state.value !== '') + !this.required || + (this.state && + typeof this.state.value !== 'undefined' && + this.state.value !== null && + this.state.value !== '') ) { return null; } @@ -386,8 +396,8 @@ export class SettingsEntryComponent removeLayer(layer: MapLayers): void { this.state.value.splice( - this.state.value.indexOf(layer), - 1 + this.state.value.indexOf(layer), + 1 ); } @@ -395,7 +405,7 @@ export class SettingsEntryComponent addNewTheme(): void { const availableThemes = (this.state.rootConfig as any).__state.availableThemes; if (!this.newThemeName || - (availableThemes.value as ThemeConfig[]).find(th => th.name === this.newThemeName)) { + (availableThemes.value as ThemeConfig[]).find(th => th.name === this.newThemeName)) { return; } this.state.value = this.newThemeName; diff --git a/test/backend/integration/routers/admin/SettingsRouter.ts b/test/backend/integration/routers/admin/SettingsRouter.ts index 5aa90736..cdc98838 100644 --- a/test/backend/integration/routers/admin/SettingsRouter.ts +++ b/test/backend/integration/routers/admin/SettingsRouter.ts @@ -7,6 +7,7 @@ import {ProjectPath} from '../../../../../src/backend/ProjectPath'; import {TAGS} from '../../../../../src/common/config/public/ClientConfig'; import {ObjectManagers} from '../../../../../src/backend/model/ObjectManagers'; import {UserRoles} from '../../../../../src/common/entities/UserDTO'; +import {ExtensionConfigWrapper} from '../../../../../src/backend/model/extension/ExtensionConfigWrapper'; process.env.NODE_ENV = 'test'; const chai: any = require('chai'); @@ -34,7 +35,7 @@ describe('SettingsRouter', () => { it('it should GET the settings', async () => { Config.Users.authenticationRequired = false; Config.Users.unAuthenticatedUserRole = UserRoles.Admin; - const originalSettings = await Config.original(); + const originalSettings = await ExtensionConfigWrapper.original(); const srv = new Server(); await srv.onStarted.wait(); const result = await chai.request(srv.App) diff --git a/test/backend/unit/middlewares/admin/SettingsMWs.ts b/test/backend/unit/middlewares/admin/SettingsMWs.ts index b8a49a4f..ef85113d 100644 --- a/test/backend/unit/middlewares/admin/SettingsMWs.ts +++ b/test/backend/unit/middlewares/admin/SettingsMWs.ts @@ -9,6 +9,7 @@ import {UserRoles} from '../../../../../src/common/entities/UserDTO'; import {ConfigClassBuilder} from '../../../../../node_modules/typeconfig/node'; import * as fs from 'fs'; import * as path from 'path'; +import {ExtensionConfigWrapper} from '../../../../../src/backend/model/extension/ExtensionConfigWrapper'; declare const describe: any; @@ -74,7 +75,7 @@ describe('Settings middleware', () => { expect(Config.Users.enforcedUsers.length).to.be.equal(1); expect(Config.Users.enforcedUsers[0].name).to.be.equal('Apple'); expect(Config.Users.enforcedUsers.length).to.be.equal(1); - Config.original().then((cfg) => { + ExtensionConfigWrapper.original().then((cfg) => { try { expect(cfg.Users.enforcedUsers.length).to.be.equal(1); expect(cfg.Users.enforcedUsers[0].name).to.be.equal('Apple');