diff --git a/src/m365/spo/commands/folder/folder-add.spec.ts b/src/m365/spo/commands/folder/folder-add.spec.ts index 5b462b19a9b..4f29b2e0924 100644 --- a/src/m365/spo/commands/folder/folder-add.spec.ts +++ b/src/m365/spo/commands/folder/folder-add.spec.ts @@ -12,13 +12,15 @@ 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 './folder-add.js'; +import command, { options } from './folder-add.js'; +import { FolderColorValues } from './FolderColor.js'; describe(commands.FOLDER_ADD, () => { let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let stubPostResponses: any; const addResponse = { "Exists": true, "IsWOPIEnabled": false, "ItemCount": 0, "Name": "abc", "ProgID": null, "ServerRelativeUrl": "/sites/test1/Shared Documents/abc", "TimeCreated": "2018-05-02T23:21:45Z", "TimeLastModified": "2018-05-02T23:21:45Z", "UniqueId": "0ac3da45-cacf-4c31-9b38-9ef3697d5a66", "WelcomePage": "" }; @@ -52,6 +54,7 @@ describe(commands.FOLDER_ADD, () => { }); }; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -104,24 +107,24 @@ describe(commands.FOLDER_ADD, () => { stubPostResponses(error); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com', parentFolderUrl: '/Shared Documents', name: 'abc' - } - } as any), new CommandError('An error has occurred')); + }) + }), new CommandError('An error has occurred')); }); it('should correctly handle folder add success request', async () => { stubPostResponses(); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', parentFolderUrl: '/Shared Documents', name: 'abc' - } + }) }); assert(loggerLogSpy.lastCall.calledWith({ "Exists": true, "IsWOPIEnabled": false, "ItemCount": 0, "Name": "abc", "ProgID": null, "ServerRelativeUrl": "/sites/test1/Shared Documents/abc", "TimeCreated": "2018-05-02T23:21:45Z", "TimeLastModified": "2018-05-02T23:21:45Z", "UniqueId": "0ac3da45-cacf-4c31-9b38-9ef3697d5a66", "WelcomePage": "" })); }); @@ -130,12 +133,12 @@ describe(commands.FOLDER_ADD, () => { const request: sinon.SinonStub = stubPostResponses(); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', parentFolderUrl: '/Shared Documents', name: 'abc' - } + }) }); assert(request.calledWith({ url: `https://contoso.sharepoint.com/_api/web/folders/addUsingPath(decodedUrl='${formatting.encodeQueryParameter('/Shared Documents/abc')}')`, @@ -149,12 +152,12 @@ describe(commands.FOLDER_ADD, () => { const request: sinon.SinonStub = stubPostResponses(); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com/sites/test1', parentFolderUrl: 'Shared Documents', name: 'abc' - } + }) }); assert(request.calledWith({ url: `https://contoso.sharepoint.com/sites/test1/_api/web/folders/addUsingPath(decodedUrl='${formatting.encodeQueryParameter('/sites/test1/Shared Documents/abc')}')`, @@ -172,7 +175,7 @@ describe(commands.FOLDER_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, color: colorNumber } }); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, color: colorNumber }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { coloringInformation: { ColorHex: `${colorNumber}` @@ -188,7 +191,7 @@ describe(commands.FOLDER_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, color: colorName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, color: colorName }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { coloringInformation: { ColorHex: colorNumber @@ -220,13 +223,13 @@ describe(commands.FOLDER_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, parentFolderUrl: '/Shared Documents/abc', name: 'folder1', ensureParentFolders: true - } + }) }); assert.strictEqual(requestGet.lastCall.args[0].url, `https://contoso.sharepoint.com/_api/web/GetFolderByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter('Shared Documents/abc')}')?$select=Exists`); @@ -234,28 +237,35 @@ describe(commands.FOLDER_ADD, () => { assert.strictEqual(requestPost.callCount, 2); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', parentFolderUrl: parentFolder, name: folderName } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', parentFolderUrl: parentFolder, name: folderName }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); + }); + + it('passes validation if the webUrl option is a valid SharePoint site URL and parentFolderUrl specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the webUrl option is a valid SharePoint site URL and parentFolderUrl specified', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if color is passed as string and color is not a valid color', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, color: 'invalid' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid value for option 'color'. Allowed values are ${Object.keys(FolderColorValues).join(', ')}, ${Object.values(FolderColorValues).join(', ')}.`); }); - it('fails validation if color is passed as string and color is not a valid color', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, color: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if color is passed as string and color is a valid color', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, color: colorName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if color is passed as string and color is a valid color', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, color: colorName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if color is passed as number and color is a valid color', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, color: colorNumber }); + assert.strictEqual(actual.success, true); }); - it('passes validation if color is passed as number and color is a valid color', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, color: colorNumber } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, parentFolderUrl: parentFolder, name: folderName, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/folder/folder-add.ts b/src/m365/spo/commands/folder/folder-add.ts index 2a2681c0980..9cf3c30c09f 100644 --- a/src/m365/spo/commands/folder/folder-add.ts +++ b/src/m365/spo/commands/folder/folder-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, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -9,18 +10,29 @@ import commands from '../../commands.js'; import { FolderColorValues } from './FolderColor.js'; import { FolderProperties } from './FolderProperties.js'; +const folderColorValues = [...Object.keys(FolderColorValues), ...Object.values(FolderColorValues)] as [string, ...string[]]; + +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'), + parentFolderUrl: z.string().alias('p'), + name: z.string().alias('n'), + color: z.enum(folderColorValues, { + error: e => `'${e.input}' is not a valid value for option 'color'. Allowed values are ${Object.keys(FolderColorValues).join(', ')}, ${Object.values(FolderColorValues).join(', ')}.` + }).optional(), + ensureParentFolders: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - parentFolderUrl: string; - name: string; - color?: string; - ensureParentFolders?: boolean -} - class SpoFolderAddCommand extends SpoCommand { public get name(): string { return commands.FOLDER_ADD; @@ -30,64 +42,8 @@ class SpoFolderAddCommand extends SpoCommand { return 'Creates a folder within a parent folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - color: typeof args.options.color !== 'undefined', - ensureParentFolders: !!args.options.ensureParentFolders - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '-p, --parentFolderUrl ' - }, - { - option: '-n, --name ' - }, - { - option: '--color [color]', - autocomplete: Object.keys(FolderColorValues) - }, - { - option: '--ensureParentFolders' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - if (args.options.color && !Object.entries(FolderColorValues).flat().includes(args.options.color)) { - return `'${args.options.color}' is not a valid value for option 'color'. Allowed values are ${Object.keys(FolderColorValues).join(', ')}, ${Object.values(FolderColorValues).join(', ')}.`; - } - - return true; - }); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'parentFolderUrl', 'name', 'color'); - this.types.boolean.push('ensureParentFolders'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-copy.spec.ts b/src/m365/spo/commands/folder/folder-copy.spec.ts index e81c3763577..916523930c0 100644 --- a/src/m365/spo/commands/folder/folder-copy.spec.ts +++ b/src/m365/spo/commands/folder/folder-copy.spec.ts @@ -10,7 +10,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 './folder-copy.js'; +import command, { options } from './folder-copy.js'; import { CreateFolderCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; import { settingsNames } from '../../../../settingsNames.js'; import { CommandError } from '../../../../Command.js'; @@ -70,6 +70,7 @@ describe(commands.FOLDER_COPY, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let loggerLogSpy: sinon.SinonSpy; let spoUtilCreateCopyJobStub: sinon.SinonStub; @@ -83,6 +84,7 @@ describe(commands.FOLDER_COPY, () => { auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => settingName === settingsNames.prompt ? false : defaultValue); spoUtilCreateCopyJobStub = sinon.stub(spo, 'createFolderCopyJob').resolves(copyJobInfo); spoUtilGetCopyJobResultStub = sinon.stub(spo, 'getCopyJobResult').resolves(copyJobResult); @@ -130,29 +132,47 @@ describe(commands.FOLDER_COPY, () => { assert.deepStrictEqual((command as any).getExcludedOptionsWithUrls(), ['targetUrl', 'sourceUrl']); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('fails validation if sourceId is not a valid guid', async () => { - const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceId: 'invalid', targetUrl: destServerRelUrl } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if sourceId is not a valid guid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceId: 'invalid', targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for sourceId.`); }); - it('fails validation if nameConflictBehavior is not valid', async () => { - const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl, nameConflictBehavior: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if nameConflictBehavior is not valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl, nameConflictBehavior: 'invalid' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid value for nameConflictBehavior. Allowed values are: fail, rename.`); }); - it('passes validation if the sourceId is a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destServerRelUrl } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both sourceUrl and sourceId are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, sourceId: sourceFolderId, targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the webUrl option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if neither sourceUrl nor sourceId are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if the sourceId is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if the webUrl option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('correctly outputs exactly one result when folder is copied when using sourceId', async () => { @@ -171,12 +191,12 @@ describe(commands.FOLDER_COPY, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destAbsoluteTargetUrl - } + }) }); assert(loggerLogSpy.calledOnce); @@ -198,12 +218,12 @@ describe(commands.FOLDER_COPY, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destAbsoluteTargetUrl - } + }) }); assert(loggerLogSpy.calledOnce); @@ -220,12 +240,12 @@ describe(commands.FOLDER_COPY, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl - } + }) }); assert(loggerLogSpy.calledOnce); @@ -248,11 +268,11 @@ describe(commands.FOLDER_COPY, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destAbsoluteTargetUrl - } + }) }); assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ @@ -277,12 +297,12 @@ describe(commands.FOLDER_COPY, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'fail' - } + }) }); assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ @@ -307,12 +327,12 @@ describe(commands.FOLDER_COPY, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceSiteRelUrl, targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'fail' - } + }) }); assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ @@ -337,12 +357,12 @@ describe(commands.FOLDER_COPY, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceAbsoluteUrl, targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'rename' - } + }) }); assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ @@ -367,13 +387,13 @@ describe(commands.FOLDER_COPY, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'rename', newName: 'Folder-renamed' - } + }) }); assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ @@ -398,11 +418,11 @@ describe(commands.FOLDER_COPY, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl - } + }) }); assert.deepStrictEqual(spoUtilGetCopyJobResultStub.lastCall.args, [ @@ -413,12 +433,12 @@ describe(commands.FOLDER_COPY, () => { it('outputs no result when skipWait is specified', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl, skipWait: true - } + }) }); assert(loggerLogSpy.notCalled); @@ -426,12 +446,12 @@ describe(commands.FOLDER_COPY, () => { it('correctly skips polling when skipWait is specified', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl, skipWait: true - } + }) }); assert(spoUtilGetCopyJobResultStub.notCalled); @@ -450,11 +470,11 @@ describe(commands.FOLDER_COPY, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destAbsoluteTargetUrl - } + }) }), new CommandError('Folder Not Found.')); }); @@ -463,11 +483,11 @@ describe(commands.FOLDER_COPY, () => { spoUtilGetCopyJobResultStub = sinon.stub(spo, 'getCopyJobResult').rejects(new Error('Target folder already exists.')); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl - } + }) }), new CommandError('Target folder already exists.')); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-copy.ts b/src/m365/spo/commands/folder/folder-copy.ts index 9d1b16180e8..a7c8aa79a63 100644 --- a/src/m365/spo/commands/folder/folder-copy.ts +++ b/src/m365/spo/commands/folder/folder-copy.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 { CreateFolderCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -7,23 +8,37 @@ import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +const nameConflictBehaviorOptions = ['fail', 'rename'] as const; + +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'), + sourceUrl: z.string().optional().alias('s'), + sourceId: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for sourceId.` + }) + .optional() + .alias('i'), + targetUrl: z.string().alias('t'), + newName: z.string().optional(), + nameConflictBehavior: z.enum(nameConflictBehaviorOptions, { + error: e => `'${e.input}' is not a valid value for nameConflictBehavior. Allowed values are: ${nameConflictBehaviorOptions.join(', ')}.` + }).optional(), + skipWait: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - sourceUrl?: string; - sourceId?: string; - targetUrl: string; - newName?: string; - nameConflictBehavior?: string; - skipWait?: boolean; -} - class SpoFolderCopyCommand extends SpoCommand { - private readonly nameConflictBehaviorOptions = ['fail', 'rename']; - public get name(): string { return commands.FOLDER_COPY; } @@ -32,83 +47,19 @@ class SpoFolderCopyCommand extends SpoCommand { return 'Copies a folder to another location'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - sourceUrl: typeof args.options.sourceUrl !== 'undefined', - sourceId: typeof args.options.sourceId !== 'undefined', - newName: typeof args.options.newName !== 'undefined', - nameConflictBehavior: typeof args.options.nameConflictBehavior !== 'undefined', - skipWait: !!args.options.skipWait - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '-s, --sourceUrl [sourceUrl]' - }, - { - option: '-i, --sourceId [sourceId]' - }, - { - option: '-t, --targetUrl ' - }, - { - option: '--newName [newName]' - }, - { - option: '--nameConflictBehavior [nameConflictBehavior]', - autocomplete: this.nameConflictBehaviorOptions - }, - { - option: '--skipWait' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - if (args.options.sourceId && !validation.isValidGuid(args.options.sourceId)) { - return `'${args.options.sourceId}' is not a valid GUID for sourceId.`; - } - - if (args.options.nameConflictBehavior && this.nameConflictBehaviorOptions.indexOf(args.options.nameConflictBehavior) === -1) { - return `'${args.options.nameConflictBehavior}' is not a valid value for nameConflictBehavior. Allowed values are: ${this.nameConflictBehaviorOptions.join(', ')}.`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.sourceUrl, opts.sourceId].filter(o => o !== undefined).length === 1, { + error: `Specify either 'sourceUrl' or 'sourceId', but not both.`, + params: { + customCode: 'optionSet', + options: ['sourceUrl', 'sourceId'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['sourceUrl', 'sourceId'] }); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'sourceUrl', 'sourceId', 'targetUrl', 'newName', 'nameConflictBehavior'); - this.types.boolean.push('skipWait'); + }); } protected getExcludedOptionsWithUrls(): string[] | undefined { diff --git a/src/m365/spo/commands/folder/folder-get.spec.ts b/src/m365/spo/commands/folder/folder-get.spec.ts index 21af6e76ebc..2eac2a9beae 100644 --- a/src/m365/spo/commands/folder/folder-get.spec.ts +++ b/src/m365/spo/commands/folder/folder-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 './folder-get.js'; +import command, { options } from './folder-get.js'; describe(commands.FOLDER_GET, () => { let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let stubGetResponses: any; const expectedFolder = { @@ -380,6 +381,7 @@ describe(commands.FOLDER_GET, () => { }); }; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -417,52 +419,73 @@ describe(commands.FOLDER_GET, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', url: '/Shared Documents' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('excludes options from URL processing', () => { + assert.deepStrictEqual((command as any).getExcludedOptionsWithUrls(), ['url']); }); - it('fails validation if the id option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', id: '12345' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', url: '/Shared Documents' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('passes validation if the webUrl option is a valid SharePoint site URL and url specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if the id option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', id: '12345' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'12345' is not a valid GUID.`); + }); + + it('fails validation if both url and id are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents', id: 'b2307a39-e878-458b-bc90-03bc578531d6' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if neither url nor id are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if the webUrl option is a valid SharePoint site URL and url specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('should correctly handle folder get reject request', async () => { stubGetResponses(new Promise((resolve, reject) => { reject('error1'); })); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' - } - } as any), new CommandError('error1')); + }) + }), new CommandError('error1')); }); it('should show tip when folder get rejects with error code 500', async () => { sinon.stub(request, 'get').rejects({ statusCode: 500 }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' - } - } as any), new CommandError('Please check the folder URL. Folder might not exist on the specified URL')); + }) + }), new CommandError('Please check the folder URL. Folder might not exist on the specified URL')); }); it('should correctly handle folder get success request', async () => { stubGetResponses(); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' - } + }) }); assert(loggerLogSpy.lastCall.calledWith({ "Exists": true, "IsWOPIEnabled": false, "ItemCount": 0, "Name": "test1", "ProgID": null, "ServerRelativeUrl": "/sites/test1/Shared Documents/test1", "TimeCreated": "2018-05-02T23:21:45Z", "TimeLastModified": "2018-05-02T23:21:45Z", "UniqueId": "0ac3da45-cacf-4c31-9b38-9ef3697d5a66", "WelcomePage": "" })); }); @@ -471,11 +494,11 @@ describe(commands.FOLDER_GET, () => { const request = stubGetResponses(); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', webUrl: 'https://contoso.sharepoint.com', id: 'b2307a39-e878-458b-bc90-03bc578531d6' - } + }) }); const lastCall: any = request.lastCall.args[0]; assert.strictEqual(lastCall.url, 'https://contoso.sharepoint.com/_api/web/GetFolderById(\'b2307a39-e878-458b-bc90-03bc578531d6\')'); @@ -485,11 +508,11 @@ describe(commands.FOLDER_GET, () => { const request = stubGetResponses(); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' - } + }) }); const lastCall: any = request.lastCall.args[0]; assert.strictEqual(lastCall.url, 'https://contoso.sharepoint.com/_api/web/GetFolderByServerRelativePath(DecodedUrl=\'%2FShared%20Documents\')'); @@ -499,11 +522,11 @@ describe(commands.FOLDER_GET, () => { const request = stubGetResponses(); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ output: 'json', webUrl: 'https://contoso.sharepoint.com/sites/test1', url: 'Shared Documents/' - } + }) }); const lastCall: any = request.lastCall.args[0]; assert.strictEqual(lastCall.url, 'https://contoso.sharepoint.com/sites/test1/_api/web/GetFolderByServerRelativePath(DecodedUrl=\'%2Fsites%2Ftest1%2FShared%20Documents\')'); @@ -513,11 +536,11 @@ describe(commands.FOLDER_GET, () => { sinon.stub(request, 'get').resolves(expectedFolder); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com/sites/test1', url: 'Shared Documents/FolderPermission', withPermissions: true - } + }) }); assert(loggerLogSpy.calledWith(expectedFolder)); }); @@ -533,11 +556,11 @@ describe(commands.FOLDER_GET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com', url: 'Shared Documents', withPermissions: true - } - } as any), new CommandError(error)); + }) + }), new CommandError(error)); }); }); diff --git a/src/m365/spo/commands/folder/folder-get.ts b/src/m365/spo/commands/folder/folder-get.ts index b7584ee86ec..08a8069189f 100644 --- a/src/m365/spo/commands/folder/folder-get.ts +++ b/src/m365/spo/commands/folder/folder-get.ts @@ -1,6 +1,6 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import { CommandError } from '../../../../Command.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { CommandError, globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -9,17 +9,30 @@ import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { ListPrincipalType } from '../list/ListPrincipalType.js'; import { FolderProperties } from './FolderProperties.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'), + url: z.string().optional(), + id: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID.` + }) + .optional() + .alias('i'), + withPermissions: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - url?: string; - id?: string; - withPermissions?: boolean; -} - class SpoFolderGetCommand extends SpoCommand { public get name(): string { return commands.FOLDER_GET; @@ -29,68 +42,19 @@ class SpoFolderGetCommand extends SpoCommand { return 'Gets information about the specified folder'; } - 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', - url: typeof args.options.url !== 'undefined', - withPermissions: typeof args.options.withPermissions !== 'undefined' - }); - }); + public get schema(): z.ZodType | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '--url [url]' - }, - { - option: '-i, --id [id]' - }, - { - option: '--withPermissions' - } - ); - } - - #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) { - if (!validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; - } + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.url, opts.id].filter(o => o !== undefined).length === 1, { + error: `Specify either 'url' or 'id', but not both.`, + params: { + customCode: 'optionSet', + options: ['url', 'id'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['url', 'id'] }); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'url', 'id'); + }); } protected getExcludedOptionsWithUrls(): string[] | undefined { diff --git a/src/m365/spo/commands/folder/folder-list.spec.ts b/src/m365/spo/commands/folder/folder-list.spec.ts index 5f27160ac18..318d2ef5231 100644 --- a/src/m365/spo/commands/folder/folder-list.spec.ts +++ b/src/m365/spo/commands/folder/folder-list.spec.ts @@ -13,7 +13,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import commands from '../../commands.js'; -import command from './folder-list.js'; +import command, { options } from './folder-list.js'; describe(commands.FOLDER_LIST, () => { const webUrl = 'https://contoso.sharepoint.com'; @@ -47,6 +47,7 @@ describe(commands.FOLDER_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -55,6 +56,7 @@ describe(commands.FOLDER_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -104,12 +106,12 @@ describe(commands.FOLDER_LIST, () => { throw `Invalid request ${opts.url}`; }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, parentFolderUrl: parentFolderUrl - } - } as any), new CommandError('error1')); + }) + }), new CommandError('error1')); }); it('should correctly handle folder get success request', async () => { @@ -122,11 +124,11 @@ describe(commands.FOLDER_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, parentFolderUrl: parentFolderUrl - } + }) }); assert(loggerLogSpy.calledWith(folderListOutput.value)); }); @@ -160,13 +162,13 @@ describe(commands.FOLDER_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, parentFolderUrl: parentFolderUrl, filter: `name eq 'Folder1'`, fields: 'ListItemAllFields/Id,Name' - } + }) }); assert(loggerLogSpy.calledWith([{ ListItemAllFields: { Id: 1 }, Name: "Test1" }, { ListItemAllFields: { Id: 2 }, Name: "Test2" }, { Name: "Test3" }])); }); @@ -189,11 +191,11 @@ describe(commands.FOLDER_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, parentFolderUrl: parentFolderUrl - } + }) }); assert(loggerLogSpy.calledWith([...folderThresholdLimitOutput.value, ...folderListOutput.value])); }); @@ -207,12 +209,12 @@ describe(commands.FOLDER_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, parentFolderUrl: parentFolderUrl, output: 'json' - } + }) }); assert(loggerLogSpy.calledWith(folderListOutput.value)); }); @@ -242,12 +244,12 @@ describe(commands.FOLDER_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: webUrl, parentFolderUrl: parentFolderUrl, recursive: true - } + }) }); const expectedResults = folderListOutputSingleFolder.value; folderListOutputRecursiveLevel1.value.forEach(element => { @@ -266,10 +268,10 @@ describe(commands.FOLDER_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, parentFolderUrl: parentFolderUrl - } + }) }); assert(loggerLogSpy.calledWith(folderListOutput.value)); }); @@ -286,23 +288,29 @@ describe(commands.FOLDER_LIST, () => { throw `Invalid request ${opts.url}`; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: webUrl, parentFolderUrl: parentFolderUrl - } + }) }); assert(loggerLogSpy.lastCall.calledWith(folderListOutput.value)); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', parentFolderUrl: parentFolderUrl } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', parentFolderUrl: parentFolderUrl }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); + }); + + it('passes validation if the webUrl option is a valid SharePoint site URL and parentFolderUrl specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, parentFolderUrl: parentFolderUrl }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the webUrl option is a valid SharePoint site URL and parentFolderUrl specified', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, parentFolderUrl: parentFolderUrl } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, parentFolderUrl: parentFolderUrl, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/folder/folder-list.ts b/src/m365/spo/commands/folder/folder-list.ts index b7beb73dc46..634f32d3876 100644 --- a/src/m365/spo/commands/folder/folder-list.ts +++ b/src/m365/spo/commands/folder/folder-list.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 { formatting } from '../../../../utils/formatting.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -8,18 +9,25 @@ import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { FolderProperties } from './FolderProperties.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'), + parentFolderUrl: z.string().alias('p'), + fields: z.string().optional(), + filter: z.string().optional(), + recursive: z.boolean().optional().alias('r') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - parentFolderUrl: string; - recursive?: boolean; - fields?: string; - filter?: string; -} - interface FieldProperties { selectProperties: string[]; expandProperties: string[]; @@ -40,54 +48,8 @@ class SpoFolderListCommand extends SpoCommand { return ['Name', 'ServerRelativeUrl']; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - recursive: !!args.options.recursive, - fields: typeof args.options.fields !== 'undefined', - filter: typeof args.options.filter !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '-p, --parentFolderUrl ' - }, - { - option: '--fields [fields]' - }, - { - option: '--filter [filter]' - }, - { - option: '-r, --recursive' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.webUrl) - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'parentFolderUrl', 'fields', 'filter'); - this.types.boolean.push('recursive'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-move.spec.ts b/src/m365/spo/commands/folder/folder-move.spec.ts index b5b001ebcf7..2771046ade5 100644 --- a/src/m365/spo/commands/folder/folder-move.spec.ts +++ b/src/m365/spo/commands/folder/folder-move.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 './folder-move.js'; +import command, { options } from './folder-move.js'; import { settingsNames } from '../../../../settingsNames.js'; import { CreateFolderCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; @@ -70,6 +70,7 @@ describe(commands.FOLDER_MOVE, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let loggerLogSpy: sinon.SinonSpy; let spoUtilCreateCopyJobStub: sinon.SinonStub; @@ -83,6 +84,7 @@ describe(commands.FOLDER_MOVE, () => { auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => settingName === settingsNames.prompt ? false : defaultValue); spoUtilCreateCopyJobStub = sinon.stub(spo, 'createFolderCopyJob').resolves(copyJobInfo); spoUtilGetCopyJobResultStub = sinon.stub(spo, 'getCopyJobResult').resolves(copyJobResult); @@ -130,29 +132,47 @@ describe(commands.FOLDER_MOVE, () => { assert.deepStrictEqual((command as any).getExcludedOptionsWithUrls(), ['targetUrl', 'sourceUrl']); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'invalid', sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid SharePoint Online site URL.`); }); - it('fails validation if sourceId is not a valid guid', async () => { - const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceId: 'invalid', targetUrl: destServerRelUrl } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if sourceId is not a valid guid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceId: 'invalid', targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for sourceId.`); }); - it('fails validation if nameConflictBehavior is not valid', async () => { - const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl, nameConflictBehavior: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if nameConflictBehavior is not valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl, nameConflictBehavior: 'invalid' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid value for nameConflictBehavior. Allowed values are: fail, rename.`); }); - it('passes validation if the sourceId is a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destServerRelUrl } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both sourceUrl and sourceId are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, sourceId: sourceFolderId, targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the webUrl option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if neither sourceUrl nor sourceId are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if the sourceId is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if the webUrl option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('correctly outputs exactly one result when folder is moved when using sourceId', async () => { @@ -171,12 +191,12 @@ describe(commands.FOLDER_MOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destAbsoluteTargetUrl - } + }) }); assert(loggerLogSpy.calledOnce); @@ -198,12 +218,12 @@ describe(commands.FOLDER_MOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destAbsoluteTargetUrl - } + }) }); assert(loggerLogSpy.calledOnce); @@ -220,12 +240,12 @@ describe(commands.FOLDER_MOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl - } + }) }); assert(loggerLogSpy.calledOnce); @@ -248,11 +268,11 @@ describe(commands.FOLDER_MOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destAbsoluteTargetUrl - } + }) }); assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ @@ -277,12 +297,12 @@ describe(commands.FOLDER_MOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'fail' - } + }) }); assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ @@ -307,12 +327,12 @@ describe(commands.FOLDER_MOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceSiteRelUrl, targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'fail' - } + }) }); assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ @@ -337,12 +357,12 @@ describe(commands.FOLDER_MOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceAbsoluteUrl, targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'rename' - } + }) }); assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ @@ -367,13 +387,13 @@ describe(commands.FOLDER_MOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'rename', newName: 'Folder-renamed' - } + }) }); assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ @@ -398,11 +418,11 @@ describe(commands.FOLDER_MOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl - } + }) }); assert.deepStrictEqual(spoUtilGetCopyJobResultStub.lastCall.args, [ @@ -413,12 +433,12 @@ describe(commands.FOLDER_MOVE, () => { it('outputs no result when skipWait is specified', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl, skipWait: true - } + }) }); assert(loggerLogSpy.notCalled); @@ -426,12 +446,12 @@ describe(commands.FOLDER_MOVE, () => { it('correctly skips polling when skipWait is specified', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl, skipWait: true - } + }) }); assert(spoUtilGetCopyJobResultStub.notCalled); @@ -450,11 +470,11 @@ describe(commands.FOLDER_MOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destAbsoluteTargetUrl - } + }) }), new CommandError('Folder Not Found.')); }); @@ -463,11 +483,11 @@ describe(commands.FOLDER_MOVE, () => { spoUtilGetCopyJobResultStub = sinon.stub(spo, 'getCopyJobResult').rejects(new Error('Target folder already exists.')); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destAbsoluteTargetUrl - } + }) }), new CommandError('Target folder already exists.')); }); }); diff --git a/src/m365/spo/commands/folder/folder-move.ts b/src/m365/spo/commands/folder/folder-move.ts index 0534e123d67..10db68dab31 100644 --- a/src/m365/spo/commands/folder/folder-move.ts +++ b/src/m365/spo/commands/folder/folder-move.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 { CreateFolderCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -7,23 +8,37 @@ import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +const nameConflictBehaviorOptions = ['fail', 'rename'] as const; + +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'), + sourceUrl: z.string().optional().alias('s'), + sourceId: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for sourceId.` + }) + .optional() + .alias('i'), + targetUrl: z.string().alias('t'), + newName: z.string().optional(), + nameConflictBehavior: z.enum(nameConflictBehaviorOptions, { + error: e => `'${e.input}' is not a valid value for nameConflictBehavior. Allowed values are: ${nameConflictBehaviorOptions.join(', ')}.` + }).optional(), + skipWait: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - sourceUrl?: string; - sourceId?: string; - targetUrl: string; - newName?: string; - nameConflictBehavior?: string; - skipWait?: boolean; -} - class SpoFolderMoveCommand extends SpoCommand { - private readonly nameConflictBehaviorOptions = ['fail', 'rename']; - public get name(): string { return commands.FOLDER_MOVE; } @@ -32,83 +47,19 @@ class SpoFolderMoveCommand extends SpoCommand { return 'Moves a folder to another location'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - sourceUrl: typeof args.options.sourceUrl !== 'undefined', - sourceId: typeof args.options.sourceId !== 'undefined', - newName: typeof args.options.newName !== 'undefined', - nameConflictBehavior: typeof args.options.nameConflictBehavior !== 'undefined', - skipWait: !!args.options.skipWait - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '-s, --sourceUrl [sourceUrl]' - }, - { - option: '-i, --sourceId [sourceId]' - }, - { - option: '-t, --targetUrl ' - }, - { - option: '--newName [newName]' - }, - { - option: '--nameConflictBehavior [nameConflictBehavior]', - autocomplete: this.nameConflictBehaviorOptions - }, - { - option: '--skipWait' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - if (args.options.sourceId && !validation.isValidGuid(args.options.sourceId)) { - return `'${args.options.sourceId}' is not a valid GUID for sourceId.`; - } - - if (args.options.nameConflictBehavior && this.nameConflictBehaviorOptions.indexOf(args.options.nameConflictBehavior) === -1) { - return `'${args.options.nameConflictBehavior}' is not a valid value for nameConflictBehavior. Allowed values are: ${this.nameConflictBehaviorOptions.join(', ')}.`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.sourceUrl, opts.sourceId].filter(o => o !== undefined).length === 1, { + error: `Specify either 'sourceUrl' or 'sourceId', but not both.`, + params: { + customCode: 'optionSet', + options: ['sourceUrl', 'sourceId'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['sourceUrl', 'sourceId'] }); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'sourceUrl', 'sourceId', 'targetUrl', 'newName', 'nameConflictBehavior'); - this.types.boolean.push('skipWait'); + }); } protected getExcludedOptionsWithUrls(): string[] | undefined { diff --git a/src/m365/spo/commands/folder/folder-remove.spec.ts b/src/m365/spo/commands/folder/folder-remove.spec.ts index 6b339cd078c..a37fd83856f 100644 --- a/src/m365/spo/commands/folder/folder-remove.spec.ts +++ b/src/m365/spo/commands/folder/folder-remove.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 './folder-remove.js'; +import command, { options } from './folder-remove.js'; describe(commands.FOLDER_REMOVE, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let requests: any[]; let promptIssued: boolean = false; let stubPost: sinon.SinonStub; @@ -28,6 +29,7 @@ describe(commands.FOLDER_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -81,8 +83,12 @@ describe(commands.FOLDER_REMOVE, () => { assert.notStrictEqual(command.description, null); }); + it('excludes options from URL processing', () => { + assert.deepStrictEqual((command as any).getExcludedOptionsWithUrls(), ['url']); + }); + it('prompts before removing folder when confirmation argument not passed', async () => { - await command.action(logger, { options: { webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' }) }); assert(promptIssued); }); @@ -90,7 +96,7 @@ describe(commands.FOLDER_REMOVE, () => { it('aborts removing folder when prompt not confirmed', async () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' }) }); assert(requests.length === 0); }); @@ -98,26 +104,26 @@ describe(commands.FOLDER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: - { - webUrl: 'https://contoso.sharepoint.com', - url: '/Shared Documents/Folder1', - permanent: true - } + options: commandOptionsSchema.parse( + { + webUrl: 'https://contoso.sharepoint.com', + url: '/Shared Documents/Folder1', + permanent: true + }) }); assert(stubPost.called); }); it('should send params for remove request', async () => { await command.action(logger, { - options: - { - verbose: true, - webUrl: 'https://contoso.sharepoint.com', - url: '/Shared Documents/Folder1', - permanent: true, - force: true - } + options: commandOptionsSchema.parse( + { + verbose: true, + webUrl: 'https://contoso.sharepoint.com', + url: '/Shared Documents/Folder1', + permanent: true, + force: true + }) }); const lastCall: any = stubPost.lastCall.args[0]; assert.strictEqual(lastCall.url, 'https://contoso.sharepoint.com/_api/web/GetFolderByServerRelativePath(DecodedUrl=\'%2FShared%20Documents%2FFolder1\')'); @@ -130,13 +136,13 @@ describe(commands.FOLDER_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: - { - verbose: true, - webUrl: 'https://contoso.sharepoint.com/sites/test1', - url: '/Shared Documents/Folder1', - permanent: true - } + options: commandOptionsSchema.parse( + { + verbose: true, + webUrl: 'https://contoso.sharepoint.com/sites/test1', + url: '/Shared Documents/Folder1', + permanent: true + }) }); const lastCall: any = stubPost.lastCall.args[0]; assert.strictEqual(lastCall.url, 'https://contoso.sharepoint.com/sites/test1/_api/web/GetFolderByServerRelativePath(DecodedUrl=\'%2Fsites%2Ftest1%2FShared%20Documents%2FFolder1\')'); @@ -149,12 +155,12 @@ describe(commands.FOLDER_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: - { - debug: true, - webUrl: 'https://contoso.sharepoint.com', - url: '/Shared Documents/Folder1' - } + options: commandOptionsSchema.parse( + { + debug: true, + webUrl: 'https://contoso.sharepoint.com', + url: '/Shared Documents/Folder1' + }) }); const lastCall: any = stubPost.lastCall.args[0]; assert.strictEqual(lastCall.url, 'https://contoso.sharepoint.com/_api/web/GetFolderByServerRelativePath(DecodedUrl=\'%2FShared%20Documents%2FFolder1\')/recycle()'); @@ -181,22 +187,28 @@ describe(commands.FOLDER_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await assert.rejects(command.action(logger, { - options: - { - debug: true, - webUrl: 'https://contoso.sharepoint.com', - url: '/Shared Documents/Folder1' - } - } as any), new CommandError(error.error['odata.error'].message.value)); + options: commandOptionsSchema.parse( + { + debug: true, + webUrl: 'https://contoso.sharepoint.com', + url: '/Shared Documents/Folder1' + }) + }), new CommandError(error.error['odata.error'].message.value)); + }); + + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', url: '/Shared Documents' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', url: '/Shared Documents' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if the webUrl option is a valid SharePoint site URL and url specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the webUrl option is a valid SharePoint site URL and url specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', url: '/Shared Documents', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/folder/folder-remove.ts b/src/m365/spo/commands/folder/folder-remove.ts index f546e7198df..4e3f811be5d 100644 --- a/src/m365/spo/commands/folder/folder-remove.ts +++ b/src/m365/spo/commands/folder/folder-remove.ts @@ -1,6 +1,7 @@ +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 { formatting } from '../../../../utils/formatting.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -8,17 +9,24 @@ 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'), + url: z.string(), + permanent: z.boolean().optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - url: string; - permanent?: boolean; - force?: boolean; -} - class SpoFolderRemoveCommand extends SpoCommand { public get name(): string { return commands.FOLDER_REMOVE; @@ -28,50 +36,8 @@ class SpoFolderRemoveCommand extends SpoCommand { return 'Deletes the specified folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - permanent: !!args.options.permanent, - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '--url ' - }, - { - option: '--permanent' - }, - { - option: '-f, --force' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.webUrl) - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'url'); - this.types.boolean.push('permanent', 'force'); + public get schema(): z.ZodType | undefined { + return options; } protected getExcludedOptionsWithUrls(): string[] | undefined { diff --git a/src/m365/spo/commands/folder/folder-retentionlabel-ensure.spec.ts b/src/m365/spo/commands/folder/folder-retentionlabel-ensure.spec.ts index 0f6296d4d40..8257ba6f597 100644 --- a/src/m365/spo/commands/folder/folder-retentionlabel-ensure.spec.ts +++ b/src/m365/spo/commands/folder/folder-retentionlabel-ensure.spec.ts @@ -12,8 +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 './folder-retentionlabel-ensure.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './folder-retentionlabel-ensure.js'; describe(commands.FOLDER_RETENTIONLABEL_ENSURE, () => { const webUrl = 'https://contoso.sharepoint.com'; @@ -36,6 +35,7 @@ describe(commands.FOLDER_RETENTIONLABEL_ENSURE, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -44,6 +44,7 @@ describe(commands.FOLDER_RETENTIONLABEL_ENSURE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -102,11 +103,11 @@ describe(commands.FOLDER_RETENTIONLABEL_ENSURE, () => { }); await assert.doesNotReject(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ folderUrl: folderUrl, webUrl: webUrl, name: retentionlabelName - } + }) })); }); @@ -129,12 +130,12 @@ describe(commands.FOLDER_RETENTIONLABEL_ENSURE, () => { }); await assert.doesNotReject(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, folderId: folderId, webUrl: webUrl, name: retentionlabelName - } + }) })); }); @@ -157,12 +158,12 @@ describe(commands.FOLDER_RETENTIONLABEL_ENSURE, () => { }); await assert.doesNotReject(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, folderId: folderId, webUrl: webUrl, name: retentionlabelName - } + }) })); }); @@ -172,58 +173,49 @@ describe(commands.FOLDER_RETENTIONLABEL_ENSURE, () => { sinon.stub(request, 'get').rejects({ error: { error: { message: errorMessage } } }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, name: retentionlabelName, folderUrl: folderUrl, webUrl: webUrl - } + }) }), new CommandError(errorMessage)); }); - it('fails validation if both folderUrl or folderId 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: webUrl, name: retentionlabelName } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if both folderUrl or folderId options are not passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, name: retentionlabelName }); + 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', folderUrl: folderUrl, name: retentionlabelName } }, 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', folderUrl: folderUrl, name: retentionlabelName }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderUrl: folderUrl, name: retentionlabelName } }, commandInfo); - assert(actual); + it('passes validation if the url option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderUrl: folderUrl, name: retentionlabelName }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the folderId option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: '12345', name: retentionlabelName } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the folderId option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: '12345', name: retentionlabelName }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'12345' is not a valid GUID.`); }); - it('passes validation if the folderId option is a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, name: retentionlabelName } }, commandInfo); - assert(actual); + it('passes validation if the folderId option is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, name: retentionlabelName }); + assert.strictEqual(actual.success, true); }); - it('fails validation if both folderId and folderUrl options are passed', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); + it('fails validation if both folderId and folderUrl options are passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, folderUrl: folderUrl, name: retentionlabelName }); + assert.strictEqual(actual.success, false); + }); - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, folderUrl: folderUrl, name: retentionlabelName } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderUrl: folderUrl, name: retentionlabelName, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-retentionlabel-ensure.ts b/src/m365/spo/commands/folder/folder-retentionlabel-ensure.ts index d2d1e41e018..1346760bc7b 100644 --- a/src/m365/spo/commands/folder/folder-retentionlabel-ensure.ts +++ b/src/m365/spo/commands/folder/folder-retentionlabel-ensure.ts @@ -1,5 +1,6 @@ -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { spo } from '../../../../utils/spo.js'; @@ -9,17 +10,29 @@ import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { FolderProperties } from './FolderProperties.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'), + name: z.string(), + folderUrl: z.string().optional(), + folderId: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID.` + }) + .optional() + .alias('i') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - name: string; - folderUrl?: string; - folderId?: string; -} - class SpoFolderRetentionLabelEnsureCommand extends SpoCommand { public get name(): string { return commands.FOLDER_RETENTIONLABEL_ENSURE; @@ -29,66 +42,19 @@ class SpoFolderRetentionLabelEnsureCommand extends SpoCommand { return 'Applies a retention label to a folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - folderUrl: typeof args.options.folderUrl !== 'undefined', - folderId: typeof args.options.folderId !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '--name ' - }, - { - option: '--folderUrl [folderUrl]' - }, - { - option: '-i, --folderId [folderId]' - } - ); + 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.folderId && - !validation.isValidGuid(args.options.folderId as string)) { - return `${args.options.folderId} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.folderUrl, opts.folderId].filter(o => o !== undefined).length === 1, { + error: `Specify either 'folderUrl' or 'folderId', but not both.`, + params: { + customCode: 'optionSet', + options: ['folderUrl', 'folderId'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['folderUrl', 'folderId'] }); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'name', 'folderUrl', 'folderId'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-retentionlabel-remove.spec.ts b/src/m365/spo/commands/folder/folder-retentionlabel-remove.spec.ts index dfdd4e7a508..946b7cee306 100644 --- a/src/m365/spo/commands/folder/folder-retentionlabel-remove.spec.ts +++ b/src/m365/spo/commands/folder/folder-retentionlabel-remove.spec.ts @@ -12,8 +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 './folder-retentionlabel-remove.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './folder-retentionlabel-remove.js'; describe(commands.FOLDER_RETENTIONLABEL_REMOVE, () => { const webUrl = 'https://contoso.sharepoint.com'; @@ -34,6 +33,7 @@ describe(commands.FOLDER_RETENTIONLABEL_REMOVE, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let promptIssued: boolean = false; before(() => { @@ -43,6 +43,7 @@ describe(commands.FOLDER_RETENTIONLABEL_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -89,7 +90,7 @@ describe(commands.FOLDER_RETENTIONLABEL_REMOVE, () => { }); it('prompts before removing retentionlabel from a folder when confirmation argument not passed', async () => { - await command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl } }); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl }) }); assert(promptIssued); }); @@ -99,10 +100,10 @@ describe(commands.FOLDER_RETENTIONLABEL_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ folderUrl: folderUrl, webUrl: webUrl - } + }) }); assert(postSpy.notCalled); }); @@ -129,10 +130,10 @@ describe(commands.FOLDER_RETENTIONLABEL_REMOVE, () => { }); await assert.doesNotReject(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ folderUrl: folderUrl, webUrl: webUrl - } + }) })); }); @@ -158,11 +159,10 @@ describe(commands.FOLDER_RETENTIONLABEL_REMOVE, () => { }); await assert.doesNotReject(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ folderId: folderId, - webUrl: webUrl, - listItemId: 1 - } + webUrl: webUrl + }) })); }); @@ -185,13 +185,12 @@ describe(commands.FOLDER_RETENTIONLABEL_REMOVE, () => { }); await assert.doesNotReject(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, folderId: folderId, webUrl: webUrl, - listItemId: 1, force: true - } + }) })); }); @@ -214,12 +213,12 @@ describe(commands.FOLDER_RETENTIONLABEL_REMOVE, () => { }); await assert.doesNotReject(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, folderId: folderId, webUrl: webUrl, force: true - } + }) })); }); @@ -230,58 +229,49 @@ describe(commands.FOLDER_RETENTIONLABEL_REMOVE, () => { sinon.stub(request, 'get').rejects({ error: { error: { message: errorMessage } } }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, force: true, folderUrl: folderUrl, webUrl: webUrl - } + }) }), new CommandError(errorMessage)); }); - it('fails validation if both folderUrl or folderId 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: webUrl } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if both folderUrl or folderId options are not passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl }); + 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', folderUrl: folderUrl } }, 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', folderUrl: folderUrl }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderUrl: folderUrl } }, commandInfo); - assert(actual); + it('passes validation if the url option is a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderUrl: folderUrl }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the folderId option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: '12345' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the folderId option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: '12345' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'12345' is not a valid GUID.`); }); - it('passes validation if the folderId option is a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId } }, commandInfo); - assert(actual); + it('passes validation if the folderId option is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId }); + assert.strictEqual(actual.success, true); }); - it('fails validation if both folderId and folderUrl options are passed', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); + it('fails validation if both folderId and folderUrl options are passed', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, folderUrl: folderUrl }); + assert.strictEqual(actual.success, false); + }); - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, folderUrl: folderUrl } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderUrl: folderUrl, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-retentionlabel-remove.ts b/src/m365/spo/commands/folder/folder-retentionlabel-remove.ts index b58ed74ca1e..5a13129ba19 100644 --- a/src/m365/spo/commands/folder/folder-retentionlabel-remove.ts +++ b/src/m365/spo/commands/folder/folder-retentionlabel-remove.ts @@ -1,6 +1,7 @@ -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { spo } from '../../../../utils/spo.js'; @@ -10,17 +11,29 @@ import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { FolderProperties } from './FolderProperties.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'), + folderUrl: z.string().optional(), + folderId: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID.` + }) + .optional() + .alias('i'), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl?: string; - folderId?: string; - force?: boolean; -} - class SpoFolderRetentionLabelRemoveCommand extends SpoCommand { public get name(): string { return commands.FOLDER_RETENTIONLABEL_REMOVE; @@ -30,68 +43,19 @@ class SpoFolderRetentionLabelRemoveCommand extends SpoCommand { return 'Clears the retention label from a folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - folderUrl: typeof args.options.folderUrl !== 'undefined', - folderId: typeof args.options.folderId !== 'undefined', - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '--folderUrl [folderUrl]' - }, - { - option: '-i, --folderId [folderId]' - }, - { - 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.folderId && - !validation.isValidGuid(args.options.folderId as string)) { - return `${args.options.folderId} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.folderUrl, opts.folderId].filter(o => o !== undefined).length === 1, { + error: `Specify either 'folderUrl' or 'folderId', but not both.`, + params: { + customCode: 'optionSet', + options: ['folderUrl', 'folderId'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['folderUrl', 'folderId'] }); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderUrl', 'folderId'); - this.types.boolean.push('force'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-roleassignment-add.spec.ts b/src/m365/spo/commands/folder/folder-roleassignment-add.spec.ts index db6d79615ee..8c046f001e7 100644 --- a/src/m365/spo/commands/folder/folder-roleassignment-add.spec.ts +++ b/src/m365/spo/commands/folder/folder-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 './folder-roleassignment-add.js'; +import command, { options } from './folder-roleassignment-add.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { spo } from '../../../../utils/spo.js'; @@ -76,6 +76,7 @@ describe(commands.FOLDER_ROLEASSIGNMENT_ADD, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const groupResponse = { Id: 11, IsHiddenInUI: false, @@ -167,6 +168,7 @@ describe(commands.FOLDER_ROLEASSIGNMENT_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -209,74 +211,83 @@ describe(commands.FOLDER_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', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - 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', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', principalId: -5, roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `Specified principalId '-5' is not a valid number.`); }); - it('passes validation if the principalId option is a number', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, roleDefinitionId: -5 }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `Specified roleDefinitionId '-5' is not a valid number.`); }); - it('passes validation if the roleDefinitionId option is a number', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, true); }); - it('fails validation if principalId and upn are specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, upn: 'someaccount@tenant.onmicrosoft.com', roleDefinitionId: 1073741827 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if principalId and upn are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, upn: 'someaccount@tenant.onmicrosoft.com', roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, false); }); - it('fails validation if principalId and groupName are specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, groupName: 'someGroup', roleDefinitionId: 1073741827 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if principalId and groupName are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, groupName: 'someGroup', roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, false); }); - it('fails validation if upn and groupName are specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', upn: 'someaccount@tenant.onmicrosoft.com', groupName: 'someGroup', roleDefinitionId: 1073741827 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if upn and groupName are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', upn: 'someaccount@tenant.onmicrosoft.com', groupName: 'someGroup', roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, false); }); - it('fails validation if roleDefinitionId and roleDefinitionName are specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup', roleDefinitionId: 1073741827, roleDefinitionName: 'readers' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if roleDefinitionId and roleDefinitionName are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup', roleDefinitionId: 1073741827, roleDefinitionName: 'readers' }); + assert.strictEqual(actual.success, false); }); - it('fails validation neither roleDefinitionId nor roleDefinitionName is specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation neither roleDefinitionId nor roleDefinitionName is specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup' }); + assert.strictEqual(actual.success, false); }); - it('fails validation neither groupName nor principalId or upn is specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', roleDefinitionName: 'readers' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation neither groupName nor principalId or upn is specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', roleDefinitionName: 'readers' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if the entraGroupId option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', entraGroupId: 'invalid', roleDefinitionId: 1073741827 } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the entraGroupId option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', entraGroupId: 'invalid', roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option entraGroupId.`); }); - it('passes validation if the entraGroupId option is a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', entraGroupId: '37455d5c-e466-4e49-8eba-808b5acec21b', roleDefinitionId: 1073741827 } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the entraGroupId option is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', entraGroupId: '37455d5c-e466-4e49-8eba-808b5acec21b', roleDefinitionId: 1073741827 }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, roleDefinitionId: 1073741827, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('add the role assignment to the specified folder based on the upn and role definition id', async () => { @@ -291,13 +302,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_ADD, () => { sinon.stub(spo, 'getUserByEmail').resolves(userResponse); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', upn: 'someaccount@tenant.onmicrosoft.com', roleDefinitionId: 1073741827 - } + }) }); }); @@ -311,13 +322,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents', principalId: 11, roleDefinitionId: 1073741827 - } + }) }); }); @@ -334,14 +345,14 @@ describe(commands.FOLDER_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', folderUrl: '/Shared Documents/FolderPermission', upn: 'someaccount@tenant.onmicrosoft.com', roleDefinitionId: 1073741827 - } - } as any), new CommandError(error)); + }) + }), new CommandError(error)); }); it('add the role assignment to the specified folder based on the group name and role definition id', async () => { @@ -356,13 +367,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_ADD, () => { sinon.stub(spo, 'getGroupByName').resolves(groupResponse); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup', roleDefinitionId: 1073741827 - } + }) }); }); @@ -379,14 +390,14 @@ describe(commands.FOLDER_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', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup', roleDefinitionId: 1073741827 - } - } as any), new CommandError(error)); + }) + }), new CommandError(error)); }); it('add the role assignment to the specified folder based on the principal id and role definition name', async () => { @@ -402,13 +413,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_ADD, () => { sinon.stub(spo, 'getRoleDefintionByName').resolves(roledefintionResponse); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, roleDefinitionName: 'Full Control' - } + }) }); }); @@ -425,14 +436,14 @@ describe(commands.FOLDER_ROLEASSIGNMENT_ADD, () => { sinon.stub(spo, 'getRoleDefintionByName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, roleDefinitionName: 'Full Control1' - } - } as any), new CommandError(error)); + }) + }), new CommandError(error)); }); it('adds the role assignment to the specified root folder based on the Entra group id and role definition id', async () => { @@ -448,13 +459,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents', entraGroupId: '27ae47f1-48f1-46f3-980b-d3c1470e398d', roleDefinitionId: 1073741827 - } + }) }); }); @@ -471,13 +482,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents', entraGroupName: 'Marketing', roleDefinitionId: 1073741827 - } + }) }); }); }); diff --git a/src/m365/spo/commands/folder/folder-roleassignment-add.ts b/src/m365/spo/commands/folder/folder-roleassignment-add.ts index 7b48e03fe60..1d344e709e8 100644 --- a/src/m365/spo/commands/folder/folder-roleassignment-add.ts +++ b/src/m365/spo/commands/folder/folder-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, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -9,22 +10,41 @@ import commands from '../../commands.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { spo } from '../../../../utils/spo.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'), + folderUrl: z.string(), + principalId: z.number() + .refine(id => validation.isValidPositiveInteger(id), { + error: e => `Specified principalId '${e.input}' is not a valid 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() + .refine(id => validation.isValidPositiveInteger(id), { + error: e => `Specified roleDefinitionId '${e.input}' is not a valid number.` + }) + .optional(), + roleDefinitionName: z.string().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl: string; - principalId?: number; - upn?: string; - groupName?: string; - entraGroupId?: string; - entraGroupName?: string; - roleDefinitionId?: number; - roleDefinitionName?: string; -} - class SpoFolderRoleAssignmentAddCommand extends SpoCommand { public get name(): string { return commands.FOLDER_ROLEASSIGNMENT_ADD; @@ -34,106 +54,26 @@ class SpoFolderRoleAssignmentAddCommand extends SpoCommand { return 'Adds a role assignment to the specified folder.'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initTypes(); - } - - #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 ' - }, - { - option: '--folderUrl ' - }, - { - 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; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.principalId, opts.upn, opts.groupName, opts.entraGroupId, opts.entraGroupName].filter(o => o !== undefined).length === 1, { + error: 'Specify one of the following options: principalId, upn, groupName, entraGroupId, entraGroupName.', + params: { + customCode: 'optionSet', + options: ['principalId', 'upn', 'groupName', 'entraGroupId', 'entraGroupName'] } - - 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.`; - } - - if (args.options.roleDefinitionId && isNaN(args.options.roleDefinitionId)) { - return `Specified roleDefinitionId ${args.options.roleDefinitionId} is not a number`; - } - - const principalOptions: any[] = [args.options.principalId, args.options.upn, args.options.groupName, args.options.entraGroupId, args.options.entraGroupName]; - if (!principalOptions.some(item => item !== undefined)) { - return `Specify either principalId, upn, groupName, entraGroupId or entraGroupName`; - } - - if (principalOptions.filter(item => item !== undefined).length > 1) { - return `Specify either principalId, upn, groupName, entraGroupId or entraGroupName but not multiple`; + }) + .refine(opts => [opts.roleDefinitionId, opts.roleDefinitionName].filter(o => o !== undefined).length === 1, { + error: 'Specify one of the following options: roleDefinitionId, roleDefinitionName.', + params: { + customCode: 'optionSet', + options: ['roleDefinitionId', 'roleDefinitionName'] } - - const roleDefinitionOptions: any[] = [args.options.roleDefinitionId, args.options.roleDefinitionName]; - if (!roleDefinitionOptions.some(item => item !== undefined)) { - return `Specify either roleDefinitionId id or roleDefinitionName`; - } - - if (roleDefinitionOptions.filter(item => item !== undefined).length > 1) { - return `Specify either roleDefinitionId id or roleDefinitionName but not both`; - } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderUrl', 'upn', 'groupName', 'roleDefinitionName'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-roleassignment-remove.spec.ts b/src/m365/spo/commands/folder/folder-roleassignment-remove.spec.ts index 181be1b38e8..6955472862f 100644 --- a/src/m365/spo/commands/folder/folder-roleassignment-remove.spec.ts +++ b/src/m365/spo/commands/folder/folder-roleassignment-remove.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 './folder-roleassignment-remove.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './folder-roleassignment-remove.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { spo } from '../../../../utils/spo.js'; @@ -77,6 +76,7 @@ describe(commands.FOLDER_ROLEASSIGNMENT_REMOVE, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let requests: any[]; let promptIssued: boolean = false; const userResponse = { @@ -117,6 +117,7 @@ describe(commands.FOLDER_ROLEASSIGNMENT_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -167,67 +168,67 @@ describe(commands.FOLDER_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', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', principalId: 11 }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - 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', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', principalId: -5 }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `Specified principalId '-5' is not a valid number.`); }); - it('passes validation if the principalId option is a number', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', 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', folderUrl: '/Shared Documents/FolderPermission', principalId: 11 }); + assert.strictEqual(actual.success, true); }); - it('fails validation if principalId and upn are specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, upn: 'someaccount@tenant.onmicrosoft.com' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if principalId and upn are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, upn: 'someaccount@tenant.onmicrosoft.com' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if principalId and groupName are specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, groupName: 'someGroup' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if principalId and groupName are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, groupName: 'someGroup' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if upn and groupName are specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', upn: 'someaccount@tenant.onmicrosoft.com', groupName: 'someGroup' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if upn and groupName are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', upn: 'someaccount@tenant.onmicrosoft.com', groupName: 'someGroup' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if neither upn nor principalId or groupName is specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if neither upn nor principalId or groupName is specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if folderUrl is not specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); + it('fails validation if folderUrl is not specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', upn: 'someaccount@tenant.onmicrosoft.com' }); + assert.strictEqual(actual.success, false); + }); - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', upn: 'someaccount@tenant.onmicrosoft.com' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the entraGroupId option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', entraGroupId: 'invalid' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option entraGroupId.`); }); - it('fails validation if the entraGroupId option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', entraGroupId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if the entraGroupId option is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', entraGroupId: '37455d5c-e466-4e49-8eba-808b5acec21b' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the entraGroupId option is a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', entraGroupId: '37455d5c-e466-4e49-8eba-808b5acec21b' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('remove role assignment from folder by folderUrl', async () => { @@ -240,13 +241,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', principalId: 11, force: true - } + }) }); }); @@ -262,13 +263,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_REMOVE, () => { sinon.stub(spo, 'getUserByEmail').resolves(userResponse); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', upn: 'someaccount@tenant.onmicrosoft.com', force: true - } + }) }); }); @@ -285,14 +286,14 @@ describe(commands.FOLDER_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', folderUrl: '/Shared Documents/FolderPermission', upn: 'someaccount@tenant.onmicrosoft.com', force: true - } - } as any), new CommandError(error)); + }) + }), new CommandError(error)); }); it('remove role assignment from folder and get principal id by group name', async () => { @@ -307,13 +308,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_REMOVE, () => { sinon.stub(spo, 'getGroupByName').resolves(groupResponse); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup', force: true - } + }) }); }); @@ -330,13 +331,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', entraGroupId: '27ae47f1-48f1-46f3-980b-d3c1470e398d', force: true - } + }) }); }); @@ -353,13 +354,13 @@ describe(commands.FOLDER_ROLEASSIGNMENT_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', entraGroupName: 'Marketing', force: true - } + }) }); }); @@ -376,24 +377,24 @@ describe(commands.FOLDER_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', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup', force: true - } - } as any), new CommandError(error)); + }) + }), 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', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup' - } + }) }); assert(requests.length === 0); @@ -401,12 +402,12 @@ describe(commands.FOLDER_ROLEASSIGNMENT_REMOVE, () => { 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', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup' - } + }) }); assert(promptIssued); }); @@ -425,12 +426,12 @@ describe(commands.FOLDER_ROLEASSIGNMENT_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: 'https://contoso.sharepoint.com', folderUrl: '/Shared Documents/FolderPermission', groupName: 'someGroup' - } + }) }); }); }); diff --git a/src/m365/spo/commands/folder/folder-roleassignment-remove.ts b/src/m365/spo/commands/folder/folder-roleassignment-remove.ts index 6d07f06511f..cf2b7b8e6de 100644 --- a/src/m365/spo/commands/folder/folder-roleassignment-remove.ts +++ b/src/m365/spo/commands/folder/folder-roleassignment-remove.ts @@ -1,6 +1,7 @@ +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 { formatting } from '../../../../utils/formatting.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -10,21 +11,36 @@ import commands from '../../commands.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { spo } from '../../../../utils/spo.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'), + folderUrl: z.string(), + principalId: z.number() + .refine(id => validation.isValidPositiveInteger(id), { + error: e => `Specified principalId '${e.input}' is not a valid 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; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl: string; - principalId?: number; - upn?: string; - groupName?: string; - entraGroupId?: string; - entraGroupName?: string; - force?: boolean; -} - class SpoFolderRoleAssignmentRemoveCommand extends SpoCommand { public get name(): string { return commands.FOLDER_ROLEASSIGNMENT_REMOVE; @@ -34,90 +50,19 @@ class SpoFolderRoleAssignmentRemoveCommand extends SpoCommand { return 'Removes a role assignment from the specified folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initTypes(); - } - - #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 - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '--folderUrl ' - }, - { - 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.`; - } - - const principalOptions: any[] = [args.options.principalId, args.options.upn, args.options.groupName, args.options.entraGroupId, args.options.entraGroupName]; - if (principalOptions.some(item => item !== undefined) && principalOptions.filter(item => item !== undefined).length > 1) { - return `Specify either principalId id, upn, groupName, entraGroupId or entraGroupName`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.principalId, opts.upn, opts.groupName, opts.entraGroupId, opts.entraGroupName].filter(o => o !== undefined).length === 1, { + error: 'Specify one of the following options: principalId, upn, groupName, entraGroupId, entraGroupName.', + params: { + customCode: 'optionSet', + options: ['principalId', 'upn', 'groupName', 'entraGroupId', 'entraGroupName'] } - - if (principalOptions.filter(item => item !== undefined).length === 0) { - return `Specify at least principalId id, upn, groupName, entraGroupId or entraGroupName`; - } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderUrl', 'upn', 'groupName'); - this.types.boolean.push('force'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-roleinheritance-break.spec.ts b/src/m365/spo/commands/folder/folder-roleinheritance-break.spec.ts index 89b2ee08fae..56aebea0d9e 100644 --- a/src/m365/spo/commands/folder/folder-roleinheritance-break.spec.ts +++ b/src/m365/spo/commands/folder/folder-roleinheritance-break.spec.ts @@ -13,7 +13,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import commands from '../../commands.js'; -import command from './folder-roleinheritance-break.js'; +import command, { options } from './folder-roleinheritance-break.js'; describe(commands.FOLDER_ROLEINHERITANCE_BREAK, () => { const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; @@ -23,6 +23,7 @@ describe(commands.FOLDER_ROLEINHERITANCE_BREAK, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let promptIssued: boolean = false; before(() => { @@ -32,6 +33,7 @@ describe(commands.FOLDER_ROLEINHERITANCE_BREAK, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -75,22 +77,28 @@ describe(commands.FOLDER_ROLEINHERITANCE_BREAK, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', folderUrl: folderUrl, force: true } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', folderUrl: folderUrl, force: true }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('passes validation if webUrl and folderUrl are valid', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderUrl: folderUrl, force: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if webUrl and folderUrl are valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderUrl: folderUrl, force: true }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderUrl: folderUrl, force: true, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('prompts before breaking role inheritance for the folder when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl - } + }) }); @@ -101,10 +109,10 @@ describe(commands.FOLDER_ROLEINHERITANCE_BREAK, () => { const postSpy = sinon.spy(request, 'post'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl - } + }) }); assert(postSpy.notCalled); @@ -121,12 +129,12 @@ describe(commands.FOLDER_ROLEINHERITANCE_BREAK, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, folderUrl: folderUrl, force: true - } + }) }); }); @@ -144,10 +152,10 @@ describe(commands.FOLDER_ROLEINHERITANCE_BREAK, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl - } + }) }); }); @@ -165,10 +173,10 @@ describe(commands.FOLDER_ROLEINHERITANCE_BREAK, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: rootFolderUrl - } + }) }); }); it('breaks role inheritance and clears existing scopes on folder by site-relative URL when prompt confirmed', async () => { @@ -185,11 +193,11 @@ describe(commands.FOLDER_ROLEINHERITANCE_BREAK, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl, clearExistingPermissions: true - } + }) }); }); @@ -208,12 +216,12 @@ describe(commands.FOLDER_ROLEINHERITANCE_BREAK, () => { sinon.stub(request, 'post').rejects(error); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, folderUrl: folderUrl, force: true - } + }) }), new CommandError(errorMessage)); }); }); diff --git a/src/m365/spo/commands/folder/folder-roleinheritance-break.ts b/src/m365/spo/commands/folder/folder-roleinheritance-break.ts index d58a37f22b5..8e46e097099 100644 --- a/src/m365/spo/commands/folder/folder-roleinheritance-break.ts +++ b/src/m365/spo/commands/folder/folder-roleinheritance-break.ts @@ -1,6 +1,7 @@ +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 { formatting } from '../../../../utils/formatting.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -8,17 +9,24 @@ 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'), + folderUrl: z.string(), + clearExistingPermissions: z.boolean().optional().alias('c'), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl: string; - clearExistingPermissions?: boolean; - force?: boolean; -} - class SpoFolderRoleInheritanceBreakCommand extends SpoCommand { public get name(): string { return commands.FOLDER_ROLEINHERITANCE_BREAK; @@ -28,50 +36,8 @@ class SpoFolderRoleInheritanceBreakCommand extends SpoCommand { return 'Breaks the role inheritance of a folder. Keeping existing permissions is the default behavior.'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initTypes(); - } - - #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 ' - }, - { - option: '--folderUrl ' - }, - { - option: '-c, --clearExistingPermissions' - }, - { - option: '-f, --force' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.webUrl) - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderUrl'); - this.types.boolean.push('clearExistingPermissions', 'force'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-roleinheritance-reset.spec.ts b/src/m365/spo/commands/folder/folder-roleinheritance-reset.spec.ts index 6c451ba76fd..74011011a25 100644 --- a/src/m365/spo/commands/folder/folder-roleinheritance-reset.spec.ts +++ b/src/m365/spo/commands/folder/folder-roleinheritance-reset.spec.ts @@ -11,7 +11,7 @@ import commands from '../../commands.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; -import command from './folder-roleinheritance-reset.js'; +import command, { options } from './folder-roleinheritance-reset.js'; describe(commands.FOLDER_ROLEINHERITANCE_RESET, () => { const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; @@ -21,6 +21,7 @@ describe(commands.FOLDER_ROLEINHERITANCE_RESET, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let promptIssued: boolean = false; before(() => { @@ -30,6 +31,7 @@ describe(commands.FOLDER_ROLEINHERITANCE_RESET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -73,22 +75,28 @@ describe(commands.FOLDER_ROLEINHERITANCE_RESET, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', folderUrl: folderUrl, force: true } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', folderUrl: folderUrl, force: true }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('passes validation if webUrl and folderUrl are valid', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderUrl: folderUrl, force: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if webUrl and folderUrl are valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderUrl: folderUrl, force: true }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderUrl: folderUrl, force: true, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); it('prompts before resetting role inheritance for the folder when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl - } + }) }); @@ -99,10 +107,10 @@ describe(commands.FOLDER_ROLEINHERITANCE_RESET, () => { const postSpy = sinon.spy(request, 'post'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl - } + }) }); assert(postSpy.notCalled); @@ -118,12 +126,12 @@ describe(commands.FOLDER_ROLEINHERITANCE_RESET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: webUrl, folderUrl: folderUrl, force: true - } + }) }); }); @@ -140,10 +148,10 @@ describe(commands.FOLDER_ROLEINHERITANCE_RESET, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl - } + }) }); }); @@ -160,10 +168,10 @@ describe(commands.FOLDER_ROLEINHERITANCE_RESET, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: rootFolderUrl - } + }) }); }); @@ -180,12 +188,12 @@ describe(commands.FOLDER_ROLEINHERITANCE_RESET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, folderUrl: folderUrl, force: true - } + }) }), new CommandError(errorMessage)); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-roleinheritance-reset.ts b/src/m365/spo/commands/folder/folder-roleinheritance-reset.ts index 3b9deff36ba..5f9c67e43b6 100644 --- a/src/m365/spo/commands/folder/folder-roleinheritance-reset.ts +++ b/src/m365/spo/commands/folder/folder-roleinheritance-reset.ts @@ -1,6 +1,7 @@ +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 { formatting } from '../../../../utils/formatting.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -8,16 +9,23 @@ 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'), + folderUrl: z.string(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl: string; - force?: boolean; -} - class SpoFolderRoleInheritanceResetCommand extends SpoCommand { public get name(): string { return commands.FOLDER_ROLEINHERITANCE_RESET; @@ -27,46 +35,8 @@ class SpoFolderRoleInheritanceResetCommand extends SpoCommand { return 'Restores the role inheritance of a folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '--folderUrl ' - }, - { - option: '-f, --force' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.webUrl) - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderUrl'); - this.types.boolean.push('force'); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-set.spec.ts b/src/m365/spo/commands/folder/folder-set.spec.ts index 28f34403098..2e53253d22c 100644 --- a/src/m365/spo/commands/folder/folder-set.spec.ts +++ b/src/m365/spo/commands/folder/folder-set.spec.ts @@ -12,12 +12,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 './folder-set.js'; +import command, { options } from './folder-set.js'; +import { FolderColorValues } from './FolderColor.js'; describe(commands.FOLDER_SET, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; const folderRelSiteUrl = '/Shared Documents/Folder1'; @@ -34,6 +36,7 @@ describe(commands.FOLDER_SET, () => { auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -71,6 +74,10 @@ describe(commands.FOLDER_SET, () => { assert.notStrictEqual(command.description, null); }); + it('excludes options from URL processing', () => { + assert.deepStrictEqual((command as any).getExcludedOptionsWithUrls(), ['url']); + }); + it('renames folder correctly by using server relative URL', async () => { const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { if (opts.url === `${webUrl}/_api/Web/GetFolderByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(folderRelServerUrl)}')/ListItemAllFields`) { @@ -81,13 +88,13 @@ describe(commands.FOLDER_SET, () => { }); await command.action(logger, { - options: - { - verbose: true, - webUrl: webUrl, - url: folderRelServerUrl, - name: newFolderName - } + options: commandOptionsSchema.parse( + { + verbose: true, + webUrl: webUrl, + url: folderRelServerUrl, + name: newFolderName + }) }); assert.deepStrictEqual(patchStub.lastCall.args[0].data, { FileLeafRef: newFolderName, Title: newFolderName }); }); @@ -102,12 +109,12 @@ describe(commands.FOLDER_SET, () => { }); await command.action(logger, { - options: - { - webUrl: webUrl, - url: folderRelSiteUrl, - name: newFolderName - } + options: commandOptionsSchema.parse( + { + webUrl: webUrl, + url: folderRelSiteUrl, + name: newFolderName + }) }); assert.deepStrictEqual(patchStub.lastCall.args[0].data, { FileLeafRef: newFolderName, Title: newFolderName }); }); @@ -121,7 +128,7 @@ describe(commands.FOLDER_SET, () => { throw 'Invalid request URL: ' + opts.url; }); - await command.action(logger, { options: { webUrl: webUrl, url: folderRelSiteUrl, color: colorNumber } }); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, url: folderRelSiteUrl, color: colorNumber }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { coloringInformation: { @@ -140,7 +147,7 @@ describe(commands.FOLDER_SET, () => { throw 'Invalid request URL: ' + opts.url; }); - await command.action(logger, { options: { webUrl: webUrl, url: folderRelSiteUrl, color: colorName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, url: folderRelSiteUrl, color: colorName }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { coloringInformation: { @@ -159,7 +166,7 @@ describe(commands.FOLDER_SET, () => { throw 'Invalid request URL: ' + opts.url; }); - await command.action(logger, { options: { webUrl: webUrl, url: folderRelSiteUrl, color: colorName, name: newFolderName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, url: folderRelSiteUrl, color: colorName, name: newFolderName }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { coloringInformation: { @@ -178,7 +185,7 @@ describe(commands.FOLDER_SET, () => { throw 'Invalid request URL: ' + opts.url; }); - await command.action(logger, { options: { webUrl: webUrl, url: folderRelSiteUrl, color: colorNumber, name: newFolderName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, url: folderRelSiteUrl, color: colorNumber, name: newFolderName }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { coloringInformation: { @@ -209,7 +216,7 @@ describe(commands.FOLDER_SET, () => { throw 'Invalid request URL: ' + opts.url; }); - await assert.rejects(command.action(logger, { options: { debug: true, webUrl: webUrl, url: folderRelServerUrl, color: colorName } } as any) + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, url: folderRelServerUrl, color: colorName }) }) , new CommandError(error.error['odata.error'].message.value)); }); @@ -217,50 +224,55 @@ describe(commands.FOLDER_SET, () => { sinon.stub(request, 'patch').resolves({ 'odata.null': true }); await assert.rejects(command.action(logger, { - options: - { - debug: true, - webUrl: webUrl, - url: folderRelServerUrl, - name: newFolderName - } - } as any), new CommandError('Folder not found.')); + options: commandOptionsSchema.parse( + { + debug: true, + webUrl: webUrl, + url: folderRelServerUrl, + name: newFolderName + }) + }), new CommandError('Folder not found.')); }); - it('fails validation if the webUrl option is not valid', async () => { - const actual = await command.validate({ options: { webUrl: 'abc', url: folderRelSiteUrl, name: newFolderName } }, commandInfo); - assert.strictEqual(actual, "'abc' is not a valid SharePoint Online site URL."); + it('fails validation if the webUrl option is not valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'abc', url: folderRelSiteUrl, name: newFolderName }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'abc' is not a valid SharePoint Online site URL.`); }); - it('passes validation when the url option specified', async () => { - const actual = await command.validate({ - options: + it('passes validation when the url option specified', () => { + const actual = commandOptionsSchema.safeParse( { webUrl: webUrl, url: folderRelServerUrl, name: newFolderName - } - }, commandInfo); - assert.strictEqual(actual, true); + }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation if color is passed as string and color is not a valid color', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, url: folderRelSiteUrl, color: 'invalid' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid value for option 'color'. Allowed values are ${Object.keys(FolderColorValues).join(', ')}, ${Object.values(FolderColorValues).join(', ')}.`); }); - it('fails validation if color is passed as string and color is not a valid color', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, url: folderRelSiteUrl, color: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('passes validation if color is passed as string and color is a valid color', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, url: folderRelSiteUrl, color: colorName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if color is passed as string and color is a valid color', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, url: folderRelSiteUrl, color: colorName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if color is passed as number and color is a valid color', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, url: folderRelSiteUrl, color: colorNumber }); + assert.strictEqual(actual.success, true); }); - it('passes validation if color is passed as number and color is a valid color', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, url: folderRelSiteUrl, color: colorNumber } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if neither name nor color is specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, url: folderRelSiteUrl }); + assert.strictEqual(actual.success, false); }); - it('fails validation if neither name nor color is specified', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, url: folderRelSiteUrl } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, url: folderRelSiteUrl, name: newFolderName, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/spo/commands/folder/folder-set.ts b/src/m365/spo/commands/folder/folder-set.ts index 84c0d5a695c..afcad9c2fc1 100644 --- a/src/m365/spo/commands/folder/folder-set.ts +++ b/src/m365/spo/commands/folder/folder-set.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 { formatting } from '../../../../utils/formatting.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -8,17 +9,28 @@ import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { FolderColorValues } from './FolderColor.js'; +const folderColorValues = [...Object.keys(FolderColorValues), ...Object.values(FolderColorValues)] as [string, ...string[]]; + +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'), + url: z.string(), + name: z.string().optional().alias('n'), + color: z.enum(folderColorValues, { + error: e => `'${e.input}' is not a valid value for option 'color'. Allowed values are ${Object.keys(FolderColorValues).join(', ')}, ${Object.values(FolderColorValues).join(', ')}.` + }).optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - url: string; - name?: string; - color?: string; -} - class SpoFolderSetCommand extends SpoCommand { public get name(): string { @@ -29,66 +41,20 @@ class SpoFolderSetCommand extends SpoCommand { return 'Updates a folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initTypes(); + public get schema(): z.ZodType | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - name: typeof args.options.name !== 'undefined', - color: typeof args.options.color !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '--url ' - }, - { - option: '-n, --name [name]' - }, - { - option: '--color [color]', - autocomplete: Object.keys(FolderColorValues) - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => opts.name !== undefined || opts.color !== undefined, { + error: `Specify at least one of the options: name or color.`, + params: { + customCode: 'required' } - - if (args.options.color === undefined && args.options.name === undefined) { - return `Specify at least one of the options: name or color.`; - } - - if (args.options.color && !Object.entries(FolderColorValues).flat().includes(args.options.color)) { - return `'${args.options.color}' is not a valid value for option 'color'. Allowed values are ${Object.keys(FolderColorValues).join(', ')}, ${Object.values(FolderColorValues).join(', ')}.`; - } - - return true; }); } - #initTypes(): void { - this.types.string.push('webUrl', 'url', 'name', 'color'); - } - protected getExcludedOptionsWithUrls(): string[] | undefined { return ['url']; } diff --git a/src/m365/spo/commands/folder/folder-sharinglink-add.spec.ts b/src/m365/spo/commands/folder/folder-sharinglink-add.spec.ts index cd12aa7e13c..470b2bce8fc 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-add.spec.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-add.spec.ts @@ -14,7 +14,7 @@ import { spo } from '../../../../utils/spo.js'; import { drive } from '../../../../utils/drive.js'; import { Drive } from '@microsoft/microsoft-graph-types'; import commands from '../../commands.js'; -import command from './folder-sharinglink-add.js'; +import command, { options } from './folder-sharinglink-add.js'; import { settingsNames } from '../../../../settingsNames.js'; describe(commands.FOLDER_SHARINGLINK_ADD, () => { @@ -22,6 +22,7 @@ describe(commands.FOLDER_SHARINGLINK_ADD, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; const folderId = 'f09c4efe-b8c0-4e89-a166-03418661b89b'; @@ -66,6 +67,7 @@ describe(commands.FOLDER_SHARINGLINK_ADD, () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => settingName === settingsNames.prompt ? false : defaultValue); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -107,44 +109,60 @@ describe(commands.FOLDER_SHARINGLINK_ADD, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', folderId: folderId, type: "view" } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', folderId: folderId, type: "view" }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('fails validation if the folderId option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: 'invalid', type: "view" } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the folderId option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: 'invalid', type: "view" }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option 'folderId'.`); }); - it('fails validation if the expirationDateTime option is not a valid date', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, expirationDateTime: 'invalid date', type: 'view' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the expirationDateTime option is not a valid date', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderId: folderId, expirationDateTime: 'invalid date', type: 'view' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid date' is not a valid ISO date string.`); }); - it('fails validation if invalid scope specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, scope: 'invalid scope', type: 'view' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if invalid scope specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderId: folderId, scope: 'invalid scope', type: 'view' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid scope' is not a valid scope. Allowed values are: anonymous, organization, users.`); }); - it('fails validation if invalid type specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'invalid type' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if invalid type specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'invalid type' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid type' is not a valid type. Allowed values are: view,edit`); }); - it('fails validation if scope is users but recipients are not specified', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'view', scope: 'users' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if scope is users but recipients are not specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'view', scope: 'users' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if recipients option is not a valid UPN', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'view', scope: 'users', recipients: "invalid upn" } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if recipients option is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'view', scope: 'users', recipients: "invalid upn" }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `The following user principal names are invalid for the option 'recipients': invalid upn.`); }); - it('passes validation if options are valid', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'view' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both folderId and folderUrl are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderId: folderId, folderUrl: folderUrl, type: 'view' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'view', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if options are valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'view' }); + assert.strictEqual(actual.success, true); }); it('creates a sharing link to a folder specified by the id', async () => { @@ -158,7 +176,7 @@ describe(commands.FOLDER_SHARINGLINK_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { webUrl: webUrl, folderId: folderId, type: 'view', scope: 'organization', verbose: true } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderId: folderId, type: 'view', scope: 'organization', verbose: true }) }); assert(loggerLogSpy.calledWith(graphResponse)); }); @@ -173,7 +191,7 @@ describe(commands.FOLDER_SHARINGLINK_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, type: 'view', scope: 'users', recipients: 'john@contoso.com', verbose: true } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl, type: 'view', scope: 'users', recipients: 'john@contoso.com', verbose: true }) }); assert(loggerLogSpy.calledWith(graphResponse)); }); @@ -190,7 +208,7 @@ describe(commands.FOLDER_SHARINGLINK_ADD, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, type: 'view' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl, type: 'view' }) }), new CommandError(`Drive 'https://contoso.sharepoint.com/sites/project-x/shared%20documents/folder1' not found`)); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-sharinglink-add.ts b/src/m365/spo/commands/folder/folder-sharinglink-add.ts index 4dc438c9559..a13bbe7fd66 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-add.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-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, { CliRequestOptions } from '../../../../request.js'; import { spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -10,25 +11,51 @@ import SpoCommand from '../../../base/SpoCommand.js'; import { Drive } from '@microsoft/microsoft-graph-types'; import commands from '../../commands.js'; +const allowedTypes = ['view', 'edit'] as const; +const allowedScopes = ['anonymous', 'organization', 'users'] as const; + +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'), + folderId: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option 'folderId'.` + }) + .optional(), + folderUrl: z.string().optional(), + type: z.enum(allowedTypes, { + error: e => `'${e.input}' is not a valid type. Allowed values are: ${allowedTypes.join(',')}` + }), + expirationDateTime: z.string() + .refine(dateTime => validation.isValidISODateTime(dateTime), { + error: e => `'${e.input}' is not a valid ISO date string.` + }) + .optional(), + scope: z.enum(allowedScopes, { + error: e => `'${e.input}' is not a valid scope. Allowed values are: ${allowedScopes.join(', ')}.` + }).optional(), + retainInheritedPermissions: z.boolean().optional(), + recipients: z.string() + .refine(recipients => validation.isValidUserPrincipalNameArray(recipients) === true, { + error: e => { + const invalidUpns = String(e.input).split(',').map(upn => upn.trim()).filter(upn => !validation.isValidUserPrincipalName(upn)); + return `The following user principal names are invalid for the option 'recipients': ${invalidUpns.join(', ')}.`; + } + }) + .optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl?: string; - folderId?: string; - type: string; - expirationDateTime?: string; - scope?: string; - retainInheritiedPermissions?: boolean; - recipients?: string; -} - class SpoFolderSharingLinkAddCommand extends SpoCommand { - private readonly allowedTypes: string[] = ['view', 'edit']; - private readonly allowedScopes: string[] = ['anonymous', 'organization', 'users']; - public get name(): string { return commands.FOLDER_SHARINGLINK_ADD; } @@ -37,112 +64,26 @@ class SpoFolderSharingLinkAddCommand extends SpoCommand { return 'Creates a new sharing link to a folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); + public get schema(): z.ZodType | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - folderId: typeof args.options.folderId !== 'undefined', - folderUrl: typeof args.options.folderUrl !== 'undefined', - type: typeof args.options.type !== 'undefined', - expirationDateTime: typeof args.options.expirationDateTime !== 'undefined', - scope: typeof args.options.scope !== 'undefined', - retainInheritiedPermissions: !!args.options.retainInheritiedPermissions, - recipients: typeof args.options.recipients !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '--folderId [folderId]' - }, - { - option: '--folderUrl [folderUrl]' - }, - { - option: '--type ', - autocomplete: this.allowedTypes - }, - { - option: '--expirationDateTime [expirationDateTime]' - }, - { - option: '--scope [scope]', - autocomplete: this.allowedScopes - }, - { - option: '--retainInheritedPermissions [retainInheritedPermissions]' - }, - { - option: '--recipients [recipients]' - } - ); - } - - #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.folderId && !validation.isValidGuid(args.options.folderId)) { - return `${args.options.folderId} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.folderId, opts.folderUrl].filter(o => o !== undefined).length === 1, { + error: `Specify one of the following options: folderId, folderUrl.`, + params: { + customCode: 'optionSet', + options: ['folderId', 'folderUrl'] } - - if (args.options.type && !this.allowedTypes.some(type => type === args.options.type)) { - return `'${args.options.type}' is not a valid type. Allowed values are: ${this.allowedTypes.join(',')}`; - } - - if (args.options.scope) { - if (!this.allowedScopes.includes(args.options.scope)) { - return `'${args.options.scope}' is not a valid scope. Allowed values are: ${this.allowedScopes.join(', ')}.`; - } - } - - if (args.options.expirationDateTime && !validation.isValidISODateTime(args.options.expirationDateTime)) { - return `${args.options.expirationDateTime} is not a valid ISO date string.`; - } - - if (args.options.recipients) { - const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.recipients); - if (isValidUPNArrayResult !== true) { - return `The following user principal names are invalid for the option 'recipients': ${isValidUPNArrayResult}.`; - } + }) + .refine(opts => opts.scope !== 'users' || opts.recipients !== undefined, { + error: `Specify recipients when the scope is set to 'users'.`, + path: ['recipients'], + params: { + customCode: 'required' } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['folderId', 'folderUrl'] }, - { - options: ['recipients'], - runsWhen: (args) => args.options.scope !== undefined && args.options.scope === 'users' - } - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderId', 'folderUrl', 'type', 'expirationDateTime', 'scope', 'recipients'); - this.types.boolean.push('retainInheritiedPermissions'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -169,7 +110,7 @@ class SpoFolderSharingLinkAddCommand extends SpoCommand { type: args.options.type, expirationDateTime: args.options.expirationDateTime, scope: args.options.scope, - retainInheritedPermissions: !!args.options.retainInheritiedPermissions + retainInheritedPermissions: !!args.options.retainInheritedPermissions } }; diff --git a/src/m365/spo/commands/folder/folder-sharinglink-clear.spec.ts b/src/m365/spo/commands/folder/folder-sharinglink-clear.spec.ts index 28619a1b21a..38052018f5e 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-clear.spec.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-clear.spec.ts @@ -12,7 +12,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import commands from '../../commands.js'; -import command from './folder-sharinglink-clear.js'; +import command, { options } from './folder-sharinglink-clear.js'; import { spo } from '../../../../utils/spo.js'; import { drive } from '../../../../utils/drive.js'; import { Drive } from '@microsoft/microsoft-graph-types'; @@ -21,6 +21,7 @@ describe(commands.FOLDER_SHARINGLINK_CLEAR, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let promptIssued: boolean = false; const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; @@ -87,6 +88,7 @@ describe(commands.FOLDER_SHARINGLINK_CLEAR, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -136,32 +138,45 @@ describe(commands.FOLDER_SHARINGLINK_CLEAR, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', folderId: folderId } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', folderId: folderId }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('fails validation if the folderId option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the folderId option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: 'invalid' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option 'folderId'.`); }); - it('fails validation if invalid scope specified', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, scope: 'invalid scope' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if invalid scope specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, scope: 'invalid scope' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid scope' is not a valid scope. Allowed values are: anonymous, users, organization`); }); - it('passes validation if options are valid', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, scope: 'organization' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both folderId and folderUrl are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, folderUrl: folderUrl }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if options are valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, scope: 'organization' }); + assert.strictEqual(actual.success, true); }); it('prompts before clearing the sharing links from a folder when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderId: folderId - } + }) }); assert(promptIssued); @@ -173,10 +188,10 @@ describe(commands.FOLDER_SHARINGLINK_CLEAR, () => { sinon.stub(cli, 'promptForConfirmation').resolves(false); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl - } + }) }); assert(deleteSpy.notCalled); @@ -199,12 +214,12 @@ describe(commands.FOLDER_SHARINGLINK_CLEAR, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: webUrl, folderId: folderId, scope: scope - } + }) }); assert(requestDeleteStub.called); @@ -223,12 +238,12 @@ describe(commands.FOLDER_SHARINGLINK_CLEAR, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: webUrl, folderUrl: folderUrl, force: true - } + }) }); assert(requestDeleteStub.called); @@ -247,7 +262,7 @@ describe(commands.FOLDER_SHARINGLINK_CLEAR, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, force: true } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl, force: true }) }), new CommandError(`Drive 'https://contoso.sharepoint.com/sites/project-x/shared%20documents/folder1' not found`)); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-sharinglink-clear.ts b/src/m365/spo/commands/folder/folder-sharinglink-clear.ts index c50718f71d0..3315c30e455 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-clear.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-clear.ts @@ -1,6 +1,7 @@ +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 { spo } from '../../../../utils/spo.js'; import { odata } from '../../../../utils/odata.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -11,21 +12,34 @@ import { Drive } from '@microsoft/microsoft-graph-types'; import request, { CliRequestOptions } from '../../../../request.js'; import commands from '../../commands.js'; +const allowedScopes = ['anonymous', 'users', 'organization'] as const; + +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'), + folderUrl: z.string().optional(), + folderId: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option 'folderId'.` + }) + .optional(), + scope: z.enum(allowedScopes, { + error: e => `'${e.input}' is not a valid scope. Allowed values are: ${allowedScopes.join(', ')}` + }).optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl?: string; - folderId?: string; - scope?: string; - force?: boolean; -} - class SpoFolderSharingLinkClearCommand extends SpoCommand { - private readonly allowedScopes: string[] = ['anonymous', 'users', 'organization']; - public get name(): string { return commands.FOLDER_SHARINGLINK_CLEAR; } @@ -34,71 +48,19 @@ class SpoFolderSharingLinkClearCommand extends SpoCommand { return 'Removes all sharing links of a folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initOptionSets(); - this.#initValidators(); - this.#initTypes(); + public get schema(): z.ZodType | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - webUrl: typeof args.options.webUrl !== 'undefined', - folderUrl: typeof args.options.folderUrl !== 'undefined', - folderId: typeof args.options.folderId !== 'undefined', - scope: typeof args.options.scope !== 'undefined', - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-u, --webUrl ' }, - { option: '--folderUrl [folderUrl]' }, - { option: '--folderId [folderId]' }, - { - option: '--scope [scope]', - autocomplete: this.allowedScopes - }, - { option: '-f, --force' } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['folderUrl', 'folderId'] } - ); - } - - #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.folderId && !validation.isValidGuid(args.options.folderId)) { - return `${args.options.folderId} is not a valid GUID`; - } - - if (args.options.scope && !this.allowedScopes.some(scope => scope === args.options.scope)) { - return `'${args.options.scope}' is not a valid scope. Allowed values are: ${this.allowedScopes.join(',')}`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.folderUrl, opts.folderId].filter(o => o !== undefined).length === 1, { + error: `Specify one of the following options: folderUrl, folderId.`, + params: { + customCode: 'optionSet', + options: ['folderUrl', 'folderId'] } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderUrl', 'folderId', 'scope'); - this.types.boolean.push('force'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-sharinglink-get.spec.ts b/src/m365/spo/commands/folder/folder-sharinglink-get.spec.ts index 6e98fcf820b..c9e71905569 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-get.spec.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-get.spec.ts @@ -10,7 +10,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 './folder-sharinglink-get.js'; +import command, { options } from './folder-sharinglink-get.js'; import { spo } from '../../../../utils/spo.js'; import { drive } from '../../../../utils/drive.js'; import { CommandError } from '../../../../Command.js'; @@ -20,6 +20,7 @@ describe(commands.FOLDER_SHARINGLINK_GET, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; const folderUrl = '/sites/project-x/shared documents/folder1'; @@ -75,6 +76,7 @@ describe(commands.FOLDER_SHARINGLINK_GET, () => { sinon.stub(spo, 'getSiteIdByMSGraph').resolves(siteId); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -115,37 +117,50 @@ describe(commands.FOLDER_SHARINGLINK_GET, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', folderId: folderId, id: id } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', folderId: folderId, id: id }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('fails validation if the folderId option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: 'invalid', id: id } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the folderId option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: 'invalid', id: id }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option 'folderId'.`); }); - it('fails validation if the id option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, id: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the id option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, id: 'invalid' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option 'id'.`); }); - it('passes validation if options are valid', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, id: id } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both folderId and folderUrl are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, folderUrl: folderUrl, id: id }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, id: id, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if options are valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, id: id }); + assert.strictEqual(actual.success, true); }); it('retrieves sharing link of folder specified by id (debug)', async () => { defaultGetStub(); - await command.action(logger, { options: { debug: true, webUrl: webUrl, folderId: folderId, id: id } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, folderId: folderId, id: id }) }); assert(loggerLogSpy.calledWith(graphResponse)); }); it('retrieves sharing link of folder specified by url (debug)', async () => { defaultGetStub(); - await command.action(logger, { options: { debug: true, webUrl: webUrl, folderUrl: folderUrl, id: id } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, folderUrl: folderUrl, id: id }) }); assert(loggerLogSpy.calledWith(graphResponse)); }); @@ -154,12 +169,12 @@ describe(commands.FOLDER_SHARINGLINK_GET, () => { sinon.stub(request, 'get').rejects({ error: { error: { message: errorMessage } } }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, webUrl: webUrl, folderUrl: folderUrl, id: id - } + }) }), new CommandError(errorMessage)); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-sharinglink-get.ts b/src/m365/spo/commands/folder/folder-sharinglink-get.ts index 4849f380d5a..411ef486c1b 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-get.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-get.ts @@ -1,6 +1,7 @@ +import { z } from 'zod'; import { Permission } from '@microsoft/microsoft-graph-types'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -10,17 +11,32 @@ import { Drive } from '@microsoft/microsoft-graph-types'; import commands from '../../commands.js'; import { drive as driveItem } from '../../../../utils/drive.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'), + folderUrl: z.string().optional(), + folderId: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option 'folderId'.` + }) + .optional(), + id: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option 'id'.` + }) + .alias('i') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl?: string; - folderId?: string; - id: string; -} - class SpoFolderSharingLinkGetCommand extends SpoCommand { public get name(): string { return commands.FOLDER_SHARINGLINK_GET; @@ -30,63 +46,19 @@ class SpoFolderSharingLinkGetCommand extends SpoCommand { return 'Gets details about a specific sharing link on a folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initOptionSets(); - this.#initValidators(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - folderUrl: typeof args.options.folderUrl !== 'undefined', - folderId: typeof args.options.folderId !== 'undefined' - }); - }); + public get schema(): z.ZodType | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { option: '-u, --webUrl ' }, - { option: '--folderUrl [folderUrl]' }, - { option: '--folderId [folderId]' }, - { option: '-i, --id ' } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['folderUrl', 'folderId'] } - ); - } - - #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.folderId && !validation.isValidGuid(args.options.folderId)) { - return `${args.options.folderId} is not a valid GUID for option 'folderId'.`; - } - - if (!validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID for option 'id'.`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.folderUrl, opts.folderId].filter(o => o !== undefined).length === 1, { + error: `Specify one of the following options: folderUrl, folderId.`, + params: { + customCode: 'optionSet', + options: ['folderUrl', 'folderId'] } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderUrl', 'folderId', 'id'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-sharinglink-list.spec.ts b/src/m365/spo/commands/folder/folder-sharinglink-list.spec.ts index 91d5c9a471a..28599545125 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-list.spec.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-list.spec.ts @@ -12,7 +12,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import commands from '../../commands.js'; -import command from './folder-sharinglink-list.js'; +import command, { options } from './folder-sharinglink-list.js'; import { spo } from '../../../../utils/spo.js'; import { drive } from '../../../../utils/drive.js'; import { Drive } from '@microsoft/microsoft-graph-types'; @@ -22,6 +22,7 @@ describe(commands.FOLDER_SHARINGLINK_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; const folderId = 'f09c4efe-b8c0-4e89-a166-03418661b89b'; @@ -125,6 +126,7 @@ describe(commands.FOLDER_SHARINGLINK_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -167,31 +169,44 @@ describe(commands.FOLDER_SHARINGLINK_LIST, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', folderId: folderId } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', folderId: folderId }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('fails validation if the folderId option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the folderId option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: 'invalid' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option 'folderId'.`); }); - it('fails validation if invalid scope specified', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, scope: 'invalid scope' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if invalid scope specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, scope: 'invalid scope' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid scope' is not a valid scope. Allowed values are: anonymous, users, organization`); }); - it('passes validation if options are valid', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if both folderId and folderUrl are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, folderUrl: folderUrl }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if options are valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId }); + assert.strictEqual(actual.success, true); }); it('retrieves sharing links from folder specified by id', async () => { getStubs({ folderUrl: folderUrl, siteId: siteId, drive: driveDetails, itemId: itemId }); stubOdataResponse(graphResponse); - await command.action(logger, { options: { webUrl: webUrl, folderId: folderId, verbose: true } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderId: folderId, verbose: true }) }); assert(loggerLogSpy.calledWith(graphResponse.value)); }); @@ -199,7 +214,7 @@ describe(commands.FOLDER_SHARINGLINK_LIST, () => { getStubs({ folderUrl: folderUrl, siteId: siteId, drive: driveDetails, itemId: itemId }); stubOdataResponse(graphResponse); - await command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, verbose: true } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl, verbose: true }) }); assert(loggerLogSpy.calledWith(graphResponse.value)); }); @@ -208,7 +223,7 @@ describe(commands.FOLDER_SHARINGLINK_LIST, () => { getStubs({ folderUrl: folderUrl, siteId: siteId, drive: driveDetails, itemId: itemId }); stubOdataScopeResponse(scope, graphResponse); - await command.action(logger, { options: { webUrl: webUrl, folderId: folderId, scope: scope } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderId: folderId, scope: scope }) }); assert(loggerLogSpy.calledWith(graphResponse.value.filter(x => x.link.scope === scope))); }); @@ -217,7 +232,7 @@ describe(commands.FOLDER_SHARINGLINK_LIST, () => { getStubs({ folderUrl: folderUrl, siteId: siteId, drive: driveDetails, itemId: itemId }); stubOdataScopeResponse(scope, graphResponse); - await command.action(logger, { options: { webUrl: webUrl, folderId: folderId, scope: scope, output: 'text' } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderId: folderId, scope: scope, output: 'text' }) }); assert(loggerLogSpy.calledWith(graphResponseText)); }); @@ -234,7 +249,7 @@ describe(commands.FOLDER_SHARINGLINK_LIST, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, verbose: true } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl, verbose: true }) }), new CommandError(`Drive 'https://contoso.sharepoint.com/sites/project-x/shared%20documents/folder1' not found`)); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-sharinglink-list.ts b/src/m365/spo/commands/folder/folder-sharinglink-list.ts index 39c17ad8d44..4ac9d269749 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-list.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-list.ts @@ -1,6 +1,7 @@ +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 { spo } from '../../../../utils/spo.js'; import { odata } from '../../../../utils/odata.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -10,20 +11,33 @@ import SpoCommand from '../../../base/SpoCommand.js'; import { Drive } from '@microsoft/microsoft-graph-types'; import commands from '../../commands.js'; +const allowedScopes = ['anonymous', 'users', 'organization'] as const; + +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'), + folderUrl: z.string().optional(), + folderId: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option 'folderId'.` + }) + .optional(), + scope: z.enum(allowedScopes, { + error: e => `'${e.input}' is not a valid scope. Allowed values are: ${allowedScopes.join(', ')}` + }).optional().alias('s') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl?: string; - folderId?: string; - scope?: string; -} - class SpoFolderSharingLinkListCommand extends SpoCommand { - private readonly allowedScopes: string[] = ['anonymous', 'users', 'organization']; - public get name(): string { return commands.FOLDER_SHARINGLINK_LIST; } @@ -36,67 +50,19 @@ class SpoFolderSharingLinkListCommand extends SpoCommand { return ['id', 'scope', 'roles', 'link']; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initOptionSets(); - this.#initValidators(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - folderUrl: typeof args.options.folderUrl !== 'undefined', - folderId: typeof args.options.folderId !== 'undefined', - scope: typeof args.options.scope !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-u, --webUrl ' }, - { option: '--folderUrl [folderUrl]' }, - { option: '--folderId [folderId]' }, - { - option: '-s, --scope [scope]', - autocomplete: this.allowedScopes - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['folderUrl', 'folderId'] } - ); + 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.folderId && !validation.isValidGuid(args.options.folderId)) { - return `${args.options.folderId} is not a valid GUID`; - } - - if (args.options.scope && !this.allowedScopes.some(scope => scope === args.options.scope)) { - return `'${args.options.scope}' is not a valid scope. Allowed values are: ${this.allowedScopes.join(',')}`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.folderUrl, opts.folderId].filter(o => o !== undefined).length === 1, { + error: `Specify one of the following options: folderUrl, folderId.`, + params: { + customCode: 'optionSet', + options: ['folderUrl', 'folderId'] } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderUrl', 'folderId', 'scope'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-sharinglink-remove.spec.ts b/src/m365/spo/commands/folder/folder-sharinglink-remove.spec.ts index 9890982cf25..10203e04210 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-remove.spec.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-remove.spec.ts @@ -11,7 +11,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import commands from '../../commands.js'; -import command from './folder-sharinglink-remove.js'; +import command, { options } from './folder-sharinglink-remove.js'; import { spo } from '../../../../utils/spo.js'; import { drive } from '../../../../utils/drive.js'; import { Drive } from '@microsoft/microsoft-graph-types'; @@ -20,6 +20,7 @@ describe(commands.FOLDER_SHARINGLINK_REMOVE, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let promptIssued: boolean = false; const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; @@ -49,6 +50,7 @@ describe(commands.FOLDER_SHARINGLINK_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -97,28 +99,46 @@ describe(commands.FOLDER_SHARINGLINK_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', folderId: folderId, id: id } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', folderId: folderId, id: id }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('fails validation if the folderId option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: 'invalid', id: id } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the folderId option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: 'invalid', id: id }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option 'folderId'.`); }); - it('passes validation if options are valid', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, id: id } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if the id option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, id: 'invalid' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option 'id'.`); + }); + + it('fails validation if both folderId and folderUrl are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, folderUrl: folderUrl, id: id }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, id: id, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if options are valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, id: id }); + assert.strictEqual(actual.success, true); }); it('prompts before removing the specified sharing link to a folder when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderId: folderId, id: id - } + }) }); assert(promptIssued); @@ -130,11 +150,11 @@ describe(commands.FOLDER_SHARINGLINK_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(false); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl, id: id - } + }) }); assert(deleteSpy.notCalled); @@ -155,12 +175,12 @@ describe(commands.FOLDER_SHARINGLINK_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: webUrl, folderId: folderId, id: id - } + }) }); assert(requestDeleteStub.called); }); @@ -180,13 +200,13 @@ describe(commands.FOLDER_SHARINGLINK_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, webUrl: webUrl, folderUrl: folderUrl, id: id, force: true - } + }) }); assert(requestDeleteStub.called); }); @@ -204,7 +224,7 @@ describe(commands.FOLDER_SHARINGLINK_REMOVE, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, force: true } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl, id: id, force: true }) }), new CommandError(`Drive 'https://contoso.sharepoint.com/sites/project-x/shared%20documents/folder1' not found`)); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-sharinglink-remove.ts b/src/m365/spo/commands/folder/folder-sharinglink-remove.ts index a69d234ae82..025bedabb6d 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-remove.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-remove.ts @@ -1,6 +1,7 @@ +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 { spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { drive } from '../../../../utils/drive.js'; @@ -10,18 +11,33 @@ import { Drive } from '@microsoft/microsoft-graph-types'; import request, { CliRequestOptions } from '../../../../request.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'), + folderUrl: z.string().optional(), + folderId: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option 'folderId'.` + }) + .optional(), + id: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option 'id'.` + }) + .alias('i'), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl?: string; - folderId?: string; - id: string; - force?: boolean; -} - class SpoFolderSharingLinkRemoveCommand extends SpoCommand { public get name(): string { return commands.FOLDER_SHARINGLINK_REMOVE; @@ -31,62 +47,19 @@ class SpoFolderSharingLinkRemoveCommand extends SpoCommand { return 'Removes a sharing link from a folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initOptionSets(); - this.#initValidators(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - folderUrl: typeof args.options.folderUrl !== 'undefined', - folderId: typeof args.options.folderId !== 'undefined', - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-u, --webUrl ' }, - { option: '--folderUrl [folderUrl]' }, - { option: '--folderId [folderId]' }, - { option: '-i, --id ' }, - { option: '-f, --force' } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initOptionSets(): void { - this.optionSets.push( - { options: ['folderUrl', 'folderId'] } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.folderUrl, opts.folderId].filter(o => o !== undefined).length === 1, { + error: `Specify one of the following options: folderUrl, folderId.`, + params: { + customCode: 'optionSet', + options: ['folderUrl', 'folderId'] } - - if (args.options.folderId && !validation.isValidGuid(args.options.folderId)) { - return `${args.options.folderId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderUrl', 'folderId', 'id'); - this.types.boolean.push('force'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/folder/folder-sharinglink-set.spec.ts b/src/m365/spo/commands/folder/folder-sharinglink-set.spec.ts index 800aba3b0bd..0c9ffc79955 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-set.spec.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-set.spec.ts @@ -11,7 +11,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import commands from '../../commands.js'; -import command from './folder-sharinglink-set.js'; +import command, { options } from './folder-sharinglink-set.js'; import { spo } from '../../../../utils/spo.js'; import { drive } from '../../../../utils/drive.js'; import { Drive } from '@microsoft/microsoft-graph-types'; @@ -21,6 +21,7 @@ describe(commands.FOLDER_SHARINGLINK_SET, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; const folderId = 'f09c4efe-b8c0-4e89-a166-03418661b89b'; @@ -65,6 +66,7 @@ describe(commands.FOLDER_SHARINGLINK_SET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -107,24 +109,43 @@ describe(commands.FOLDER_SHARINGLINK_SET, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', folderId: folderId, id: id } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the webUrl option is not a valid SharePoint site URL', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', folderId: folderId, id: id }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'foo' is not a valid SharePoint Online site URL.`); }); - it('fails validation if the folderId option is not a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, folderId: 'invalid', id: id } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the folderId option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: 'invalid', id: id }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option 'folderId'.`); }); - it('fails validation if the expirationDateTime option is not a valid date', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, expirationDateTime: 'invalid date', id: id } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the id option is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, id: 'invalid' }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid GUID for option 'id'.`); }); - it('passes validation if options are valid', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, expirationDateTime: '2024-05-05T16:57:00.000Z', id: id } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if the expirationDateTime option is not a valid date', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderId: folderId, expirationDateTime: 'invalid date', id: id }); + assert.strictEqual(actual.success, false); + assert.strictEqual(actual.error?.issues[0].message, `'invalid date' is not a valid ISO date string.`); + }); + + it('fails validation if both folderId and folderUrl are specified', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, folderUrl: folderUrl, id: id }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, folderId: folderId, id: id, unknownOption: 'value' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation if options are valid', () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'https://contoso.sharepoint.com', folderId: folderId, expirationDateTime: '2024-05-05T16:57:00.000Z', id: id }); + assert.strictEqual(actual.success, true); }); it('updates a sharing link to a folder specified by the id', async () => { @@ -138,7 +159,7 @@ describe(commands.FOLDER_SHARINGLINK_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { webUrl: webUrl, folderId: folderId, expirationDateTime: '2024-05-05T16:57:00.000Z', id: id, verbose: true } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderId: folderId, expirationDateTime: '2024-05-05T16:57:00.000Z', id: id, verbose: true }) }); assert(loggerLogSpy.calledWith(graphResponse)); }); @@ -153,7 +174,7 @@ describe(commands.FOLDER_SHARINGLINK_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, expirationDateTime: '2024-05-05T16:57:00.000Z', id: id, verbose: true } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl, expirationDateTime: '2024-05-05T16:57:00.000Z', id: id, verbose: true }) }); assert(loggerLogSpy.calledWith(graphResponse)); }); @@ -170,7 +191,7 @@ describe(commands.FOLDER_SHARINGLINK_SET, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, expirationDateTime: '2024-05-05T16:57:00.000Z', id: id, verbose: true } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ webUrl: webUrl, folderUrl: folderUrl, expirationDateTime: '2024-05-05T16:57:00.000Z', id: id, verbose: true }) }), new CommandError(`Drive 'https://contoso.sharepoint.com/sites/project-x/shared%20documents/folder1' not found`)); }); }); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-sharinglink-set.ts b/src/m365/spo/commands/folder/folder-sharinglink-set.ts index 613d38ef2a0..1999b96a762 100644 --- a/src/m365/spo/commands/folder/folder-sharinglink-set.ts +++ b/src/m365/spo/commands/folder/folder-sharinglink-set.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 { spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; @@ -9,18 +10,37 @@ import SpoCommand from '../../../base/SpoCommand.js'; import { Drive } from '@microsoft/microsoft-graph-types'; 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'), + folderUrl: z.string().optional(), + folderId: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option 'folderId'.` + }) + .optional(), + id: z.string() + .refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID for option 'id'.` + }) + .alias('i'), + expirationDateTime: z.string() + .refine(dateTime => validation.isValidISODateTime(dateTime), { + error: e => `'${e.input}' is not a valid ISO date string.` + }) + .optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - webUrl: string; - folderUrl?: string; - folderId?: string; - id: string; - expirationDateTime?: string; -} - class SpoFolderSharingLinkSetCommand extends SpoCommand { public get name(): string { return commands.FOLDER_SHARINGLINK_SET; @@ -30,63 +50,19 @@ class SpoFolderSharingLinkSetCommand extends SpoCommand { return 'Updates a specific sharing link to a folder'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - folderUrl: typeof args.options.folderUrl !== 'undefined', - folderId: typeof args.options.folderId !== 'undefined', - expirationDateTime: typeof args.options.expirationDateTime !== 'undefined' - }); - }); + public get schema(): z.ZodType | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { option: '-u, --webUrl ' }, - { option: '--folderUrl [folderUrl]' }, - { option: '--folderId [folderId]' }, - { option: '-i, --id ' }, - { option: '--expirationDateTime [expirationDateTime]' } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(opts => [opts.folderId, opts.folderUrl].filter(o => o !== undefined).length === 1, { + error: `Specify one of the following options: folderId, folderUrl.`, + params: { + customCode: 'optionSet', + options: ['folderId', 'folderUrl'] } - - if (args.options.folderId && !validation.isValidGuid(args.options.folderId)) { - return `${args.options.folderId} is not a valid GUID`; - } - - if (args.options.expirationDateTime && !validation.isValidISODateTime(args.options.expirationDateTime)) { - return `${args.options.expirationDateTime} is not a valid ISO date string.`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['folderId', 'folderUrl'] }); - } - - #initTypes(): void { - this.types.string.push('webUrl', 'folderId', 'folderUrl', 'id', 'expirationDateTime'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise {