diff --git a/src/m365/spo/commands/user/user-ensure.spec.ts b/src/m365/spo/commands/user/user-ensure.spec.ts index a5e9a8d5f7c..2a8bb9e2a86 100644 --- a/src/m365/spo/commands/user/user-ensure.spec.ts +++ b/src/m365/spo/commands/user/user-ensure.spec.ts @@ -12,7 +12,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-ensure.js'; +import command, { options } from './user-ensure.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; describe(commands.USER_ENSURE, () => { @@ -142,6 +142,7 @@ describe(commands.USER_ENSURE, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -150,6 +151,7 @@ describe(commands.USER_ENSURE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -199,7 +201,7 @@ describe(commands.USER_ENSURE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, userName: validUserName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, userName: validUserName }) }); assert(loggerLogSpy.calledWith(ensuredUserResponse)); }); @@ -216,7 +218,7 @@ describe(commands.USER_ENSURE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraId: validEntraId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, entraId: validEntraId }) }); assert(loggerLogSpy.calledWith(ensuredUserResponse)); }); @@ -229,7 +231,7 @@ describe(commands.USER_ENSURE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, loginName: validLoginName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, loginName: validLoginName }) }); assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'i:0#.f|membership|john@contoso.com' }); }); @@ -244,7 +246,7 @@ describe(commands.USER_ENSURE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraGroupId: validEntraGroupId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, entraGroupId: validEntraGroupId }) }); assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'c:0o.c|federateddirectoryclaimprovider|2056d2f6-3257-4253-8cfc-b73393e414e5' }); }); @@ -259,7 +261,7 @@ describe(commands.USER_ENSURE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraGroupName: validEntraSecurityGroupName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, entraGroupName: validEntraSecurityGroupName }) }); assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'c:0t.c|tenant|2056d2f6-3257-4253-8cfc-b73393e414e5' }); }); @@ -274,7 +276,7 @@ describe(commands.USER_ENSURE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraGroupName: validEntraGroupName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, entraGroupName: validEntraGroupName }) }); assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'c:0o.c|federateddirectoryclaimprovider|2056d2f6-3257-4253-8cfc-b73393e414e5' }); }); @@ -295,7 +297,7 @@ describe(commands.USER_ENSURE, () => { }; }); - await assert.rejects(command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraId: validEntraId } }), new CommandError(`Resource '${validEntraId}' does not exist or one of its queried reference-property objects are not present.`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, entraId: validEntraId }) }), new CommandError(`Resource '${validEntraId}' does not exist or one of its queried reference-property objects are not present.`)); }); it('throws error message when no user was found with a specific user name', async () => { @@ -319,51 +321,66 @@ describe(commands.USER_ENSURE, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { verbose: true, webUrl: validWebUrl, userName: validUserName } }), new CommandError(error.error['odata.error'].message.value)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, userName: validUserName }) }), new CommandError(error.error['odata.error'].message.value)); }); - it('fails validation if webUrl is not a valid url', async () => { - const actual = await command.validate({ options: { webUrl: 'invalid', entraId: validEntraId } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if webUrl is not a valid url', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', entraId: validEntraId }); + assert.strictEqual(actual.success, false); }); - it('fails validation if entraId is not a valid id', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if entraId is not a valid id', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraId: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if userName is not a valid user principal name', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, userName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userName is not a valid user principal name', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if entraGroupId is not a valid id', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if entraGroupId is not a valid id', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupId: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the url is valid and entraId is a valid id', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraId: validEntraId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation without a user selector', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the url is valid and userName is a valid user principal name', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, userName: validUserName } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with multiple user selectors', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: validUserName, loginName: validLoginName }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the url is valid and loginName is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, loginName: validLoginName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and entraId is a valid id', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraId: validEntraId }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and entraGroupName is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupName: validEntraGroupName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and userName is a valid user principal name', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: validUserName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and entraGroupId is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: validEntraGroupId } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and loginName is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, loginName: validLoginName }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if the url is valid and entraGroupName is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupName: validEntraGroupName }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if the url is valid and entraGroupId is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupId: validEntraGroupId }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: validUserName, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/user/user-ensure.ts b/src/m365/spo/commands/user/user-ensure.ts index 8e45cf9e426..e96ee488dbe 100644 --- a/src/m365/spo/commands/user/user-ensure.ts +++ b/src/m365/spo/commands/user/user-ensure.ts @@ -1,5 +1,5 @@ import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { Group } from '@microsoft/microsoft-graph-types'; @@ -7,20 +7,26 @@ import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { entraUser } from '../../../../utils/entraUser.js'; +import { z } from 'zod'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(webUrl => validation.isValidSharePointUrl(webUrl) === true, { + error: e => validation.isValidSharePointUrl(e.input as string).toString() + }).alias('u'), + entraId: z.string().refine(id => validation.isValidGuid(id), { error: e => `${e.input} is not a valid GUID.` }).optional(), + userName: z.string().refine(userName => validation.isValidUserPrincipalName(userName), { error: e => `${e.input} is not a valid userName.` }).optional(), + loginName: z.string().optional(), + entraGroupId: z.string().refine(id => validation.isValidGuid(id), { error: e => `${e.input} is not a valid GUID for option 'entraGroupId'.` }).optional(), + entraGroupName: z.string().optional() +}); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - entraId?: string; - userName?: string; - loginName?: string; - entraGroupId?: string; - entraGroupName?: string; -} - class SpoUserEnsureCommand extends SpoCommand { public get name(): string { return commands.USER_ENSURE; @@ -30,82 +36,18 @@ class SpoUserEnsureCommand extends SpoCommand { return 'Ensures that a user is available on a specific site'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - entraId: typeof args.options.entraId !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - loginName: typeof args.options.loginName !== 'undefined', - entraGroupId: typeof args.options.entraGroupId !== 'undefined', - entraGroupName: typeof args.options.entraGroupName !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '--entraId [entraId]' - }, - { - option: '--userName [userName]' - }, - { - option: '--loginName [loginName]' - }, - { - option: '--entraGroupId [entraGroupId]' - }, - { - option: '--entraGroupName [entraGroupName]' - } - ); + public get schema(): z.ZodType { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - if (args.options.entraId && !validation.isValidGuid(args.options.entraId)) { - return `${args.options.entraId} is not a valid GUID.`; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid userName.`; - } - - if (args.options.entraGroupId && !validation.isValidGuid(args.options.entraGroupId)) { - return `${args.options.entraGroupId} is not a valid GUID for option 'entraGroupId'.`; - } - - return true; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema.refine(opts => [opts.entraId, opts.userName, opts.loginName, opts.entraGroupId, opts.entraGroupName].filter(value => value !== undefined).length === 1, { + error: 'Specify one of the following options: entraId, userName, loginName, entraGroupId, entraGroupName.', + params: { + customCode: 'optionSet', + options: ['entraId', 'userName', 'loginName', 'entraGroupId', 'entraGroupName'] } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['entraId', 'userName', 'loginName', 'entraGroupId', 'entraGroupName'] }); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'entraId', 'userName', 'loginName', 'entraGroupId', 'entraGroupName'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/user/user-get.spec.ts b/src/m365/spo/commands/user/user-get.spec.ts index 345c4a3974c..e70b76ed37b 100644 --- a/src/m365/spo/commands/user/user-get.spec.ts +++ b/src/m365/spo/commands/user/user-get.spec.ts @@ -11,8 +11,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-get.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './user-get.js'; import { formatting } from '../../../../utils/formatting.js'; describe(commands.USER_GET, () => { @@ -109,6 +108,7 @@ describe(commands.USER_GET, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -117,6 +117,7 @@ describe(commands.USER_GET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -165,12 +166,12 @@ describe(commands.USER_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: validWebUrl, id: 10 - } + }) }); assert(loggerLogSpy.calledWith(userResponse)); @@ -186,12 +187,12 @@ describe(commands.USER_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: validWebUrl, email: validEmail - } + }) }); assert(loggerLogSpy.calledWith(userResponse)); @@ -207,12 +208,12 @@ describe(commands.USER_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: validWebUrl, loginName: validLoginName - } + }) }); assert(loggerLogSpy.calledWith(userResponse)); @@ -234,12 +235,12 @@ describe(commands.USER_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: validWebUrl, userName: validUserName - } + }) }); assert(loggerLogSpy.calledWith(userResponse)); @@ -272,12 +273,12 @@ describe(commands.USER_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: validWebUrl, entraGroupId: validEntraGroupId - } + }) }); assert(loggerLogSpy.calledWith({ @@ -323,12 +324,12 @@ describe(commands.USER_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', webUrl: validWebUrl, entraGroupName: validEntraSecurityGroupName - } - } as any); + }) + }); assert(loggerLogSpy.calledWith({ "Id": 31, @@ -369,9 +370,9 @@ describe(commands.USER_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com' - } + }) }); assert(loggerLogSpy.calledWith({ @@ -401,11 +402,11 @@ describe(commands.USER_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: validWebUrl, userName: validUserName - } + }) }), new CommandError(err)); }); @@ -415,99 +416,90 @@ describe(commands.USER_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com', loginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" - } - } as any), new CommandError('An error has occurred')); + }) + }), new CommandError('An error has occurred')); }); - it('supports specifying URL', () => { - const options = command.options; - let containsTypeOption = false; - options.forEach(o => { - if (o.option.indexOf('') > -1) { - containsTypeOption = true; - } - }); - assert(containsTypeOption); + it('fails validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', id: 1 }); + assert.strictEqual(actual.success, false); }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', id: 1 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if entraGroupId is not a valid id', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupId: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if entraGroupId is not a valid id', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is not a valid number', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, id: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if id is not a valid number', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, id: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userName is not a valid user principal name', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if id is a negative number', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, id: -1 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if email is not a valid user principal name', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, email: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if id is a float number', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, id: 1.5 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is a negative number', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, id: -1 }); + assert.strictEqual(actual.success, false); }); - it('fails validation if userName is not a valid user principal name', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, userName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is a float number', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, id: 1.5 }); + assert.strictEqual(actual.success, false); }); - it('fails validation if email is not a valid user principal name', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, email: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id, email, loginName, userName, entraGroupId, and entraGroupName options are passed (multiple options)', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, id: 1, email: validEmail, loginName: validLoginName, userName: validUserName, entraGroupId: validEntraGroupId, entraGroupName: validEntraGroupName }); + assert.strictEqual(actual.success, false); }); - it('fails validation if id, email, loginName, userName, entraGroupId, and entraGroupName options are passed (multiple options)', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); + it('passes validation url is valid and id is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, id: 1 }); + assert.strictEqual(actual.success, true); + }); - const actual = await command.validate({ options: { webUrl: validWebUrl, id: 1, email: validEmail, loginName: validLoginName, userName: validUserName, entraGroupId: validEntraGroupId, entraGroupName: validEntraGroupName } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if the url is valid and email is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, email: validEmail }); + assert.strictEqual(actual.success, true); }); - it('passes validation url is valid and id is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, id: 1 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and loginName is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, loginName: validLoginName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and email is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, email: validEmail } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and userName is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: validUserName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and loginName is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, loginName: validLoginName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and entraGroupName is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupName: validEntraGroupName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and userName is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, userName: validUserName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and entraGroupId is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupId: validEntraGroupId }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and entraGroupName is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupName: validEntraGroupName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation without a user selector', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and entraGroupId is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: validEntraGroupId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/user/user-get.ts b/src/m365/spo/commands/user/user-get.ts index 6a20c6176ab..4b503c1fcc3 100644 --- a/src/m365/spo/commands/user/user-get.ts +++ b/src/m365/spo/commands/user/user-get.ts @@ -1,5 +1,5 @@ import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { Group } from '@microsoft/microsoft-graph-types'; import { entraGroup } from '../../../../utils/entraGroup.js'; @@ -7,6 +7,22 @@ import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +import { z } from 'zod'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(webUrl => validation.isValidSharePointUrl(webUrl) === true, { + error: e => validation.isValidSharePointUrl(e.input as string).toString() + }).alias('u'), + id: z.number().int().positive().optional().alias('i'), + email: z.string().refine(email => validation.isValidUserPrincipalName(email), { error: e => `${e.input} is not a valid email.` }).optional(), + loginName: z.string().optional(), + userName: z.string().refine(userName => validation.isValidUserPrincipalName(userName), { error: e => `${e.input} is not a valid userName.` }).optional(), + entraGroupId: z.string().refine(id => validation.isValidGuid(id), { error: e => `${e.input} is not a valid GUID.` }).optional(), + entraGroupName: z.string().optional() +}); + +declare type Options = z.infer; interface SpoUser { Id: number; @@ -30,16 +46,6 @@ interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { - webUrl: string; - id?: string; - email?: string; - loginName?: string; - userName?: string; - entraGroupId?: string; - entraGroupName?: string; -} - class SpoUserGetCommand extends SpoCommand { public get name(): string { return commands.USER_GET; @@ -49,87 +55,17 @@ class SpoUserGetCommand extends SpoCommand { return 'Gets a site user within specific web'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - email: typeof args.options.email !== 'undefined', - loginName: typeof args.options.loginName !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - entraGroupId: typeof args.options.entraGroupId !== 'undefined', - entraGroupName: typeof args.options.entraGroupName !== 'undefined' - }); - }); + public get schema(): z.ZodType { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '-i, --id [id]' - }, - { - option: '--email [email]' - }, - { - option: '--loginName [loginName]' - }, - { - option: '--userName [userName]' - }, - { - option: '--entraGroupId [entraGroupId]' - }, - { - option: '--entraGroupName [entraGroupName]' + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema.refine(opts => [opts.id, opts.email, opts.loginName, opts.userName, opts.entraGroupId, opts.entraGroupName].filter(value => value !== undefined).length <= 1, { + error: 'Specify no more than one of the following options: id, email, loginName, userName, entraGroupId, entraGroupName.', + params: { + customCode: 'optionSet', + options: ['id', 'email', 'loginName', 'userName', 'entraGroupId', 'entraGroupName'] } - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'email', 'loginName', 'userName', 'entraGroupId', 'entraGroupName'); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidPositiveInteger(args.options.id)) { - return `Specified id ${args.options.id} is not a valid number.`; - } - - if (args.options.entraGroupId && !validation.isValidGuid(args.options.entraGroupId)) { - return `${args.options.entraGroupId} is not a valid GUID.`; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid userName.`; - } - - if (args.options.email && !validation.isValidUserPrincipalName(args.options.email)) { - return `${args.options.email} is not a valid email.`; - } - - return validation.isValidSharePointUrl(args.options.webUrl); - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ - options: ['id', 'email', 'loginName', 'userName', 'entraGroupId', 'entraGroupName'], - runsWhen: (args) => args.options.id || args.options.email || args.options.loginName || args.options.userName || args.options.entraGroupId || args.options.entraGroupName }); } diff --git a/src/m365/spo/commands/user/user-list.spec.ts b/src/m365/spo/commands/user/user-list.spec.ts index 8bc9d480c9d..2628d82352e 100644 --- a/src/m365/spo/commands/user/user-list.spec.ts +++ b/src/m365/spo/commands/user/user-list.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-list.js'; +import command, { options } from './user-list.js'; describe(commands.USER_LIST, () => { let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -26,6 +27,7 @@ describe(commands.USER_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -105,11 +107,11 @@ describe(commands.USER_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: 'https://contoso.sharepoint.com' - } + }) }); assert(loggerLogSpy.calledWith([{ Id: 6, @@ -163,10 +165,10 @@ describe(commands.USER_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com' - } + }) }); assert(loggerLogSpy.calledWith([{ "Id": 6, @@ -182,30 +184,24 @@ describe(commands.USER_LIST, () => { }])); }); - it('supports specifying URL', () => { - const options = command.options; - let containsTypeOption = false; - options.forEach(o => { - if (o.option.indexOf('') > -1) { - containsTypeOption = true; - } - }); - assert(containsTypeOption); - }); - it('handles error correctly', async () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: { webUrl: 'https://contoso.sharepoint.com' } } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com' }) }), new CommandError('An error has occurred')); + }); + + it('fails validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if the url is valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/user/user-list.ts b/src/m365/spo/commands/user/user-list.ts index 838567c173d..8a304e55b13 100644 --- a/src/m365/spo/commands/user/user-list.ts +++ b/src/m365/spo/commands/user/user-list.ts @@ -1,18 +1,24 @@ import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +import { z } from 'zod'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(webUrl => validation.isValidSharePointUrl(webUrl) === true, { + error: e => validation.isValidSharePointUrl(e.input as string).toString() + }).alias('u') +}); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; -} - class SpoUserListCommand extends SpoCommand { public get name(): string { return commands.USER_LIST; @@ -26,25 +32,8 @@ class SpoUserListCommand extends SpoCommand { return ['Id', 'Title', 'LoginName']; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.webUrl) - ); + public get schema(): z.ZodType { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/user/user-remove.spec.ts b/src/m365/spo/commands/user/user-remove.spec.ts index 6ccc22c3a49..bfe374fb8f5 100644 --- a/src/m365/spo/commands/user/user-remove.spec.ts +++ b/src/m365/spo/commands/user/user-remove.spec.ts @@ -13,8 +13,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './user-remove.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './user-remove.js'; describe(commands.USER_REMOVE, () => { const validUserName = 'john.deo_hotmail.com#ext#@contoso.onmicrosoft.com'; @@ -109,6 +108,7 @@ describe(commands.USER_REMOVE, () => { let logger: Logger; let promptIssued: boolean = false; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -117,6 +117,7 @@ describe(commands.USER_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -163,110 +164,83 @@ describe(commands.USER_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if id or loginName or userName or email or entraGroupName or entraGroupId options are not passed', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - return defaultValue; - }); - - const actual = await command.validate({ - options: { - webUrl: validWebUrl - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id or loginName or userName or email or entraGroupName or entraGroupId options are not passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl }); + assert.strictEqual(actual.success, false); }); - it('fails validation if more than one of the options userName or email or entraGroupName or entraGroupId are passed', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); + it('fails validation if more than one of the options userName or email or entraGroupName or entraGroupId are passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, id: 10, loginName: validLoginName }); + assert.strictEqual(actual.success, false); + }); - const actual = await command.validate({ - options: { - webUrl: validWebUrl, - id: 10, - loginName: validLoginName - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('should fail validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', id: 10 }); + assert.strictEqual(actual.success, false); }); - it('should fail validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ - options: - { - webUrl: 'foo', - id: 10 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if entraGroupId is not a valid id', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupId: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if entraGroupId is not a valid id', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is not a valid number', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, id: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if id is not a valid number', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, id: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userName is not a valid user principal name', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if userName is not a valid user principal name', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, userName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if email is not a valid user principal name', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, email: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if email is not a valid user principal name', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, email: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation url is valid and id is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, id: 1 }); + assert.strictEqual(actual.success, true); }); - it('passes validation url is valid and id is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, id: 1 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and email is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, email: validEmail }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and email is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, email: validEmail } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and loginName is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, loginName: validLoginName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and loginName is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, loginName: validLoginName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and userName is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: validUserName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and userName is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, userName: validUserName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and entraGroupName is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupName: validEntraM365GroupName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and entraGroupName is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupName: validEntraM365GroupName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url is valid and entraGroupId is passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupId: validEntraGroupId }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url is valid and entraGroupId is passed', async () => { - const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: validEntraGroupId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, id: 1, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('should prompt before removing user using id from web when confirmation argument not passed ', async () => { await command.action(logger, { - options: - { + options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com/subsite', id: 10 - } + }) }); assert(promptIssued); @@ -274,11 +248,10 @@ describe(commands.USER_REMOVE, () => { it('should prompt before removing user using login name from web when confirmation argument not passed ', async () => { await command.action(logger, { - options: - { + options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com/subsite', loginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" - } + }) }); assert(promptIssued); @@ -294,11 +267,11 @@ describe(commands.USER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: validWebUrl, id: 10, force: true - } + }) }); let correctRequestIssued = false; requests.forEach(r => { @@ -320,11 +293,11 @@ describe(commands.USER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: validWebUrl, loginName: "i:0#.f|membership|parker@tenant.onmicrosoft.com", force: true - } + }) }); let correctRequestIssued = false; requests.forEach(r => { @@ -348,10 +321,10 @@ describe(commands.USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: validWebUrl, id: 10 - } + }) }); let correctRequestIssued = false; requests.forEach(r => { @@ -375,10 +348,10 @@ describe(commands.USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: validWebUrl, loginName: validLoginName - } + }) }); let correctRequestIssued = false; requests.forEach(r => { @@ -400,12 +373,12 @@ describe(commands.USER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, id: 10, force: true - } + }) }); let correctRequestIssued = false; requests.forEach(r => { @@ -427,12 +400,12 @@ describe(commands.USER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: validWebUrl, id: 10, force: true - } + }) }); let correctRequestIssued = false; requests.forEach(r => { @@ -457,12 +430,12 @@ describe(commands.USER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: validWebUrl, email: validEmail, force: true - } + }) }); assert(removeRequestIssued); }); @@ -487,12 +460,12 @@ describe(commands.USER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: validWebUrl, userName: validUserName, force: true - } + }) }); assert(true); }); @@ -515,11 +488,11 @@ describe(commands.USER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: validWebUrl, entraGroupId: validEntraGroupId, force: true - } + }) }); assert(true); }); @@ -542,12 +515,12 @@ describe(commands.USER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: validWebUrl, entraGroupName: validEntraM365GroupName, force: true - } + }) }); assert(true); }); @@ -570,12 +543,12 @@ describe(commands.USER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: validWebUrl, entraGroupName: validEntraSecurityGroupName, force: true - } - } as any); + }) + }); }); it('handles error when removing user using from web', async () => { @@ -588,12 +561,12 @@ describe(commands.USER_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: "https://contoso.sharepoint.com/subsite", id: 10, force: true - } - } as any), new CommandError('An error has occurred')); + }) + }), new CommandError('An error has occurred')); }); it('handles generic error when user not found when username is passed without prompting with confirmation argument', async () => { @@ -608,13 +581,12 @@ describe(commands.USER_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: validWebUrl, userName: validUserName, force: true - } + }) }), new CommandError(err)); }); }); - diff --git a/src/m365/spo/commands/user/user-remove.ts b/src/m365/spo/commands/user/user-remove.ts index 3316753cdeb..fe35df10bbd 100644 --- a/src/m365/spo/commands/user/user-remove.ts +++ b/src/m365/spo/commands/user/user-remove.ts @@ -1,7 +1,7 @@ import { Group } from '@microsoft/microsoft-graph-types'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { spo } from '../../../../utils/spo.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; @@ -9,6 +9,23 @@ import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +import { z } from 'zod'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(webUrl => validation.isValidSharePointUrl(webUrl) === true, { + error: e => validation.isValidSharePointUrl(e.input as string).toString() + }).alias('u'), + id: z.number().int().positive().optional().alias('i'), + loginName: z.string().optional(), + email: z.string().refine(email => validation.isValidUserPrincipalName(email), { error: e => `${e.input} is not a valid email.` }).optional(), + userName: z.string().refine(userName => validation.isValidUserPrincipalName(userName), { error: e => `${e.input} is not a valid userName.` }).optional(), + entraGroupId: z.string().refine(id => validation.isValidGuid(id), { error: e => `${e.input} is not a valid GUID.` }).optional(), + entraGroupName: z.string().optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; interface SpoUser { Id: number; @@ -30,17 +47,6 @@ interface SpoUser { interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - id?: string; - loginName?: string; - email?: string; - userName?: string; - entraGroupId?: string; - entraGroupName?: string; - force: boolean; -} - class SpoUserRemoveCommand extends SpoCommand { public get name(): string { return commands.USER_REMOVE; @@ -50,89 +56,17 @@ class SpoUserRemoveCommand extends SpoCommand { return 'Removes user from specific web'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodType { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - loginName: typeof args.options.loginName !== 'undefined', - email: typeof args.options.email !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - entraGroupId: typeof args.options.entraGroupId !== 'undefined', - entraGroupName: typeof args.options.entraGroupName !== 'undefined', - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '-i, --id [id]' - }, - { - option: '--loginName [loginName]' - }, - { - option: '--email [email]' - }, - { - option: '--userName [userName]' - }, - { - option: '--entraGroupId [entraGroupId]' - }, - { - option: '--entraGroupName [entraGroupName]' - }, - { - option: '-f, --force' + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema.refine(opts => [opts.id, opts.loginName, opts.email, opts.userName, opts.entraGroupId, opts.entraGroupName].filter(value => value !== undefined).length === 1, { + error: 'Specify one of the following options: id, loginName, email, userName, entraGroupId, entraGroupName.', + params: { + customCode: 'optionSet', + options: ['id', 'loginName', 'email', 'userName', 'entraGroupId', 'entraGroupName'] } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - if (args.options.id && isNaN(parseInt(args.options.id))) { - return `Specified id ${args.options.id} is not a number`; - } - - if (args.options.entraGroupId && !validation.isValidGuid(args.options.entraGroupId)) { - return `${args.options.entraId} is not a valid GUID.`; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid userName.`; - } - - if (args.options.email && !validation.isValidUserPrincipalName(args.options.email)) { - return `${args.options.email} is not a valid email.`; - } - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ - options: ['id', 'loginName', 'email', 'userName', 'entraGroupId', 'entraGroupName'] }); } @@ -149,7 +83,7 @@ class SpoUserRemoveCommand extends SpoCommand { } } - private async removeUser(logger: Logger, options: GlobalOptions): Promise { + private async removeUser(logger: Logger, options: Options): Promise { if (this.verbose) { await logger.logToStderr(`Removing user from subsite ${options.webUrl} ...`); } @@ -207,8 +141,8 @@ class SpoUserRemoveCommand extends SpoCommand { } } - private async getUser(options: GlobalOptions): Promise { - const requestUrl: string = `${options.webUrl}/_api/web/siteusers?$filter=UserPrincipalName eq ('${formatting.encodeQueryParameter(options.userName)}')`; + private async getUser(options: Options): Promise { + const requestUrl: string = `${options.webUrl}/_api/web/siteusers?$filter=UserPrincipalName eq ('${formatting.encodeQueryParameter(options.userName!)}')`; const requestOptions: CliRequestOptions = { url: requestUrl, headers: { @@ -223,8 +157,8 @@ class SpoUserRemoveCommand extends SpoCommand { }).value[0]; } - private async getEntraGroup(options: GlobalOptions): Promise { - return options.entraGroupId ? await entraGroup.getGroupById(options.entraGroupId) : await entraGroup.getGroupByDisplayName(options.entraGroupName); + private async getEntraGroup(options: Options): Promise { + return options.entraGroupId ? await entraGroup.getGroupById(options.entraGroupId) : await entraGroup.getGroupByDisplayName(options.entraGroupName!); } } diff --git a/src/m365/spo/commands/userprofile/userprofile-get.spec.ts b/src/m365/spo/commands/userprofile/userprofile-get.spec.ts index 264bd0e3bf3..5f49657d722 100644 --- a/src/m365/spo/commands/userprofile/userprofile-get.spec.ts +++ b/src/m365/spo/commands/userprofile/userprofile-get.spec.ts @@ -12,13 +12,14 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './userprofile-get.js'; +import command, { options } from './userprofile-get.js'; describe(commands.USERPROFILE_GET, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -34,6 +35,7 @@ describe(commands.USERPROFILE_GET, () => { auth.connection.active = true; auth.connection.spoUrl = 'https://contoso.sharepoint.com'; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -108,11 +110,11 @@ describe(commands.USERPROFILE_GET, () => { throw 'Invalid request'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'text', userName: 'john.doe@contoso.onmicrosoft.com' - } - } as any); + }) + }); const loggedProfile = JSON.parse(JSON.stringify(profile)); loggedProfile.UserProfileProperties = JSON.stringify(loggedProfile.UserProfileProperties); assert.strictEqual(JSON.stringify(log[0]), JSON.stringify(loggedProfile)); @@ -142,12 +144,12 @@ describe(commands.USERPROFILE_GET, () => { throw 'Invalid request'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, userName: 'john.doe@contoso.onmicrosoft.com' - } - } as any); + }) + }); assert(loggerLogSpy.calledWith({ "AccountName": "i:0#.f|membership|dips1802@dev1802.onmicrosoft.com", "DirectReports": [], @@ -167,34 +169,28 @@ describe(commands.USERPROFILE_GET, () => { })); }); - it('supports specifying userName', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--userName') > -1) { - containsOption = true; - } - }); - assert(containsOption); - }); - it('handles error correctly', async () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred')); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ userName: 'john.doe@contoso.onmicrosoft.com' - } - } as any), new CommandError('An error has occurred')); + }) + }), new CommandError('An error has occurred')); + }); + + it('fails validation if the user principal name is not a valid', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'abc' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if the user principal name is not a valid', async () => { - const actual = await command.validate({ options: { userName: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation when the user principal name is a valid', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'john.doe@mytenant.onmicrosoft.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation when the user principal name is a valid', async () => { - const actual = await command.validate({ options: { userName: 'john.doe@mytenant.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'john.doe@mytenant.onmicrosoft.com', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/userprofile/userprofile-get.ts b/src/m365/spo/commands/userprofile/userprofile-get.ts index 567267d333d..358241a0efc 100644 --- a/src/m365/spo/commands/userprofile/userprofile-get.ts +++ b/src/m365/spo/commands/userprofile/userprofile-get.ts @@ -1,21 +1,27 @@ import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { spo } from '../../../../utils/spo.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +import { z } from 'zod'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userName: z.string().refine(userName => validation.isValidUserPrincipalName(userName), { + error: e => `${e.input} is not a valid user principal name` + }).alias('u') +}); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userName: string; -} - class SpoUserProfileGetCommand extends SpoCommand { public get name(): string { return commands.USERPROFILE_GET; @@ -25,31 +31,8 @@ class SpoUserProfileGetCommand extends SpoCommand { return 'Gets SharePoint user profile properties for the specified user'; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --userName ' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (!validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid user principal name`; - } - - return true; - } - ); + public get schema(): z.ZodType { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/userprofile/userprofile-set.spec.ts b/src/m365/spo/commands/userprofile/userprofile-set.spec.ts index d7a1f363c85..550f3e24c22 100644 --- a/src/m365/spo/commands/userprofile/userprofile-set.spec.ts +++ b/src/m365/spo/commands/userprofile/userprofile-set.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -10,12 +12,14 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './userprofile-set.js'; +import command, { options } from './userprofile-set.js'; describe(commands.USERPROFILE_SET, () => { let log: any[]; let logger: Logger; const spoUrl = 'https://contoso.sharepoint.com'; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -30,6 +34,8 @@ describe(commands.USERPROFILE_SET, () => { }); auth.connection.active = true; auth.connection.spoUrl = spoUrl; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -84,12 +90,12 @@ describe(commands.USERPROFILE_SET, () => { }; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ userName: 'john.doe@mytenant.onmicrosoft.com', propertyName: 'SPS-JobTitle', propertyValue: 'Senior Developer', debug: true - } + }) }); const lastCall = postStub.lastCall.args[0]; assert.strictEqual(JSON.stringify(lastCall.data), JSON.stringify(data)); @@ -112,11 +118,11 @@ describe(commands.USERPROFILE_SET, () => { }; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ userName: 'john.doe@mytenant.onmicrosoft.com', propertyName: 'SPS-Skills', propertyValue: 'CSS, HTML' - } + }) }); const lastCall = postStub.lastCall.args[0]; assert.strictEqual(JSON.stringify(lastCall.data), JSON.stringify(data)); @@ -126,11 +132,21 @@ describe(commands.USERPROFILE_SET, () => { sinon.stub(request, 'post').rejects(new Error('An error has occurred')); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ userName: 'john.doe@mytenant.onmicrosoft.com', propertyName: 'SPS-JobTitle', propertyValue: 'Senior Developer' - } - } as any), new CommandError('An error has occurred')); + }) + }), new CommandError('An error has occurred')); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + userName: 'john.doe@mytenant.onmicrosoft.com', + propertyName: 'SPS-JobTitle', + propertyValue: 'Senior Developer', + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/userprofile/userprofile-set.ts b/src/m365/spo/commands/userprofile/userprofile-set.ts index de390fc8b14..010618f0fa9 100644 --- a/src/m365/spo/commands/userprofile/userprofile-set.ts +++ b/src/m365/spo/commands/userprofile/userprofile-set.ts @@ -1,20 +1,24 @@ import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { ContextInfo, spo } from '../../../../utils/spo.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +import { z } from 'zod'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userName: z.string().alias('u'), + propertyName: z.string().alias('n'), + propertyValue: z.string().alias('v') +}); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userName: string; - propertyName: string; - propertyValue: string; -} - class SpoUserProfileSetCommand extends SpoCommand { public get name(): string { return commands.USERPROFILE_SET; @@ -24,24 +28,8 @@ class SpoUserProfileSetCommand extends SpoCommand { return 'Sets user profile property for a SharePoint user'; } - constructor() { - super(); - - this.#initOptions(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --userName ' - }, - { - option: '-n, --propertyName ' - }, - { - option: '-v, --propertyValue ' - } - ); + public get schema(): z.ZodType { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/web/web-add.spec.ts b/src/m365/spo/commands/web/web-add.spec.ts index 0d1e627b0d7..137f606be36 100644 --- a/src/m365/spo/commands/web/web-add.spec.ts +++ b/src/m365/spo/commands/web/web-add.spec.ts @@ -12,7 +12,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './web-add.js'; +import command, { options } from './web-add.js'; import { settingsNames } from '../../../../settingsNames.js'; describe(commands.WEB_ADD, () => { @@ -20,6 +20,7 @@ describe(commands.WEB_ADD, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -34,6 +35,7 @@ describe(commands.WEB_ADD, () => { }); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -104,15 +106,16 @@ describe(commands.WEB_ADD, () => { throw 'Invalid request'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", + webTemplate: "STS#0", locale: 1033, breakInheritance: true, inheritNavigation: false, debug: true - } + }) }); assert(loggerLogSpy.calledWith({ Configuration: 0, @@ -168,13 +171,14 @@ describe(commands.WEB_ADD, () => { throw 'Invalid request'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", + webTemplate: "STS#0", inheritNavigation: true, locale: 1033 - } + }) }); assert(loggerLogSpy.calledWith({ Configuration: 0, @@ -230,14 +234,15 @@ describe(commands.WEB_ADD, () => { throw 'Invalid request'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", + webTemplate: "STS#0", inheritNavigation: true, locale: 1033, debug: true - } + }) }); assert(loggerLogSpy.calledWith({ Configuration: 0, @@ -310,14 +315,15 @@ describe(commands.WEB_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", + webTemplate: "STS#0", inheritNavigation: true, locale: 1033, debug: true - } + }) }); assert.strictEqual(configuredNavigation, true); }); @@ -377,13 +383,14 @@ describe(commands.WEB_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", + webTemplate: "STS#0", inheritNavigation: true, locale: 1033 - } + }) }); assert.strictEqual(configuredNavigation, true); }); @@ -433,14 +440,15 @@ describe(commands.WEB_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", + webTemplate: "STS#0", inheritNavigation: true, - local: 1033, + locale: 1033, debug: true - } + }) } as any), new CommandError('An error has occurred.')); }); @@ -464,14 +472,15 @@ describe(commands.WEB_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com/sites/test", + webTemplate: "STS#0", inheritNavigation: true, - local: 1033, + locale: 1033, debug: true - } + }) } as any), new CommandError("The Web site address \"/sites/test/subsite\" is already in use.")); }); @@ -514,14 +523,15 @@ describe(commands.WEB_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", + webTemplate: "STS#0", inheritNavigation: true, - local: 1033, + locale: 1033, debug: true - } + }) } as any), new CommandError('An error has occurred.')); }); @@ -530,14 +540,15 @@ describe(commands.WEB_ADD, () => { sinon.stub(spo, 'getRequestDigest').rejects({ error: { 'odata.error': { message: { value: 'An error has occurred' } } } }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", + webTemplate: "STS#0", inheritNavigation: true, - local: 1033, + locale: 1033, debug: true - } + }) } as any), new CommandError('An error has occurred')); }); @@ -546,38 +557,35 @@ describe(commands.WEB_ADD, () => { sinon.stub(spo, 'getRequestDigest').rejects(new Error('An error has occurred')); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", + webTemplate: "STS#0", inheritNavigation: true, - local: 1033, + locale: 1033, debug: true - } + }) } as any), new CommandError('An error has occurred')); }); - it('passes validation if all required options are specified', async () => { - const actual = await command.validate({ - options: { - title: "subsite", url: "subsite", - parentWebUrl: "https://contoso.sharepoint.com", webTemplate: "STS#0" - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if all required options are specified', () => { + const actual = commandOptionsSchema.safeParse({ + title: "subsite", url: "subsite", + parentWebUrl: "https://contoso.sharepoint.com", webTemplate: "STS#0" + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if all required options and valid locale are specified', async () => { - const actual = await command.validate({ - options: { - title: "subsite", url: "subsite", - parentWebUrl: "https://contoso.sharepoint.com", webTemplate: "STS#0", locale: 1033 - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if all required options and valid locale are specified', () => { + const actual = commandOptionsSchema.safeParse({ + title: "subsite", url: "subsite", + parentWebUrl: "https://contoso.sharepoint.com", webTemplate: "STS#0", locale: '1033' + }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the parentWebUrl option not specified', async () => { + it('fails validation if the parentWebUrl option not specified', () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { if (settingName === settingsNames.prompt) { return false; @@ -586,32 +594,33 @@ describe(commands.WEB_ADD, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - title: "subsite", - url: "subsite", webTemplate: "STS#0", locale: 1033 - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + title: "subsite", + url: "subsite", webTemplate: "STS#0", locale: '1033' + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if the parentWebUrl option is not a valid SharePoint URL', async () => { - const actual = await command.validate({ - options: { - title: "subsite", - url: "subsite", webTemplate: "STS#0", locale: 1033, - parentWebUrl: 'foo' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the parentWebUrl option is not a valid SharePoint URL', () => { + const actual = commandOptionsSchema.safeParse({ + title: "subsite", + url: "subsite", webTemplate: "STS#0", locale: '1033', + parentWebUrl: 'foo' + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if the specified locale is not a number', async () => { - const actual = await command.validate({ - options: { - title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", webTemplate: 'STS#0', locale: 'abc' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the specified locale is not a number', () => { + const actual = commandOptionsSchema.safeParse({ + title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", webTemplate: 'STS#0', locale: 'abc' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + title: "subsite", url: "subsite", parentWebUrl: "https://contoso.sharepoint.com", webTemplate: 'STS#0', unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/web/web-add.ts b/src/m365/spo/commands/web/web-add.ts index dc0d840daf2..5e2170211cc 100644 --- a/src/m365/spo/commands/web/web-add.ts +++ b/src/m365/spo/commands/web/web-add.ts @@ -1,6 +1,7 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; import config from '../../../../config.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { ClientSvcResponse, ClientSvcResponseContents, ContextInfo, spo } from '../../../../utils/spo.js'; @@ -9,21 +10,28 @@ import SpoCommand from '../../../base/SpoCommand.js'; import { BasePermissions, PermissionKind } from '../../base-permissions.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + title: z.string().alias('t'), + description: z.string().optional().alias('d'), + url: z.string().alias('u'), + webTemplate: z.string().alias('w'), + parentWebUrl: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('p'), + locale: z.union([z.string(), z.number()]).refine(locale => !isNaN(parseInt(locale.toString())), { + error: e => `${e.input} is not a valid locale number` + }).optional().alias('l'), + breakInheritance: z.boolean().optional(), + inheritNavigation: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - title: string; - url: string; - webTemplate: string; - parentWebUrl: string; - description?: string; - locale?: string; - breakInheritance: boolean; - inheritNavigation: boolean; -} - class SpoWebAddCommand extends SpoCommand { public get name(): string { return commands.WEB_ADD; @@ -33,72 +41,8 @@ class SpoWebAddCommand extends SpoCommand { return 'Creates new subsite'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - description: (!(!args.options.description)).toString(), - locale: args.options.locale || '1033', - breakInheritance: args.options.breakInheritance || false, - inheritNavigation: args.options.inheritNavigation || false - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-t, --title ' - }, - { - option: '-d, --description [description]' - }, - { - option: '-u, --url <url>' - }, - { - option: '-w, --webTemplate <webTemplate>' - }, - { - option: '-p, --parentWebUrl <parentWebUrl>' - }, - { - option: '-l, --locale [locale]' - }, - { - option: '--breakInheritance' - }, - { - option: '--inheritNavigation' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.parentWebUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - if (args.options.locale) { - const locale: number = parseInt(args.options.locale); - if (isNaN(locale)) { - return `${args.options.locale} is not a valid locale number`; - } - } - - return true; - } - ); + public get schema(): z.ZodType | undefined { + return options; } protected getExcludedOptionsWithUrls(): string[] | undefined { @@ -129,7 +73,7 @@ class SpoWebAddCommand extends SpoCommand { }; if (this.verbose) { - await logger.logToStderr(`Creating subsite ${args.options.parentWebUrl}/${args.options.webUrl}...`); + await logger.logToStderr(`Creating subsite ${args.options.parentWebUrl}/${args.options.url}...`); } const siteInfo = await request.post(requestOptionsPost); diff --git a/src/m365/spo/commands/web/web-clientsidewebpart-list.spec.ts b/src/m365/spo/commands/web/web-clientsidewebpart-list.spec.ts index d3e65670c6a..45ec47006ce 100644 --- a/src/m365/spo/commands/web/web-clientsidewebpart-list.spec.ts +++ b/src/m365/spo/commands/web/web-clientsidewebpart-list.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-clientsidewebpart-list.js'; +import command, { options } from './web-clientsidewebpart-list.js'; describe(commands.WEB_CLIENTSIDEWEBPART_LIST, () => { let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -26,6 +27,7 @@ describe(commands.WEB_CLIENTSIDEWEBPART_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -63,23 +65,19 @@ describe(commands.WEB_CLIENTSIDEWEBPART_LIST, () => { assert.notStrictEqual(command.description, null); }); - it('should fail validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ - options: - { - webUrl: 'foo' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('should fail validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if all required options are specified', async () => { - const actual = await command.validate({ - options: { - webUrl: "https://contoso.sharepoint.com/subsite" - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if all required options are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: "https://contoso.sharepoint.com/subsite" }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: "https://contoso.sharepoint.com/subsite", unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('handles error when calling client side webparts', async () => { @@ -91,10 +89,10 @@ describe(commands.WEB_CLIENTSIDEWEBPART_LIST, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', webUrl: 'https://contoso.sharepoint.com' - } + }) } as any), new CommandError('Error')); }); @@ -128,10 +126,10 @@ describe(commands.WEB_CLIENTSIDEWEBPART_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', webUrl: 'https://contoso.sharepoint.com' - } + }) }); }); @@ -166,11 +164,11 @@ describe(commands.WEB_CLIENTSIDEWEBPART_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: 'https://contoso.sharepoint.com' - } + }) }); assert(loggerLogSpy.calledOnceWithExactly([])); }); @@ -205,11 +203,11 @@ describe(commands.WEB_CLIENTSIDEWEBPART_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: 'https://contoso.sharepoint.com' - } + }) }); const expectedClientSideWebparts: any[] = []; @@ -260,11 +258,11 @@ describe(commands.WEB_CLIENTSIDEWEBPART_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: 'https://contoso.sharepoint.com' - } + }) }); const expectedClientSideWebparts: any[] = []; diff --git a/src/m365/spo/commands/web/web-clientsidewebpart-list.ts b/src/m365/spo/commands/web/web-clientsidewebpart-list.ts index a372ad65996..33d338633ee 100644 --- a/src/m365/spo/commands/web/web-clientsidewebpart-list.ts +++ b/src/m365/spo/commands/web/web-clientsidewebpart-list.ts @@ -1,19 +1,25 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { GetClientSideWebPartsRsp } from './GetClientSideWebPartsRsp.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; -} - class SpoWebClientSideWebPartListCommand extends SpoCommand { public get name(): string { return commands.WEB_CLIENTSIDEWEBPART_LIST; @@ -23,25 +29,8 @@ class SpoWebClientSideWebPartListCommand extends SpoCommand { return 'Lists available client-side web parts'; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl <webUrl>' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.webUrl) - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/spo/commands/web/web-get.spec.ts b/src/m365/spo/commands/web/web-get.spec.ts index 2f0de0ed69d..46e6180be98 100644 --- a/src/m365/spo/commands/web/web-get.spec.ts +++ b/src/m365/spo/commands/web/web-get.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-get.js'; +import command, { options } from './web-get.js'; describe(commands.WEB_GET, () => { let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const webResponse = { value: [{ AllowRssFeeds: false, @@ -323,6 +324,7 @@ describe(commands.WEB_GET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -369,11 +371,11 @@ describe(commands.WEB_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, url: 'https://contoso.sharepoint.com' - } + }) }); assert(loggerLogSpy.calledWith(webResponse)); @@ -388,12 +390,12 @@ describe(commands.WEB_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, url: 'https://contoso.sharepoint.com', withGroups: true - } + }) }); assert(loggerLogSpy.calledWith(webResponseGroups)); }); @@ -411,13 +413,13 @@ describe(commands.WEB_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, url: 'https://contoso.sharepoint.com', withGroups: true, withPermissions: true - } + }) }); assert(loggerLogSpy.calledWith({ value: webResponseGroups.value, RoleAssignments: webResponseGroupsRoleAssignments.value[0].RoleAssignments })); }); @@ -432,10 +434,10 @@ describe(commands.WEB_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'text', url: 'https://contoso.sharepoint.com' - } + }) }); assert(loggerLogSpy.calledWith(webResponse)); }); @@ -460,10 +462,10 @@ describe(commands.WEB_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, url: 'https://contoso.sharepoint.com' - } + }) } as any), new CommandError(err)); }); @@ -479,32 +481,26 @@ describe(commands.WEB_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', url: 'https://contoso.sharepoint.com' - } + }) }); assert('Correct Url'); }); - it('supports specifying URL', () => { - const options = command.options; - let containsTypeOption = false; - options.forEach(o => { - if (o.option.indexOf('<url>') > -1) { - containsTypeOption = true; - } - }); - assert(containsTypeOption); + it('fails validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ url: 'foo' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { url: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if the url option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/web/web-get.ts b/src/m365/spo/commands/web/web-get.ts index 22e01402589..34a0e9514bd 100644 --- a/src/m365/spo/commands/web/web-get.ts +++ b/src/m365/spo/commands/web/web-get.ts @@ -1,5 +1,6 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; @@ -7,16 +8,21 @@ import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { WebProperties } from './WebProperties.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + url: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u'), + withGroups: z.boolean().optional(), + withPermissions: z.boolean().optional() +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { - url: string; - withGroups?: boolean; - withPermissions?: boolean; -} - class SpoWebGetCommand extends SpoCommand { public get name(): string { return commands.WEB_GET; @@ -26,41 +32,8 @@ class SpoWebGetCommand extends SpoCommand { return 'Retrieves information about the specified site'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - withGroups: !!args.options.withGroups, - withPermissions: !!args.options.withPermissions - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --url <url>' - }, - { - option: '--withGroups' - }, - { - option: '--withPermissions' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.url) - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/spo/commands/web/web-installedlanguage-list.spec.ts b/src/m365/spo/commands/web/web-installedlanguage-list.spec.ts index 8a3ef02825a..f45b9bb9f4c 100644 --- a/src/m365/spo/commands/web/web-installedlanguage-list.spec.ts +++ b/src/m365/spo/commands/web/web-installedlanguage-list.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-installedlanguage-list.js'; +import command, { options } from './web-installedlanguage-list.js'; describe(commands.WEB_INSTALLEDLANGUAGE_LIST, () => { let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -26,6 +27,7 @@ describe(commands.WEB_INSTALLEDLANGUAGE_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -88,11 +90,11 @@ describe(commands.WEB_INSTALLEDLANGUAGE_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: 'https://contoso.sharepoint.com' - } + }) }); assert(loggerLogSpy.calledWith([{ "DisplayName": "German", @@ -117,31 +119,25 @@ describe(commands.WEB_INSTALLEDLANGUAGE_LIST, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com' - } + }) } as any), new CommandError(err)); }); - it('supports specifying URL', () => { - const options = command.options; - let containsTypeOption = false; - options.forEach(o => { - if (o.option.indexOf('<webUrl>') > -1) { - containsTypeOption = true; - } - }); - assert(containsTypeOption); + it('fails validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if the url option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/web/web-installedlanguage-list.ts b/src/m365/spo/commands/web/web-installedlanguage-list.ts index 5dcc3908ded..6e07c1fa9d7 100644 --- a/src/m365/spo/commands/web/web-installedlanguage-list.ts +++ b/src/m365/spo/commands/web/web-installedlanguage-list.ts @@ -1,19 +1,25 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { WebInstalledLanguagePropertiesCollection } from './WebPropertiesCollection.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; -} - class SpoWebInstalledLanguageListCommand extends SpoCommand { public get name(): string { return commands.WEB_INSTALLEDLANGUAGE_LIST; @@ -27,25 +33,8 @@ class SpoWebInstalledLanguageListCommand extends SpoCommand { return ['DisplayName', 'LanguageTag', 'Lcid']; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl <webUrl>' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.webUrl) - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/spo/commands/web/web-list.spec.ts b/src/m365/spo/commands/web/web-list.spec.ts index 764d8e4e9f0..f3df13cf27d 100644 --- a/src/m365/spo/commands/web/web-list.spec.ts +++ b/src/m365/spo/commands/web/web-list.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-list.js'; +import command, { options } from './web-list.js'; describe(commands.WEB_LIST, () => { let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -26,6 +27,7 @@ describe(commands.WEB_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -112,11 +114,11 @@ describe(commands.WEB_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, url: 'https://contoso.sharepoint.com' - } + }) }); assert(loggerLogSpy.calledWith([{ "AllowRssFeeds": false, @@ -165,10 +167,10 @@ describe(commands.WEB_LIST, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, url: 'https://contoso.sharepoint.com' - } + }) } as any), new CommandError(err)); }); @@ -184,32 +186,26 @@ describe(commands.WEB_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', url: 'https://contoso.sharepoint.com' - } + }) }); assert('Correct Url'); }); - it('supports specifying URL', () => { - const options = command.options; - let containsTypeOption = false; - options.forEach(o => { - if (o.option.indexOf('<url>') > -1) { - containsTypeOption = true; - } - }); - assert(containsTypeOption); + it('fails validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ url: 'foo' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { url: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if the url option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/web/web-list.ts b/src/m365/spo/commands/web/web-list.ts index f154debf552..c5b6d7e450d 100644 --- a/src/m365/spo/commands/web/web-list.ts +++ b/src/m365/spo/commands/web/web-list.ts @@ -1,19 +1,25 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { odata } from '../../../../utils/odata.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { WebProperties } from './WebProperties.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + url: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - url: string; -} - class SpoWebListCommand extends SpoCommand { public get name(): string { return commands.WEB_LIST; @@ -27,25 +33,8 @@ class SpoWebListCommand extends SpoCommand { return ['Title', 'Url', 'Id']; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --url <url>' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.url) - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/spo/commands/web/web-reindex.spec.ts b/src/m365/spo/commands/web/web-reindex.spec.ts index fb2e60f2d8a..74a7162a1ed 100644 --- a/src/m365/spo/commands/web/web-reindex.spec.ts +++ b/src/m365/spo/commands/web/web-reindex.spec.ts @@ -13,13 +13,14 @@ import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; import { SpoPropertyBagBaseCommand } from '../propertybag/propertybag-base.js'; -import command from './web-reindex.js'; +import command, { options } from './web-reindex.js'; describe(commands.WEB_REINDEX, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let loggerLogToStderrSpy: sinon.SinonSpy; before(() => { @@ -35,6 +36,7 @@ describe(commands.WEB_REINDEX, () => { }); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -113,7 +115,7 @@ describe(commands.WEB_REINDEX, () => { return JSON.stringify({}); }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a' }) }); assert(loggerLogSpy.notCalled, 'Something has been logged'); assert.strictEqual(propertyName, 'vti_searchversion', 'Incorrect property stored in the property bag'); assert.strictEqual(propertyValue, '1', 'Incorrect property value stored in the property bag'); @@ -157,7 +159,7 @@ describe(commands.WEB_REINDEX, () => { return JSON.stringify({}); }); - await command.action(logger, { options: { debug: true, url: 'https://contoso.sharepoint.com/sites/team-a' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, url: 'https://contoso.sharepoint.com/sites/team-a' }) }); assert.strictEqual(propertyName, 'vti_searchversion', 'Incorrect property stored in the property bag'); assert.strictEqual(propertyValue, '2', 'Incorrect property value stored in the property bag'); }); @@ -243,7 +245,7 @@ describe(commands.WEB_REINDEX, () => { return JSON.stringify({}); }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a' }) }); assert(loggerLogSpy.notCalled, 'Something has been logged'); assert.strictEqual(propertyName[0], 'vti_searchversion'); assert.strictEqual(propertyName[1], 'vti_searchversion'); @@ -332,7 +334,7 @@ describe(commands.WEB_REINDEX, () => { return JSON.stringify({}); }); - await command.action(logger, { options: { debug: true, url: 'https://contoso.sharepoint.com/sites/team-a' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, url: 'https://contoso.sharepoint.com/sites/team-a' }) }); assert(loggerLogToStderrSpy.called, 'Nothing has been logged'); assert.strictEqual(propertyName[0], 'vti_searchversion'); assert.strictEqual(propertyName[1], 'vti_searchversion'); @@ -410,16 +412,21 @@ describe(commands.WEB_REINDEX, () => { sinon.stub(SpoPropertyBagBaseCommand, 'isNoScriptSite').resolves(true); sinon.stub(SpoPropertyBagBaseCommand, 'setProperty').rejects(new Error('ClientSvc unknown error')); - await assert.rejects(command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a' } } as any), new CommandError('ClientSvc unknown error')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a' }) }), new CommandError('ClientSvc unknown error')); }); - it('fails validation if url is not a valid SharePoint URL', async () => { - const actual = await command.validate({ options: { url: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if url is not a valid SharePoint URL', () => { + const actual = commandOptionsSchema.safeParse({ url: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if url is valid', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if url is valid', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com' }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/web/web-reindex.ts b/src/m365/spo/commands/web/web-reindex.ts index c108cd0d04c..00cf8380314 100644 --- a/src/m365/spo/commands/web/web-reindex.ts +++ b/src/m365/spo/commands/web/web-reindex.ts @@ -1,5 +1,6 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { ContextInfo, IdentityResponse, spo } from '../../../../utils/spo.js'; import { validation } from '../../../../utils/validation.js'; @@ -7,14 +8,19 @@ import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { SpoPropertyBagBaseCommand } from '../propertybag/propertybag-base.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + url: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - url: string; -} - class SpoWebReindexCommand extends SpoCommand { public get name(): string { return commands.WEB_REINDEX; @@ -24,25 +30,8 @@ class SpoWebReindexCommand extends SpoCommand { return 'Requests reindexing the specified subsite'; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --url <url>' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.url) - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/spo/commands/web/web-remove.spec.ts b/src/m365/spo/commands/web/web-remove.spec.ts index b4c856db999..0a60dc718c5 100644 --- a/src/m365/spo/commands/web/web-remove.spec.ts +++ b/src/m365/spo/commands/web/web-remove.spec.ts @@ -11,7 +11,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-remove.js'; +import command, { options } from './web-remove.js'; describe(commands.WEB_REMOVE, () => { let log: any[]; @@ -19,6 +19,7 @@ describe(commands.WEB_REMOVE, () => { let logger: Logger; let promptIssued: boolean = false; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -27,6 +28,7 @@ describe(commands.WEB_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -71,23 +73,19 @@ describe(commands.WEB_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('should fail validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ - options: - { - url: 'foo' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('should fail validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ url: 'foo' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if all required options are specified', async () => { - const actual = await command.validate({ - options: { - url: "https://contoso.sharepoint.com/subsite" - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if all required options are specified', () => { + const actual = commandOptionsSchema.safeParse({ url: "https://contoso.sharepoint.com/subsite" }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ url: "https://contoso.sharepoint.com/subsite", unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('should prompt before deleting subsite when confirmation argument not passed', async () => { @@ -99,7 +97,7 @@ describe(commands.WEB_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/subsite' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/subsite' }) }); assert(promptIssued); }); @@ -114,10 +112,10 @@ describe(commands.WEB_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: "https://contoso.sharepoint.com/subsite", force: true - } + }) }); let correctRequestIssued = false; requests.forEach(r => { @@ -145,9 +143,9 @@ describe(commands.WEB_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: "https://contoso.sharepoint.com/subsite" - } + }) }); let correctRequestIssued = false; requests.forEach(r => { @@ -171,11 +169,11 @@ describe(commands.WEB_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, url: "https://contoso.sharepoint.com/subsite", force: true - } + }) }); let correctRequestIssued = false; requests.forEach(r => { @@ -199,11 +197,11 @@ describe(commands.WEB_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, url: "https://contoso.sharepoint.com/subsite", force: true - } + }) }); let correctRequestIssued = false; requests.forEach(r => { @@ -227,10 +225,10 @@ describe(commands.WEB_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ url: "https://contoso.sharepoint.com/subsite", force: true - } + }) } as any), new CommandError('An error has occurred')); }); }); diff --git a/src/m365/spo/commands/web/web-remove.ts b/src/m365/spo/commands/web/web-remove.ts index 6e506aca6b4..cfc41ec96b5 100644 --- a/src/m365/spo/commands/web/web-remove.ts +++ b/src/m365/spo/commands/web/web-remove.ts @@ -1,20 +1,26 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + url: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u'), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - url: string; - force?: boolean; -} - class SpoWebRemoveCommand extends SpoCommand { public get name(): string { return commands.WEB_REMOVE; @@ -24,37 +30,8 @@ class SpoWebRemoveCommand extends SpoCommand { return 'Deletes specified subsite'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: (!(!args.options.force)).toString() - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --url <url>' - }, - { - option: '-f, --force' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.url) - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/spo/commands/web/web-retentionlabel-list.spec.ts b/src/m365/spo/commands/web/web-retentionlabel-list.spec.ts index 7db2b2bfe53..ef45043347b 100644 --- a/src/m365/spo/commands/web/web-retentionlabel-list.spec.ts +++ b/src/m365/spo/commands/web/web-retentionlabel-list.spec.ts @@ -12,7 +12,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-retentionlabel-list.js'; +import command, { options } from './web-retentionlabel-list.js'; describe(commands.WEB_RETENTIONLABEL_LIST, () => { @@ -56,6 +56,7 @@ describe(commands.WEB_RETENTIONLABEL_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -64,6 +65,7 @@ describe(commands.WEB_RETENTIONLABEL_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -105,14 +107,19 @@ describe(commands.WEB_RETENTIONLABEL_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['TagId', 'TagName']); }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com' }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('retrieves a list of retention labels', async () => { @@ -124,11 +131,11 @@ describe(commands.WEB_RETENTIONLABEL_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', debug: true, webUrl: 'https://contoso.sharepoint.com' - } + }) }); assert(loggerLogSpy.calledWith(mockResponseArray)); }); @@ -143,10 +150,10 @@ describe(commands.WEB_RETENTIONLABEL_LIST, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com' - } + }) } as any), new CommandError('An error has occurred')); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/web/web-retentionlabel-list.ts b/src/m365/spo/commands/web/web-retentionlabel-list.ts index 9782304398d..8dc90977cb9 100644 --- a/src/m365/spo/commands/web/web-retentionlabel-list.ts +++ b/src/m365/spo/commands/web/web-retentionlabel-list.ts @@ -1,19 +1,25 @@ +import { z } from 'zod'; import { Logger } from "../../../../cli/Logger.js"; -import GlobalOptions from "../../../../GlobalOptions.js"; +import { globalOptionsZod } from '../../../../Command.js'; import { formatting } from '../../../../utils/formatting.js'; import { odata } from "../../../../utils/odata.js"; import { validation } from "../../../../utils/validation.js"; import SpoCommand from "../../../base/SpoCommand.js"; import commands from "../../commands.js"; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { - webUrl: string; -} - class SpoWebRetentionLabelListCommand extends SpoCommand { public get name(): string { return commands.WEB_RETENTIONLABEL_LIST; @@ -27,25 +33,8 @@ class SpoWebRetentionLabelListCommand extends SpoCommand { return ['TagId', 'TagName']; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl <webUrl>' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.webUrl) - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/spo/commands/web/web-roleassignment-add.spec.ts b/src/m365/spo/commands/web/web-roleassignment-add.spec.ts index 840475b7774..6cb6b474803 100644 --- a/src/m365/spo/commands/web/web-roleassignment-add.spec.ts +++ b/src/m365/spo/commands/web/web-roleassignment-add.spec.ts @@ -11,7 +11,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-roleassignment-add.js'; +import command, { options } from './web-roleassignment-add.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { spo } from '../../../../utils/spo.js'; @@ -19,6 +19,7 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const graphGroup = { id: '27ae47f1-48f1-46f3-980b-d3c1470e398d', @@ -168,6 +169,7 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -210,48 +212,53 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', principalId: 11, roleDefinitionId: 1073741827 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', principalId: 11, roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionId: 1073741827 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the principalId option is not a number', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', principalId: 'abc', roleDefinitionId: 1073741827 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the principalId option is not a number', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', principalId: 'abc', roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the principalId option is a number', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionId: 1073741827 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the principalId option is a number', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the roleDefinitionId option is not a number', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionId: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the roleDefinitionId option is not a number', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionId: 'abc' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the roleDefinitionId option is a number', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionId: 1073741827 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the roleDefinitionId option is a number', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the entaGroupId is not a valid guid', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', entraGroupId: 'invalid', roleDefinitionId: '1073741827' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the entaGroupId is not a valid guid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', entraGroupId: 'invalid', roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the entaGroupId is a valid guid', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', entraGroupId: '27ae47f1-48f1-46f3-980b-d3c1470e398d', roleDefinitionId: 1073741827 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the entaGroupId is a valid guid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', entraGroupId: '27ae47f1-48f1-46f3-980b-d3c1470e398d', roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionId: 1073741827, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('add role assignment on web by role definition id', async () => { - sinon.stub(request, 'post').callsFake(async (opts) => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if ((opts.url as string).indexOf('_api/web/roleassignments/addroleassignment(principalid=\'11\',roledefid=\'1073741827\')') > -1) { return; } @@ -260,13 +267,15 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionId: 1073741827 - } + }) }); + + assert(postStub.calledOnce); }); it('add role assignment on web get principal id by upn', async () => { @@ -281,12 +290,12 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { sinon.stub(spo, 'getUserByEmail').resolves(userResponse); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', upn: 'someaccount@tenant.onmicrosoft.com', roleDefinitionId: 1073741827 - } + }) }); }); @@ -303,12 +312,12 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { sinon.stub(spo, 'getUserByEmail').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', upn: 'someaccount@tenant.onmicrosoft.com', roleDefinitionId: 1073741827 - } + }) } as any), new CommandError(error)); }); @@ -324,12 +333,12 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { sinon.stub(spo, 'getGroupByName').resolves(groupResponse); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', groupName: 'someGroup', roleDefinitionId: 1073741827 - } + }) }); }); @@ -346,12 +355,12 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { sinon.stub(spo, 'getGroupByName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', groupName: 'someGroup', roleDefinitionId: 1073741827 - } + }) } as any), new CommandError(error)); }); @@ -367,12 +376,12 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { sinon.stub(spo, 'getRoleDefinitionByName').resolves(roleDefinitionResponse); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionName: 'Full Control' - } + }) }); }); @@ -389,13 +398,12 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', entraGroupId: '27ae47f1-48f1-46f3-980b-d3c1470e398d', - principalId: 11, roleDefinitionId: 1073741827 - } + }) }); }); @@ -412,13 +420,12 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', entraGroupName: 'Marketing', - principalId: 11, roleDefinitionId: 1073741827 - } + }) }); }); @@ -435,12 +442,12 @@ describe(commands.WEB_ROLEASSIGNMENT_ADD, () => { sinon.stub(spo, 'getRoleDefinitionByName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', principalId: 11, roleDefinitionName: 'Full Control' - } + }) } as any), new CommandError(error)); }); }); diff --git a/src/m365/spo/commands/web/web-roleassignment-add.ts b/src/m365/spo/commands/web/web-roleassignment-add.ts index 07b8c77f7b2..7d46e90da0e 100644 --- a/src/m365/spo/commands/web/web-roleassignment-add.ts +++ b/src/m365/spo/commands/web/web-roleassignment-add.ts @@ -1,5 +1,6 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { spo } from '../../../../utils/spo.js'; @@ -7,21 +8,28 @@ import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u'), + principalId: z.number().optional(), + upn: z.string().optional(), + groupName: z.string().optional(), + entraGroupId: z.string().refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option entraGroupId.` + }).optional(), + entraGroupName: z.string().optional(), + roleDefinitionId: z.number().optional(), + roleDefinitionName: z.string().optional() +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - principalId?: number; - upn?: string; - groupName?: string; - entraGroupId?: string; - entraGroupName?: string; - roleDefinitionId?: number; - roleDefinitionName?: string; -} - class SpoWebRoleAssignmentAddCommand extends SpoCommand { public get name(): string { return commands.WEB_ROLEASSIGNMENT_ADD; @@ -31,88 +39,26 @@ class SpoWebRoleAssignmentAddCommand extends SpoCommand { return 'Adds a role assignment to web'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - principalId: typeof args.options.principalId !== 'undefined', - upn: typeof args.options.upn !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined', - entraGroupId: typeof args.options.entraGroupId !== 'undefined', - entraGroupName: typeof args.options.entraGroupName !== 'undefined', - roleDefinitionId: typeof args.options.roleDefinitionId !== 'undefined', - roleDefinitionName: typeof args.options.roleDefinitionName !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl <webUrl>' - }, - { - option: '--principalId [principalId]' - }, - { - option: '--upn [upn]' - }, - { - option: '--groupName [groupName]' - }, - { - option: '--entraGroupId [entraGroupId]' - }, - { - option: '--entraGroupName [entraGroupName]' - }, - { - option: '--roleDefinitionId [roleDefinitionId]' - }, - { - option: '--roleDefinitionName [roleDefinitionName]' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - if (args.options.principalId && isNaN(args.options.principalId)) { - return `Specified principalId ${args.options.principalId} is not a number`; - } - - if (args.options.roleDefinitionId && isNaN(args.options.roleDefinitionId)) { - return `Specified roleDefinitionId ${args.options.roleDefinitionId} is not a number`; + public getRefinedSchema(schema: typeof options): z.ZodObject<any> | undefined { + return schema + .refine(options => [options.principalId, options.upn, options.groupName, options.entraGroupId, options.entraGroupName].filter(x => x !== undefined).length === 1, { + error: `Specify either 'principalId', 'upn', 'groupName', 'entraGroupId', or 'entraGroupName'.`, + params: { + customCode: 'optionSet', + options: ['principalId', 'upn', 'groupName', 'entraGroupId', 'entraGroupName'] } - - if (args.options.entraGroupId && !validation.isValidGuid(args.options.entraGroupId)) { - return `'${args.options.entraGroupId}' is not a valid GUID for option entraGroupId.`; + }) + .refine(options => [options.roleDefinitionId, options.roleDefinitionName].filter(x => x !== undefined).length === 1, { + error: `Specify either 'roleDefinitionId' or 'roleDefinitionName'.`, + params: { + customCode: 'optionSet', + options: ['roleDefinitionId', 'roleDefinitionName'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['principalId', 'upn', 'groupName', 'entraGroupId', 'entraGroupName'] }, - { options: ['roleDefinitionId', 'roleDefinitionName'] } - ); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { @@ -123,7 +69,10 @@ class SpoWebRoleAssignmentAddCommand extends SpoCommand { try { const roleDefinitionId = await this.getRoleDefinitionId(args.options, logger); - if (args.options.upn) { + if (args.options.principalId) { + await this.addRoleAssignment(args.options.webUrl, args.options.principalId, roleDefinitionId, logger); + } + else if (args.options.upn) { const principalId = await this.getUserPrincipalId(args.options, logger); await this.addRoleAssignment(args.options.webUrl, principalId, roleDefinitionId, logger); } diff --git a/src/m365/spo/commands/web/web-roleassignment-remove.spec.ts b/src/m365/spo/commands/web/web-roleassignment-remove.spec.ts index 3423639ecfe..6654e0b8d19 100644 --- a/src/m365/spo/commands/web/web-roleassignment-remove.spec.ts +++ b/src/m365/spo/commands/web/web-roleassignment-remove.spec.ts @@ -11,7 +11,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-roleassignment-remove.js'; +import command, { options } from './web-roleassignment-remove.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { spo } from '../../../../utils/spo.js'; @@ -19,6 +19,7 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let requests: any[]; let promptIssued: boolean = false; @@ -119,6 +120,7 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -168,38 +170,43 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', principalId: 11 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', principalId: 11 }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', principalId: 11 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', principalId: 11 }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the principalId option is not a number', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', principalId: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the principalId option is not a number', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', principalId: 'abc' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the principalId option is a number', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', principalId: 11 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the principalId option is a number', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', principalId: 11 }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the entreGroupId option is not a valid guid', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', entraGroupId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the entreGroupId option is not a valid guid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', entraGroupId: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the entreGroupId option is a valid guid', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', entraGroupId: 'a449d6a5-1a05-4e79-b345-e2519fd66a99' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the entreGroupId option is a valid guid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', entraGroupId: 'a449d6a5-1a05-4e79-b345-e2519fd66a99' }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', principalId: 11, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('remove role assignment from web', async () => { - sinon.stub(request, 'post').callsFake(async (opts) => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if ((opts.url as string).indexOf('_api/web/roleassignments/removeroleassignment(principalid=\'11\')') > -1) { return; } @@ -208,13 +215,15 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', principalId: 11, force: true - } + }) }); + + assert(postStub.calledOnce); }); it('remove role assignment from web get principal id by upn', async () => { @@ -229,12 +238,12 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { sinon.stub(spo, 'getUserByEmail').resolves(userResponse); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', upn: 'someaccount@tenant.onmicrosoft.com', force: true - } + }) }); }); @@ -251,12 +260,12 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { sinon.stub(spo, 'getUserByEmail').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', upn: 'someaccount@tenant.onmicrosoft.com', force: true - } + }) } as any), new CommandError(error)); }); @@ -272,12 +281,12 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { sinon.stub(spo, 'getGroupByName').resolves(groupResponse); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', groupName: 'someGroup', force: true - } + }) }); }); @@ -294,33 +303,33 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { sinon.stub(spo, 'getGroupByName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', groupName: 'someGroup', force: true - } + }) } as any), new CommandError(error)); }); it('aborts removing role assignment when prompt not confirmed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', groupName: 'someGroup' - } + }) }); assert(requests.length === 0); }); it('prompts before removing role assignment when confirmation argument not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', groupName: 'someGroup' - } + }) }); assert(promptIssued); @@ -341,11 +350,11 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', groupName: 'someGroup' - } + }) }); }); @@ -362,12 +371,12 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', entraGroupId: '27ae47f1-48f1-46f3-980b-d3c1470e398d', force: true - } + }) }); }); @@ -384,12 +393,12 @@ describe(commands.WEB_ROLEASSIGNMENT_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', entraGroupName: 'Marketing', force: true - } + }) }); }); }); diff --git a/src/m365/spo/commands/web/web-roleassignment-remove.ts b/src/m365/spo/commands/web/web-roleassignment-remove.ts index fc1f68b9c5c..70bfa840e74 100644 --- a/src/m365/spo/commands/web/web-roleassignment-remove.ts +++ b/src/m365/spo/commands/web/web-roleassignment-remove.ts @@ -1,5 +1,6 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; @@ -8,20 +9,27 @@ import { entraGroup } from '../../../../utils/entraGroup.js'; import { spo } from '../../../../utils/spo.js'; import { cli } from '../../../../cli/cli.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u'), + principalId: z.number().optional(), + upn: z.string().optional(), + groupName: z.string().optional(), + entraGroupId: z.string().refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option entraGroupId.` + }).optional(), + entraGroupName: z.string().optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - principalId?: number; - upn?: string; - groupName?: string; - entraGroupId?: string; - entraGroupName?: string; - force?: boolean; -} - class SpoWebRoleAssignmentRemoveCommand extends SpoCommand { public get name(): string { return commands.WEB_ROLEASSIGNMENT_REMOVE; @@ -31,79 +39,18 @@ class SpoWebRoleAssignmentRemoveCommand extends SpoCommand { return 'Removes a role assignment from web permissions'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - principalId: typeof args.options.principalId !== 'undefined', - upn: typeof args.options.upn !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined', - entraGroupId: typeof args.options.entraGroupId !== 'undefined', - entraGroupName: typeof args.options.entraGroupName !== 'undefined', - force: (!(!args.options.force)).toString() - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl <webUrl>' - }, - { - option: '--principalId [principalId]' - }, - { - option: '--upn [upn]' - }, - { - option: '--groupName [groupName]' - }, - { - option: '--entraGroupId [entraGroupId]' - }, - { - option: '--entraGroupName [entraGroupName]' - }, - { - option: '-f, --force' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - if (args.options.principalId && isNaN(args.options.principalId)) { - return `Specified principalId ${args.options.principalId} is not a number`; - } - - if (args.options.entraGroupId && !validation.isValidGuid(args.options.entraGroupId)) { - return `'${args.options.entraGroupId}' is not a valid GUID for option entraGroupId.`; - } - - return true; + public getRefinedSchema(schema: typeof options): z.ZodObject<any> | undefined { + return schema.refine(options => [options.principalId, options.upn, options.groupName, options.entraGroupId, options.entraGroupName].filter(x => x !== undefined).length === 1, { + error: `Specify either 'principalId', 'upn', 'groupName', 'entraGroupId', or 'entraGroupName'.`, + params: { + customCode: 'optionSet', + options: ['principalId', 'upn', 'groupName', 'entraGroupId', 'entraGroupName'] } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['principalId', 'upn', 'groupName', 'entraGroupId', 'entraGroupName'] } - ); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { @@ -125,7 +72,10 @@ class SpoWebRoleAssignmentRemoveCommand extends SpoCommand { } try { - if (options.upn) { + if (options.principalId) { + await this.removeRoleAssignmentWithOptions(options.webUrl, options.principalId, logger); + } + else if (options.upn) { const principalId = await this.getUserPrincipalId(options, logger); await this.removeRoleAssignmentWithOptions(options.webUrl, principalId, logger); } diff --git a/src/m365/spo/commands/web/web-roleinheritance-break.spec.ts b/src/m365/spo/commands/web/web-roleinheritance-break.spec.ts index 886b7d76ee3..0f241303089 100644 --- a/src/m365/spo/commands/web/web-roleinheritance-break.spec.ts +++ b/src/m365/spo/commands/web/web-roleinheritance-break.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-roleinheritance-break.js'; +import command, { options } from './web-roleinheritance-break.js'; describe(commands.WEB_ROLEINHERITANCE_BREAK, () => { let log: any[]; let logger: Logger; let promptIssued: boolean = false; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -26,6 +27,7 @@ describe(commands.WEB_ROLEINHERITANCE_BREAK, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -69,18 +71,19 @@ describe(commands.WEB_ROLEINHERITANCE_BREAK, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ - options: { - webUrl: "https://contoso.sharepoint.com/subsite" - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the url option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: "https://contoso.sharepoint.com/subsite" }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: "https://contoso.sharepoint.com/subsite", unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('should prompt before breaking when confirmation argument not passed', async () => { @@ -92,7 +95,7 @@ describe(commands.WEB_ROLEINHERITANCE_BREAK, () => { throw 'Invalid request URL: ' + opts.url; }); - await command.action(logger, { options: { webUrl: "https://contoso.sharepoint.com/subsite" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: "https://contoso.sharepoint.com/subsite" }) }); assert(promptIssued); }); @@ -109,9 +112,9 @@ describe(commands.WEB_ROLEINHERITANCE_BREAK, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: "https://contoso.sharepoint.com/subsite" - } + }) }); }); @@ -125,9 +128,9 @@ describe(commands.WEB_ROLEINHERITANCE_BREAK, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: "https://contoso.sharepoint.com/subsite" - } + }) }); assert(sinonStub.notCalled); @@ -143,12 +146,12 @@ describe(commands.WEB_ROLEINHERITANCE_BREAK, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: 'https://contoso.sharepoint.com/subsite', clearExistingPermissions: true, force: true - } + }) }); }); @@ -157,10 +160,10 @@ describe(commands.WEB_ROLEINHERITANCE_BREAK, () => { sinon.stub(request, 'post').callsFake(async () => { throw { error: { message: errorMessage } }; }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com/subsite', force: true - } + }) }), new CommandError(errorMessage)); }); }); diff --git a/src/m365/spo/commands/web/web-roleinheritance-break.ts b/src/m365/spo/commands/web/web-roleinheritance-break.ts index d8472462fdb..cf1430bb073 100644 --- a/src/m365/spo/commands/web/web-roleinheritance-break.ts +++ b/src/m365/spo/commands/web/web-roleinheritance-break.ts @@ -1,21 +1,27 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u'), + clearExistingPermissions: z.boolean().optional().alias('c'), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - clearExistingPermissions?: boolean; - force?: boolean; -} - class SpoWebRoleInheritanceBreakCommand extends SpoCommand { public get name(): string { return commands.WEB_ROLEINHERITANCE_BREAK; @@ -25,48 +31,8 @@ class SpoWebRoleInheritanceBreakCommand extends SpoCommand { return 'Breaks role inheritance of subsite'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - clearExistingPermissions: !!args.options.clearExistingPermissions, - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl <webUrl>' - }, - { - option: '-c, --clearExistingPermissions' - }, - { - option: '-f, --force' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - return true; - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/spo/commands/web/web-roleinheritance-reset.spec.ts b/src/m365/spo/commands/web/web-roleinheritance-reset.spec.ts index 9fe5a8df04c..301934b4d60 100644 --- a/src/m365/spo/commands/web/web-roleinheritance-reset.spec.ts +++ b/src/m365/spo/commands/web/web-roleinheritance-reset.spec.ts @@ -11,12 +11,13 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-roleinheritance-reset.js'; +import command, { options } from './web-roleinheritance-reset.js'; describe(commands.WEB_ROLEINHERITANCE_RESET, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let promptIssued: boolean = false; before(() => { @@ -26,6 +27,7 @@ describe(commands.WEB_ROLEINHERITANCE_RESET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -69,25 +71,19 @@ describe(commands.WEB_ROLEINHERITANCE_RESET, () => { assert.notStrictEqual(command.description, null); }); - it('supports specifying URL', () => { - const options = command.options; - let containsTypeOption = false; - options.forEach(o => { - if (o.option.indexOf('<webUrl>') > -1) { - containsTypeOption = true; - } - }); - assert(containsTypeOption); + it('fails validation if the url option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if the url option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('reset role inheritance of subsite', async () => { @@ -100,11 +96,11 @@ describe(commands.WEB_ROLEINHERITANCE_RESET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', force: true - } + }) }); }); @@ -119,22 +115,22 @@ describe(commands.WEB_ROLEINHERITANCE_RESET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', force: true - } + }) } as any), new CommandError(err)); }); it('aborts resetting role inheritance when prompt not confirmed', async () => { const postSpy = sinon.spy(request, 'post'); - await command.action(logger, { options: { debug: true, webUrl: 'https://contoso.sharepoint.com' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com' }) }); assert(postSpy.notCalled); }); it('prompts before resetting role inheritance when confirmation argument not passed', async () => { - await command.action(logger, { options: { debug: true, webUrl: 'https://contoso.sharepoint.com' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com' }) }); assert(promptIssued); }); @@ -151,7 +147,7 @@ describe(commands.WEB_ROLEINHERITANCE_RESET, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { debug: true, webUrl: 'https://contoso.sharepoint.com' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com' }) }); assert(resetInheritanceCallIssued); }); }); diff --git a/src/m365/spo/commands/web/web-roleinheritance-reset.ts b/src/m365/spo/commands/web/web-roleinheritance-reset.ts index 5d39c2e6351..c449e1ad5f2 100644 --- a/src/m365/spo/commands/web/web-roleinheritance-reset.ts +++ b/src/m365/spo/commands/web/web-roleinheritance-reset.ts @@ -1,20 +1,26 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u'), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - force?: boolean; -} - class SpoWebRoleInheritanceResetCommand extends SpoCommand { public get name(): string { return commands.WEB_ROLEINHERITANCE_RESET; @@ -24,39 +30,8 @@ class SpoWebRoleInheritanceResetCommand extends SpoCommand { return 'Restores role inheritance of subsite'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: (!(!args.options.force)).toString() - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl <webUrl>' - }, - { - option: '-f, --force' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - return validation.isValidSharePointUrl(args.options.webUrl); - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { diff --git a/src/m365/spo/commands/web/web-set.spec.ts b/src/m365/spo/commands/web/web-set.spec.ts index af20dfaa517..24c88688c63 100644 --- a/src/m365/spo/commands/web/web-set.spec.ts +++ b/src/m365/spo/commands/web/web-set.spec.ts @@ -11,12 +11,13 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './web-set.js'; +import command, { options } from './web-set.js'; describe(commands.WEB_SET, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -25,6 +26,7 @@ describe(commands.WEB_SET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -72,7 +74,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', title: 'New title' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', title: 'New title' }) }); }); it('updates site logo URL', async () => { @@ -86,7 +88,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', siteLogoUrl: 'image.png' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', siteLogoUrl: 'image.png' }) }); }); it('unsets the site logo', async () => { @@ -100,7 +102,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', siteLogoUrl: '' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', siteLogoUrl: '' }) }); }); it('disables quick launch', async () => { @@ -114,7 +116,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', quickLaunchEnabled: false } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', quickLaunchEnabled: false }) }); }); it('enables quick launch', async () => { @@ -128,7 +130,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', quickLaunchEnabled: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', quickLaunchEnabled: true }) }); }); it('sets site header to compact', async () => { @@ -142,7 +144,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerLayout: 'compact' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerLayout: 'compact' }) }); }); it('sets site header to standard', async () => { @@ -156,7 +158,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerLayout: 'standard' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerLayout: 'standard' }) }); }); it('sets site header emphasis to 0', async () => { @@ -170,7 +172,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 0 } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: '0' }) }); }); it('sets site header emphasis to 1', async () => { @@ -184,7 +186,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 1 } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: '1' }) }); }); it('sets site header emphasis to 2', async () => { @@ -198,7 +200,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 2 } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: '2' }) }); }); it('sets site header emphasis to 3', async () => { @@ -212,7 +214,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 3 } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: '3' }) }); }); it('sets site menu mode to megamenu', async () => { @@ -226,7 +228,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', megaMenuEnabled: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', megaMenuEnabled: true }) }); }); it('sets site menu mode to cascading', async () => { @@ -240,7 +242,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', megaMenuEnabled: false } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', megaMenuEnabled: false }) }); }); it('updates all properties', async () => { @@ -252,7 +254,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', title: 'New title', description: 'New description', siteLogoUrl: 'image.png', quickLaunchEnabled: true, headerLayout: 'compact', headerEmphasis: 1, megaMenuEnabled: true, footerEnabled: true, navAudienceTargetingEnabled: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', title: 'New title', description: 'New description', siteLogoUrl: 'image.png', quickLaunchEnabled: true, headerLayout: 'compact', headerEmphasis: '1', megaMenuEnabled: true, footerEnabled: true, navAudienceTargetingEnabled: true }) }); }); it('Update Welcome page', async () => { @@ -267,7 +269,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { welcomePage: 'SitePages/Home.aspx', url: 'https://contoso.sharepoint.com/sites/team-a' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ welcomePage: 'SitePages/Home.aspx', url: 'https://contoso.sharepoint.com/sites/team-a' }) }); }); it('Update Welcome page (debug)', async () => { @@ -282,7 +284,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, welcomePage: 'SitePages/Home.aspx', url: 'https://contoso.sharepoint.com/sites/team-a' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, welcomePage: 'SitePages/Home.aspx', url: 'https://contoso.sharepoint.com/sites/team-a' }) }); }); it('correctly handles error when hub site not found', async () => { @@ -300,7 +302,7 @@ describe(commands.WEB_SET, () => { }; }); - await assert.rejects(command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a' } }), new CommandError("Exception of type 'Microsoft.SharePoint.Client.ResourceNotFoundException' was thrown.")); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a' }) }), new CommandError("Exception of type 'Microsoft.SharePoint.Client.ResourceNotFoundException' was thrown.")); }); it('correctly handles error while updating Welcome page', async () => { @@ -326,10 +328,10 @@ describe(commands.WEB_SET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ welcomePage: 'https://contoso.sharepoint.com/sites/team-a/SitePages/Home.aspx', url: 'https://contoso.sharepoint.com/sites/team-a' - } + }) }), new CommandError('The WelcomePage property must be a path that is relative to the folder, and the path cannot contain two consecutive periods (..).')); }); @@ -338,105 +340,94 @@ describe(commands.WEB_SET, () => { assert.strictEqual(allowUnknownOptions, true); }); - it('supports specifying url', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--url') > -1) { - containsOption = true; - } - }); - assert(containsOption); + it('fails validation if url is not a valid SharePoint URL', () => { + const actual = commandOptionsSchema.safeParse({ url: 'abc' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if url is not a valid SharePoint URL', async () => { - const actual = await command.validate({ options: { url: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation when the url is a valid SharePoint URL', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a' }); + assert.strictEqual(actual.success, true); }); - it('passes validation when the url is a valid SharePoint URL', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when the url is a valid SharePoint URL and quickLaunch set to "true"', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', quickLaunchEnabled: true }); + assert.strictEqual(actual.success, true); }); - it('passes validation when the url is a valid SharePoint URL and quickLaunch set to "true"', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', quickLaunchEnabled: true } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if headerLayout is invalid', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerLayout: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if headerLayout is invalid', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerLayout: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if headerLayout is set to standard', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerLayout: 'standard' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if headerLayout is set to standard', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerLayout: 'standard' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if headerLayout is set to compact', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerLayout: 'compact' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if headerLayout is set to compact', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerLayout: 'compact' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if headerEmphasis is not a number', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 'abc' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if headerEmphasis is not a number', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if headerEmphasis is out of bounds', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: '4' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if headerEmphasis is out of bounds', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 4 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if headerEmphasis is 0', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: '0' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if headerEmphasis is 0', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 0 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if headerEmphasis is 1', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: '1' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if headerEmphasis is 1', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 1 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if headerEmphasis is 2', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: '2' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if headerEmphasis is 2', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 2 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if headerEmphasis is 3', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: '3' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if headerEmphasis is 3', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', headerEmphasis: 3 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if megaMenuEnabled is set to true', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', megaMenuEnabled: true }); + assert.strictEqual(actual.success, true); }); - it('passes validation if megaMenuEnabled is set to true', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', megaMenuEnabled: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if megaMenuEnabled is set to false', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', megaMenuEnabled: false }); + assert.strictEqual(actual.success, true); }); - it('passes validation if megaMenuEnabled is set to false', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', megaMenuEnabled: false } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if footerEnabled is set to true', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', footerEnabled: true }); + assert.strictEqual(actual.success, true); }); - it('passes validation if footerEnabled is set to true', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', footerEnabled: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if footerEnabled is set to false', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', footerEnabled: false }); + assert.strictEqual(actual.success, true); }); - it('passes validation if footerEnabled is set to false', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', footerEnabled: false } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if navAudienceTargetingEnabled is set to true', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', navAudienceTargetingEnabled: true }); + assert.strictEqual(actual.success, true); }); - it('passes validation if navAudienceTargetingEnabled is set to true', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', navAudienceTargetingEnabled: true } }, commandInfo); - assert.strictEqual(actual, true); - }); - - it('passes validation if navAudienceTargetingEnabled is set to false', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', navAudienceTargetingEnabled: false } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if navAudienceTargetingEnabled is set to false', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', navAudienceTargetingEnabled: false }); + assert.strictEqual(actual.success, true); }); it('enables footer', async () => { @@ -450,7 +441,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', footerEnabled: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', footerEnabled: true }) }); }); it('disables footer', async () => { @@ -464,7 +455,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', footerEnabled: false } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', footerEnabled: false }) }); }); it('enables navAudienceTargetingEnabled', async () => { @@ -480,43 +471,48 @@ describe(commands.WEB_SET, () => { NavAudienceTargetingEnabled: true }; - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', navAudienceTargetingEnabled: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', navAudienceTargetingEnabled: true }) }); assert.deepStrictEqual(postRequestStub.lastCall.args[0].data, requestBody); }); - it('fails validation if search scope is not valid', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if search scope is not valid', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'invalid' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if search scope is set to defaultscope', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'defaultscope' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if search scope is set to defaultscope', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'defaultscope' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if search scope is set to tenant', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'tenant' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if search scope is set to tenant', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'tenant' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if search scope is set to hub', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'hub' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if search scope is set to hub', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'hub' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if search scope is set to site', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'site' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if search scope is set to site', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'site' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation even if search scope is not all lower case', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'DefaultScope' }); + assert.strictEqual(actual.success, true); }); - it('passes validation even if search scope is not all lower case', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'DefaultScope' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if search scope passed is a number', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 2 }); + assert.strictEqual(actual.success, false); }); - it('fails validation if search scope passed is a number', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 2 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation with an unknown option', () => { + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com/sites/team-a', unknownOption: 'value' }); + assert.strictEqual(actual.success, true); }); it('sets search scope to default scope', async () => { @@ -530,7 +526,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'defaultscope' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'defaultscope' }) }); }); it('sets search scope to tenant', async () => { @@ -544,7 +540,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'tenant' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'tenant' }) }); }); it('sets search scope to hub', async () => { @@ -558,7 +554,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'hub' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'hub' }) }); }); it('sets search scope to site', async () => { @@ -572,7 +568,7 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'site' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'site' }) }); }); it('sets search scope even if parameter is not all lower case', async () => { @@ -586,6 +582,6 @@ describe(commands.WEB_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'Site' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ url: 'https://contoso.sharepoint.com/sites/team-a', searchScope: 'Site' }) }); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/web/web-set.ts b/src/m365/spo/commands/web/web-set.ts index 9acdee9cd91..43f3ce1677b 100644 --- a/src/m365/spo/commands/web/web-set.ts +++ b/src/m365/spo/commands/web/web-set.ts @@ -1,29 +1,37 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +export const options = z.looseObject({ + ...globalOptionsZod.shape, + description: z.string().optional().alias('d'), + headerEmphasis: z.enum(['0', '1', '2', '3'], { + error: e => `${e.input} is not a valid value for headerEmphasis. Allowed values are 0|1|2|3` + }).optional(), + headerLayout: z.enum(['standard', 'compact']).optional(), + megaMenuEnabled: z.boolean().optional(), + quickLaunchEnabled: z.boolean().optional(), + siteLogoUrl: z.string().optional(), + title: z.string().optional().alias('t'), + url: z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `${e.input} is not a valid SharePoint Online site URL.` + }).alias('u'), + footerEnabled: z.boolean().optional(), + navAudienceTargetingEnabled: z.boolean().optional(), + searchScope: z.preprocess(value => String(value).toLowerCase(), z.enum(['defaultscope', 'tenant', 'hub', 'site'])).optional(), + welcomePage: z.string().optional() +}); + +declare type Options = z.infer<typeof options>; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - description?: string; - headerEmphasis?: number; - headerLayout?: string; - megaMenuEnabled?: boolean; - quickLaunchEnabled?: boolean; - siteLogoUrl?: string; - title?: string; - url: string; - footerEnabled?: boolean; - navAudienceTargetingEnabled?: boolean; - searchScope?: string; - welcomePage?: string; -} - class SpoWebSetCommand extends SpoCommand { private static searchScopeOptions: string[] = ['defaultscope', 'tenant', 'hub', 'site']; @@ -35,126 +43,14 @@ class SpoWebSetCommand extends SpoCommand { return 'Updates subsite properties'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initTypes(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - description: typeof args.options.description !== 'undefined', - headerEmphasis: typeof args.options.headerEmphasis !== 'undefined', - headerLayout: typeof args.options.headerLayout !== 'undefined', - megaMenuEnabled: typeof args.options.megaMenuEnabled !== 'undefined', - siteLogoUrl: typeof args.options.siteLogoUrl !== 'undefined', - title: typeof args.options.title !== 'undefined', - quickLaunchEnabled: typeof args.options.quickLaunchEnabled !== 'undefined', - footerEnabled: typeof args.options.footerEnabled !== 'undefined', - navAudienceTargetingEnabled: typeof args.options.navAudienceTargetingEnabled !== 'undefined', - searchScope: typeof args.options.searchScope !== 'undefined', - welcomePage: typeof args.options.welcomePage !== 'undefined' - }); - this.trackUnknownOptions(this.telemetryProperties, args.options); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --url <url>' - }, - { - option: '-t, --title [title]' - }, - { - option: '-d, --description [description]' - }, - { - option: '--siteLogoUrl [siteLogoUrl]' - }, - { - option: '--quickLaunchEnabled [quickLaunchEnabled]', - autocomplete: ['true', 'false'] - }, - { - option: '--headerLayout [headerLayout]', - autocomplete: ['standard', 'compact'] - }, - { - option: '--headerEmphasis [headerEmphasis]', - autocomplete: ['0', '1', '2', '3'] - }, - { - option: '--megaMenuEnabled [megaMenuEnabled]', - autocomplete: ['true', 'false'] - }, - { - option: '--footerEnabled [footerEnabled]', - autocomplete: ['true', 'false'] - }, - { - option: '--navAudienceTargetingEnabled [navAudienceTargetingEnabled]', - autocomplete: ['true', 'false'] - }, - { - option: '--searchScope [searchScope]', - autocomplete: SpoWebSetCommand.searchScopeOptions - }, - { - option: '--welcomePage [welcomePage]' - } - ); - } - - #initTypes(): void { - this.types.boolean.push('megaMenuEnabled', 'footerEnabled', 'quickLaunchEnabled', 'navAudienceTargetingEnabled'); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.url); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - if (typeof args.options.headerEmphasis !== 'undefined') { - if (isNaN(args.options.headerEmphasis)) { - return `${args.options.headerEmphasis} is not a number`; - } - - if ([0, 1, 2, 3].indexOf(args.options.headerEmphasis) < 0) { - return `${args.options.headerEmphasis} is not a valid value for headerEmphasis. Allowed values are 0|1|2|3`; - } - } - - if (typeof args.options.headerLayout !== 'undefined') { - if (['standard', 'compact'].indexOf(args.options.headerLayout) < 0) { - return `${args.options.headerLayout} is not a valid value for headerLayout. Allowed values are standard|compact`; - } - } - - if (typeof args.options.searchScope !== 'undefined') { - const searchScope = args.options.searchScope.toString().toLowerCase(); - if (SpoWebSetCommand.searchScopeOptions.indexOf(searchScope) < 0) { - return `${args.options.searchScope} is not a valid value for searchScope. Allowed values are DefaultScope|Tenant|Hub|Site`; - } - } - - return true; - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { const payload: any = {}; - this.addUnknownOptionsToPayload(payload, args.options); + this.addUnknownOptionsToPayloadZod(payload, args.options); if (args.options.title) { payload.Title = args.options.title; @@ -169,7 +65,7 @@ class SpoWebSetCommand extends SpoCommand { payload.QuickLaunchEnabled = args.options.quickLaunchEnabled; } if (typeof args.options.headerEmphasis !== 'undefined') { - payload.HeaderEmphasis = args.options.headerEmphasis; + payload.HeaderEmphasis = Number(args.options.headerEmphasis); } if (typeof args.options.headerLayout !== 'undefined') { payload.HeaderLayout = args.options.headerLayout === 'standard' ? 1 : 2; diff --git a/src/utils/fsUtil.spec.ts b/src/utils/fsUtil.spec.ts deleted file mode 100644 index 7087f1cb816..00000000000 --- a/src/utils/fsUtil.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import assert from 'assert'; -import fs from 'fs'; -import path from 'path'; -import sinon from 'sinon'; -import { fsUtil } from './fsUtil.js'; - -describe('utils/fsUtil', () => { - afterEach(() => { - sinon.restore(); - }); - - describe('copyRecursiveSync', () => { - it('copies a directory recursively creating dest if it does not exist', () => { - sinon.stub(fs, 'existsSync') - .withArgs('src').returns(true) - .withArgs('dest').returns(false); - sinon.stub(fs, 'statSync').returns({ isDirectory: () => true } as fs.Stats); - const mkdirStub = sinon.stub(fs, 'mkdirSync'); - sinon.stub(fs, 'readdirSync').returns(['file1.txt'] as any); - const copyFileStub = sinon.stub(fs, 'copyFileSync'); - // child is a file - (fs.existsSync as sinon.SinonStub) - .withArgs(path.join('src', 'file1.txt')).returns(true); - (fs.statSync as sinon.SinonStub) - .withArgs(path.join('src', 'file1.txt')).returns({ isDirectory: () => false } as fs.Stats); - - fsUtil.copyRecursiveSync('src', 'dest'); - - assert(mkdirStub.calledWith('dest')); - assert(copyFileStub.calledWith(path.join('src', 'file1.txt'), path.join('dest', 'file1.txt'))); - }); - - it('copies a directory recursively when dest already exists', () => { - sinon.stub(fs, 'existsSync').returns(true); - sinon.stub(fs, 'statSync') - .withArgs('src').returns({ isDirectory: () => true } as fs.Stats) - .withArgs(path.join('src', 'child.txt')).returns({ isDirectory: () => false } as fs.Stats); - const mkdirStub = sinon.stub(fs, 'mkdirSync'); - sinon.stub(fs, 'readdirSync').returns(['child.txt'] as any); - const copyFileStub = sinon.stub(fs, 'copyFileSync'); - - fsUtil.copyRecursiveSync('src', 'dest'); - - assert(mkdirStub.notCalled); - assert(copyFileStub.calledWith(path.join('src', 'child.txt'), path.join('dest', 'child.txt'))); - }); - - it('applies replaceTokens to destination path', () => { - sinon.stub(fs, 'existsSync') - .withArgs('src').returns(true) - .withArgs('replaced-dest').returns(false); - sinon.stub(fs, 'statSync').returns({ isDirectory: () => true } as fs.Stats); - const mkdirStub = sinon.stub(fs, 'mkdirSync'); - sinon.stub(fs, 'readdirSync').returns([] as any); - - fsUtil.copyRecursiveSync('src', 'dest', (s: string) => s === 'dest' ? 'replaced-dest' : s); - - assert(mkdirStub.calledWith('replaced-dest')); - }); - - it('copies a single file', () => { - sinon.stub(fs, 'existsSync').returns(true); - sinon.stub(fs, 'statSync').returns({ isDirectory: () => false } as fs.Stats); - const copyFileStub = sinon.stub(fs, 'copyFileSync'); - - fsUtil.copyRecursiveSync('src/file.txt', 'dest/file.txt'); - - assert(copyFileStub.calledWith('src/file.txt', 'dest/file.txt')); - }); - }); -});