diff --git a/docs/docs/cmd/spo/storageentity/storageentity-get.mdx b/docs/docs/cmd/spo/storageentity/storageentity-get.mdx index 19327b6adf7..e2b5ee35355 100644 --- a/docs/docs/cmd/spo/storageentity/storageentity-get.mdx +++ b/docs/docs/cmd/spo/storageentity/storageentity-get.mdx @@ -17,22 +17,31 @@ m365 spo storageentity get [options] ```md definition-list `-k, --key ` : Name of the tenant property to retrieve + +`-u, --appCatalogUrl [appCatalogUrl]` +: URL of the app catalog site. If not specified, the tenant app catalog URL will be used. ``` ## Remarks -Tenant properties are stored in the app catalog site associated with the site to which you are currently connected. When retrieving the specified tenant property, SharePoint will automatically find the associated app catalog and try to retrieve the property from it. +Tenant properties are stored in the app catalog site associated with the site to which you are currently connected. When retrieving the specified tenant property, SharePoint will use the specified app catalog URL or automatically resolve the tenant app catalog URL. ## Examples -Show the value, description and comment of the _AnalyticsId_ tenant property +Show the value, description and comment of the _AnalyticsId_ tenant property using the tenant app catalog ```sh m365 spo storageentity get -k AnalyticsId ``` +Show the value, description and comment of the _AnalyticsId_ tenant property from a specific app catalog site + +```sh +m365 spo storageentity get -k AnalyticsId -u https://contoso.sharepoint.com/sites/appcatalog +``` + ## Response diff --git a/docs/docs/cmd/spo/storageentity/storageentity-list.mdx b/docs/docs/cmd/spo/storageentity/storageentity-list.mdx index 199fd21100e..0707392807f 100644 --- a/docs/docs/cmd/spo/storageentity/storageentity-list.mdx +++ b/docs/docs/cmd/spo/storageentity/storageentity-list.mdx @@ -15,18 +15,24 @@ m365 spo storageentity list [options] ## Options ```md definition-list -`-u, --appCatalogUrl ` -: URL of the app catalog site +`-u, --appCatalogUrl [appCatalogUrl]` +: URL of the app catalog site. If not specified, the tenant app catalog URL will be used. ``` ## Remarks -Tenant properties are stored in the app catalog site. To list all tenant properties, you have to specify the absolute URL of the app catalog site. If you specify an incorrect URL, or the site at the given URL is not an app catalog site, no properties will be retrieved. +Tenant properties are stored in the app catalog site. To list all tenant properties, you can specify the absolute URL of the app catalog site. If not specified, the command will automatically resolve the tenant app catalog URL. If you specify an incorrect URL, or the site at the given URL is not an app catalog site, no properties will be retrieved. ## Examples +List all tenant properties using the tenant app catalog + +```sh +m365 spo storageentity list +``` + List all tenant properties stored in the _https://contoso.sharepoint.com/sites/appcatalog_ app catalog site ```sh diff --git a/docs/docs/cmd/spo/storageentity/storageentity-remove.mdx b/docs/docs/cmd/spo/storageentity/storageentity-remove.mdx index 2343dcd204c..6825b56f823 100644 --- a/docs/docs/cmd/spo/storageentity/storageentity-remove.mdx +++ b/docs/docs/cmd/spo/storageentity/storageentity-remove.mdx @@ -13,8 +13,8 @@ m365 spo storageentity remove [options] ## Options ```md definition-list -`-u, --appCatalogUrl ` -: URL of the app catalog site +`-u, --appCatalogUrl [appCatalogUrl]` +: URL of the app catalog site. If not specified, the tenant app catalog URL will be used. `-k, --key ` : Name of the tenant property to retrieve @@ -27,7 +27,7 @@ m365 spo storageentity remove [options] ## Remarks -Tenant properties are stored in the app catalog site associated with that tenant. To remove a property, you have to specify the absolute URL of the app catalog site. If you specify the URL of a site different than the app catalog, you will get an access denied error. +Tenant properties are stored in the app catalog site associated with that tenant. To remove a property, you can specify the absolute URL of the app catalog site. If not specified, the command will automatically resolve the tenant app catalog URL. If you specify the URL of a site different than the app catalog, you will get an access denied error. :::info @@ -37,13 +37,13 @@ To use this command you must be either **SharePoint Administrator** or **Global ## Examples -Remove the _AnalyticsId_ tenant property. Yields a confirmation prompt before actually removing the property +Remove the _AnalyticsId_ tenant property using the tenant app catalog. Yields a confirmation prompt before actually removing the property ```sh -m365 spo storageentity remove -k AnalyticsId -u https://contoso.sharepoint.com/sites/appcatalog +m365 spo storageentity remove -k AnalyticsId ``` -Remove the _AnalyticsId_ tenant property. Suppresses the confirmation prompt +Remove the _AnalyticsId_ tenant property from a specific app catalog. Suppresses the confirmation prompt ```sh m365 spo storageentity remove -k AnalyticsId --force -u https://contoso.sharepoint.com/sites/appcatalog diff --git a/docs/docs/cmd/spo/storageentity/storageentity-set.mdx b/docs/docs/cmd/spo/storageentity/storageentity-set.mdx index 9df427e8bad..ac482c2eb91 100644 --- a/docs/docs/cmd/spo/storageentity/storageentity-set.mdx +++ b/docs/docs/cmd/spo/storageentity/storageentity-set.mdx @@ -13,8 +13,8 @@ m365 spo storageentity set [options] ## Options ```md definition-list -`-u, --appCatalogUrl ` -: URL of the app catalog site +`-u, --appCatalogUrl [appCatalogUrl]` +: URL of the app catalog site. If not specified, the tenant app catalog URL will be used. `-k, --key ` : Name of the tenant property to retrieve @@ -33,7 +33,7 @@ m365 spo storageentity set [options] ## Remarks -Tenant properties are stored in the app catalog site associated with that tenant. To set a property, you have to specify the absolute URL of the app catalog site without a trailing slash. If you specify the URL with trailing slash you get the error `The managed path sites/apps is not a managed path in this tenant.` +Tenant properties are stored in the app catalog site associated with that tenant. To set a property, you can specify the absolute URL of the app catalog site without a trailing slash. If not specified, the command will automatically resolve the tenant app catalog URL. If you specify the URL with trailing slash you get the error `The managed path sites/apps is not a managed path in this tenant.` If you specify the URL of a site different than the app catalog, you will get an access denied error. @@ -45,7 +45,13 @@ To use this command you must be either **SharePoint Administrator** or **Global ## Examples -Set _123_ as the value of the _AnalyticsId_ tenant property. Also include a description and a comment for additional clarification of the usage of the property. +Set _123_ as the value of the _AnalyticsId_ tenant property using the tenant app catalog + +```sh +m365 spo storageentity set -k AnalyticsId -v 123 -d 'Web analytics ID' -c 'Use on all sites' +``` + +Set _123_ as the value of the _AnalyticsId_ tenant property on a specific app catalog site ```sh m365 spo storageentity set -k AnalyticsId -v 123 -d 'Web analytics ID' -c 'Use on all sites' -u https://contoso.sharepoint.com/sites/appcatalog diff --git a/src/m365/spo/commands/storageentity/storageentity-get.spec.ts b/src/m365/spo/commands/storageentity/storageentity-get.spec.ts index a10b0ef0224..3818b7517ca 100644 --- a/src/m365/spo/commands/storageentity/storageentity-get.spec.ts +++ b/src/m365/spo/commands/storageentity/storageentity-get.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -8,13 +10,16 @@ import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './storageentity-get.js'; +import command, { options } from './storageentity-get.js'; describe(commands.STORAGEENTITY_GET, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -23,46 +28,28 @@ describe(commands.STORAGEENTITY_GET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; auth.connection.spoUrl = 'https://contoso.sharepoint.com'; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/web/GetStorageEntity('existingproperty')`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return { Comment: 'Lorem', Description: 'ipsum', Value: 'dolor' }; - } + if (opts.url === `https://contoso.sharepoint.com/sites/appcatalog/_api/web/GetStorageEntity('existingproperty')`) { + return { Comment: 'Lorem', Description: 'ipsum', Value: 'dolor' }; } - if ((opts.url as string).indexOf(`/_api/web/GetStorageEntity('propertywithoutdescription')`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return { Comment: 'Lorem', Value: 'dolor' }; - } + if (opts.url === `https://contoso.sharepoint.com/sites/appcatalog/_api/web/GetStorageEntity('propertywithoutdescription')`) { + return { Comment: 'Lorem', Value: 'dolor' }; } - if ((opts.url as string).indexOf(`/_api/web/GetStorageEntity('propertywithoutcomments')`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return { Description: 'ipsum', Value: 'dolor' }; - } + if (opts.url === `https://contoso.sharepoint.com/sites/appcatalog/_api/web/GetStorageEntity('propertywithoutcomments')`) { + return { Description: 'ipsum', Value: 'dolor' }; } - if ((opts.url as string).indexOf(`/_api/web/GetStorageEntity('nonexistingproperty')`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return { "odata.null": true }; - } + if (opts.url === `https://contoso.sharepoint.com/sites/appcatalog/_api/web/GetStorageEntity('nonexistingproperty')`) { + return { "odata.null": true }; } - if ((opts.url as string).indexOf(`/_api/web/GetStorageEntity('%23myprop')`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return { Description: 'ipsum', Value: 'dolor' }; - } + if (opts.url === `https://contoso.sharepoint.com/sites/appcatalog/_api/web/GetStorageEntity('%23myprop')`) { + return { Description: 'ipsum', Value: 'dolor' }; } throw 'Invalid request'; @@ -85,6 +72,12 @@ describe(commands.STORAGEENTITY_GET, () => { loggerLogSpy = sinon.spy(logger, 'log'); }); + afterEach(() => { + sinonUtil.restore([ + spo.getTenantAppCatalogUrl + ]); + }); + after(() => { sinon.restore(); auth.connection.active = false; @@ -100,7 +93,7 @@ describe(commands.STORAGEENTITY_GET, () => { }); it('retrieves the details of an existing tenant property', async () => { - await command.action(logger, { options: { debug: true, key: 'existingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'existingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert(loggerLogSpy.calledWith({ Key: 'existingproperty', Value: 'dolor', @@ -110,7 +103,7 @@ describe(commands.STORAGEENTITY_GET, () => { }); it('retrieves the details of an existing tenant property without a description', async () => { - await command.action(logger, { options: { debug: true, key: 'propertywithoutdescription', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'propertywithoutdescription', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert(loggerLogSpy.calledWith({ Key: 'propertywithoutdescription', Value: 'dolor', @@ -120,7 +113,7 @@ describe(commands.STORAGEENTITY_GET, () => { }); it('retrieves the details of an existing tenant property without a comment', async () => { - await command.action(logger, { options: { key: 'propertywithoutcomments', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ key: 'propertywithoutcomments', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert(loggerLogSpy.calledWith({ Key: 'propertywithoutcomments', Value: 'dolor', @@ -129,17 +122,36 @@ describe(commands.STORAGEENTITY_GET, () => { })); }); + it('retrieves tenant property using tenant app catalog URL when appCatalogUrl is not specified', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves('https://contoso.sharepoint.com/sites/appcatalog'); + + await command.action(logger, { options: commandOptionsSchema.parse({ key: 'existingproperty' }) }); + assert(loggerLogSpy.calledWith({ + Key: 'existingproperty', + Value: 'dolor', + Description: 'ipsum', + Comment: 'Lorem' + })); + }); + + it('throws error when tenant app catalog is not found and appCatalogUrl is not specified', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); + + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ key: 'existingproperty' }) }), + new CommandError('Tenant app catalog URL not found. Specify the URL of the app catalog site using the appCatalogUrl option.')); + }); + it('handles a non-existent tenant property', async () => { - await command.action(logger, { options: { key: 'nonexistingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ key: 'nonexistingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); }); it('handles a non-existent tenant property (debug)', async () => { - await command.action(logger, { options: { debug: true, key: 'nonexistingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'nonexistingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); let correctValue: boolean = false; log.forEach(l => { if (l && typeof l === 'string' && - l.indexOf('Property with key nonexistingproperty not found') > -1) { + l.includes('Property with key nonexistingproperty not found')) { correctValue = true; } }); @@ -147,7 +159,7 @@ describe(commands.STORAGEENTITY_GET, () => { }); it('escapes special characters in property name', async () => { - await command.action(logger, { options: { debug: true, key: '#myprop', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: '#myprop', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert(loggerLogSpy.calledWith({ Key: '#myprop', Value: 'dolor', @@ -156,21 +168,25 @@ describe(commands.STORAGEENTITY_GET, () => { })); }); - it('requires tenant property name', () => { - const options = command.options; - let requiresTenantPropertyName = false; - options.forEach(o => { - if (o.option.indexOf('') > -1) { - requiresTenantPropertyName = true; - } - }); - assert(requiresTenantPropertyName); + it('fails validation if appCatalogUrl is not a valid URL', () => { + const actual = commandOptionsSchema.safeParse({ key: 'prop', appCatalogUrl: 'foo' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation when appCatalogUrl is a valid SharePoint URL', () => { + const actual = commandOptionsSchema.safeParse({ key: 'prop', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation when appCatalogUrl is not specified', () => { + const actual = commandOptionsSchema.safeParse({ key: 'prop' }); + assert.strictEqual(actual.success, true); }); it('handles promise rejection', async () => { sinonUtil.restore(request.get); sinon.stub(request, 'get').rejects(new Error('error')); - await assert.rejects(command.action(logger, { options: { debug: true, key: '#myprop' } } as any), new CommandError('error')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: '#myprop', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }), new CommandError('error')); }); }); diff --git a/src/m365/spo/commands/storageentity/storageentity-get.ts b/src/m365/spo/commands/storageentity/storageentity-get.ts index 86831da8b2d..5dc23423d83 100644 --- a/src/m365/spo/commands/storageentity/storageentity-get.ts +++ b/src/m365/spo/commands/storageentity/storageentity-get.ts @@ -1,20 +1,30 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; -import request from '../../../../request.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { spo } from '../../../../utils/spo.js'; +import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { TenantProperty } from './TenantProperty.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + key: z.string().alias('k'), + appCatalogUrl: z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `'${e.input}' is not a valid SharePoint Online site URL.` + }) + .optional() + .alias('u') +}); +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - key: string; -} - class SpoStorageEntityGetCommand extends SpoCommand { public get name(): string { return commands.STORAGEENTITY_GET; @@ -24,25 +34,24 @@ class SpoStorageEntityGetCommand extends SpoCommand { return 'Get details for the specified tenant property'; } - constructor() { - super(); - - this.#initOptions(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-k, --key ' - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { try { - const spoUrl: string = await spo.getSpoUrl(logger, this.debug); - const requestOptions: any = { - url: `${spoUrl}/_api/web/GetStorageEntity('${formatting.encodeQueryParameter(args.options.key)}')`, + let appCatalogUrl = args.options.appCatalogUrl; + + if (!appCatalogUrl) { + appCatalogUrl = await spo.getTenantAppCatalogUrl(logger, this.debug) as string; + + if (!appCatalogUrl) { + throw 'Tenant app catalog URL not found. Specify the URL of the app catalog site using the appCatalogUrl option.'; + } + } + + const requestOptions: CliRequestOptions = { + url: `${appCatalogUrl}/_api/web/GetStorageEntity('${formatting.encodeQueryParameter(args.options.key)}')`, headers: { accept: 'application/json;odata=nometadata' }, diff --git a/src/m365/spo/commands/storageentity/storageentity-list.spec.ts b/src/m365/spo/commands/storageentity/storageentity-list.spec.ts index 2f36dad9233..e068a18d62d 100644 --- a/src/m365/spo/commands/storageentity/storageentity-list.spec.ts +++ b/src/m365/spo/commands/storageentity/storageentity-list.spec.ts @@ -10,15 +10,16 @@ import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './storageentity-list.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './storageentity-list.js'; describe(commands.STORAGEENTITY_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -27,6 +28,7 @@ describe(commands.STORAGEENTITY_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -48,7 +50,7 @@ describe(commands.STORAGEENTITY_LIST, () => { afterEach(() => { sinonUtil.restore([ request.get, - cli.getSettingWithDefaultValue + spo.getTenantAppCatalogUrl ]); }); @@ -67,28 +69,24 @@ describe(commands.STORAGEENTITY_LIST, () => { it('retrieves the list of configured tenant properties', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/web/AllProperties?$select=storageentitiesindex`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return { - storageentitiesindex: JSON.stringify({ - 'Property1': { - Value: 'dolor1' - }, - 'Property2': { - Comment: 'Lorem2', - Description: 'ipsum2', - Value: 'dolor2' - } - }) - }; - } + if (opts.url === 'https://contoso.sharepoint.com/sites/appcatalog/_api/web/AllProperties?$select=storageentitiesindex') { + return { + storageentitiesindex: JSON.stringify({ + 'Property1': { + Value: 'dolor1' + }, + 'Property2': { + Comment: 'Lorem2', + Description: 'ipsum2', + Value: 'dolor2' + } + }) + }; } throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert(loggerLogSpy.calledWith([ { Key: 'Property1', @@ -105,96 +103,92 @@ describe(commands.STORAGEENTITY_LIST, () => { ])); }); - it('doesn\'t fail if no tenant properties have been configured', async () => { + it('retrieves tenant properties using tenant app catalog URL when appCatalogUrl is not specified', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves('https://contoso.sharepoint.com/sites/appcatalog'); + sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/web/AllProperties?$select=storageentitiesindex`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return { storageentitiesindex: '' }; - } + if (opts.url === 'https://contoso.sharepoint.com/sites/appcatalog/_api/web/AllProperties?$select=storageentitiesindex') { + return { + storageentitiesindex: JSON.stringify({ + 'Property1': { + Value: 'dolor1' + } + }) + }; } throw 'Invalid request'; }); - await command.action(logger, { options: { appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); + assert(loggerLogSpy.calledWith([ + { + Key: 'Property1', + Description: undefined, + Comment: undefined, + Value: 'dolor1' + } + ])); }); - it('doesn\'t fail if tenant properties web property value is empty', async () => { + it('throws error when tenant app catalog is not found and appCatalogUrl is not specified', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); + + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({}) }), + new CommandError('Tenant app catalog URL not found. Specify the URL of the app catalog site using the appCatalogUrl option.')); + }); + + it('doesn\'t fail if no tenant properties have been configured', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/web/AllProperties?$select=storageentitiesindex`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return {}; - } + if (opts.url === 'https://contoso.sharepoint.com/sites/appcatalog/_api/web/AllProperties?$select=storageentitiesindex') { + return { storageentitiesindex: '' }; } throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); - let correctResponse: boolean = false; - log.forEach(l => { - if (!l || typeof l !== 'string') { - return; - } + await command.action(logger, { options: commandOptionsSchema.parse({ appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); + assert(loggerLogSpy.calledWith([])); + }); - if (l.indexOf('No tenant properties found') > -1) { - correctResponse = true; + it('doesn\'t fail if tenant properties web property value is empty', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === 'https://contoso.sharepoint.com/sites/appcatalog/_api/web/AllProperties?$select=storageentitiesindex') { + return {}; } + + throw 'Invalid request'; }); - assert(correctResponse, 'Incorrect response'); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); + assert(loggerLogSpy.calledWith([])); }); it('doesn\'t fail if tenant properties web property value is empty JSON object', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/web/AllProperties?$select=storageentitiesindex`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return { storageentitiesindex: JSON.stringify({}) }; - } + if (opts.url === 'https://contoso.sharepoint.com/sites/appcatalog/_api/web/AllProperties?$select=storageentitiesindex') { + return { storageentitiesindex: JSON.stringify({}) }; } throw 'Invalid request'; }); - await command.action(logger, { options: { appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); + assert(loggerLogSpy.calledWith([])); }); it('doesn\'t fail if tenant properties web property value is empty JSON object (debug)', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/web/AllProperties?$select=storageentitiesindex`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return { storageentitiesindex: JSON.stringify({}) }; - } + if (opts.url === 'https://contoso.sharepoint.com/sites/appcatalog/_api/web/AllProperties?$select=storageentitiesindex') { + return { storageentitiesindex: JSON.stringify({}) }; } throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); - let correctResponse: boolean = false; - log.forEach(l => { - if (!l || typeof l !== 'string') { - return; - } - - if (l.indexOf('No tenant properties found') > -1) { - correctResponse = true; - } - }); - assert(correctResponse, 'Incorrect response'); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); + assert(loggerLogSpy.calledWith([])); }); it('doesn\'t fail if tenant properties web property value is invalid JSON', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/web/AllProperties?$select=storageentitiesindex`) > -1) { - if (opts.headers && - opts.headers.accept && - (opts.headers.accept as string).indexOf('application/json') === 0) { - return { storageentitiesindex: 'a' }; - } + if (opts.url === 'https://contoso.sharepoint.com/sites/appcatalog/_api/web/AllProperties?$select=storageentitiesindex') { + return { storageentitiesindex: 'a' }; } throw 'Invalid request'; @@ -208,52 +202,27 @@ describe(commands.STORAGEENTITY_LIST, () => { errorMessage = err.message; } - await assert.rejects(command.action(logger, { options: { debug: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } } as any), new CommandError(`${errorMessage}`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }), new CommandError(`${errorMessage}`)); }); - it('requires app catalog URL', () => { - const options = command.options; - let requiresAppCatalogUrl = false; - options.forEach(o => { - if (o.option.indexOf('') > -1) { - requiresAppCatalogUrl = true; - } - }); - assert(requiresAppCatalogUrl); + it('fails validation if appCatalogUrl is not a valid URL', () => { + const actual = commandOptionsSchema.safeParse({ appCatalogUrl: 'foo' }); + assert.strictEqual(actual.success, false); }); - it('accepts valid SharePoint Online app catalog URL', async () => { - const actual = await command.validate({ options: { appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when appCatalogUrl is a valid SharePoint URL', () => { + const actual = commandOptionsSchema.safeParse({ appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }); + assert.strictEqual(actual.success, true); }); - it('accepts valid SharePoint Online site URL', async () => { - const actual = await command.validate({ options: { appCatalogUrl: 'https://contoso.sharepoint.com' } }, commandInfo); - assert.strictEqual(actual, true); - }); - - it('rejects invalid SharePoint Online URL', async () => { - const url = 'http://contoso'; - const actual = await command.validate({ options: { appCatalogUrl: url } }, commandInfo); - assert.strictEqual(actual, `'${url}' is not a valid SharePoint Online site URL.`); - }); - - it('fails validation when no SharePoint Online app catalog URL specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: {} }, commandInfo); - assert.strictEqual(actual, 'Required option appCatalogUrl not specified'); + it('passes validation when appCatalogUrl is not specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, true); }); it('handles promise rejection', async () => { sinon.stub(request, 'get').rejects(new Error('error')); - await assert.rejects(command.action(logger, { options: { debug: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } } as any), new CommandError('error')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }), new CommandError('error')); }); }); diff --git a/src/m365/spo/commands/storageentity/storageentity-list.ts b/src/m365/spo/commands/storageentity/storageentity-list.ts index afe17ddac3f..db7008a0391 100644 --- a/src/m365/spo/commands/storageentity/storageentity-list.ts +++ b/src/m365/spo/commands/storageentity/storageentity-list.ts @@ -1,19 +1,28 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; -import request from '../../../../request.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { spo } from '../../../../utils/spo.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { TenantProperty } from './TenantProperty.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + appCatalogUrl: z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `'${e.input}' is not a valid SharePoint Online site URL.` + }) + .optional() + .alias('u') +}); +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appCatalogUrl: string; -} - class SpoStorageEntityListCommand extends SpoCommand { public get name(): string { return commands.STORAGEENTITY_LIST; @@ -23,66 +32,63 @@ class SpoStorageEntityListCommand extends SpoCommand { return 'Lists tenant properties stored on the specified SharePoint Online app catalog'; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); + public get schema(): z.ZodType | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: '-u, --appCatalogUrl ' - } - ); - } + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + let appCatalogUrl = args.options.appCatalogUrl; - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.appCatalogUrl) - ); - } + if (!appCatalogUrl) { + appCatalogUrl = await spo.getTenantAppCatalogUrl(logger, this.debug) as string; - public async commandAction(logger: Logger, args: CommandArgs): Promise { - if (this.verbose) { - await logger.logToStderr(`Retrieving details for all tenant properties in ${args.options.appCatalogUrl}...`); - } + if (!appCatalogUrl) { + throw 'Tenant app catalog URL not found. Specify the URL of the app catalog site using the appCatalogUrl option.'; + } + } - const requestOptions: any = { - url: `${args.options.appCatalogUrl}/_api/web/AllProperties?$select=storageentitiesindex`, - headers: { - accept: 'application/json;odata=nometadata' - }, - responseType: 'json' - }; + if (this.verbose) { + await logger.logToStderr(`Retrieving details for all tenant properties in ${appCatalogUrl}...`); + } + + const requestOptions: CliRequestOptions = { + url: `${appCatalogUrl}/_api/web/AllProperties?$select=storageentitiesindex`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; - try { const web: { storageentitiesindex?: string } = await request.get<{ storageentitiesindex?: string }>(requestOptions); if (!web.storageentitiesindex || web.storageentitiesindex.trim().length === 0) { if (this.verbose) { await logger.logToStderr('No tenant properties found'); } + + await logger.log([]); } else { const properties: { [key: string]: TenantProperty } = JSON.parse(web.storageentitiesindex); const keys: string[] = Object.keys(properties); + if (keys.length === 0) { if (this.verbose) { await logger.logToStderr('No tenant properties found'); } + + await logger.log([]); } else { - await logger.log(keys.map((key: string): any => { - const property: TenantProperty = properties[key]; - return { - Key: key, - Value: property.Value, - Description: property.Description, - Comment: property.Comment - }; + const result = keys.map(key => ({ + Key: key, + Value: properties[key].Value, + Description: properties[key].Description, + Comment: properties[key].Comment })); + + await logger.log(result); } } } diff --git a/src/m365/spo/commands/storageentity/storageentity-remove.spec.ts b/src/m365/spo/commands/storageentity/storageentity-remove.spec.ts index 0b062bf1309..2db9c284a29 100644 --- a/src/m365/spo/commands/storageentity/storageentity-remove.spec.ts +++ b/src/m365/spo/commands/storageentity/storageentity-remove.spec.ts @@ -13,14 +13,14 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './storageentity-remove.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './storageentity-remove.js'; describe(commands.STORAGEENTITY_REMOVE, () => { let log: string[]; let logger: Logger; let promptIssued: boolean = false; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -36,6 +36,7 @@ describe(commands.STORAGEENTITY_REMOVE, () => { auth.connection.active = true; auth.connection.spoUrl = 'https://contoso.sharepoint.com'; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -62,7 +63,7 @@ describe(commands.STORAGEENTITY_REMOVE, () => { sinonUtil.restore([ request.post, cli.promptForConfirmation, - cli.getSettingWithDefaultValue + spo.getTenantAppCatalogUrl ]); }); @@ -82,34 +83,56 @@ describe(commands.STORAGEENTITY_REMOVE, () => { it('removes existing tenant property without prompting with confirmation argument', async () => { const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_vti_bin/client.svc/ProcessQuery') > -1) { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { return JSON.stringify([{ "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7018.1204", "ErrorInfo": null, "TraceCorrelationId": "4456299e-d09e-4000-ae61-ddde716daa27" }, 31, { "IsNull": false }, 33, { "IsNull": false }, 35, { "IsNull": false }]); } throw 'Invalid request'; }); - await command.action(logger, { options: { key: 'existingproperty', force: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ key: 'existingproperty', force: true, appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); assert.strictEqual(postStub.lastCall.args[0].headers['X-RequestDigest'], 'ABC'); assert.strictEqual(postStub.lastCall.args[0].data, `existingpropertyhttps://contoso.sharepoint.com/sites/appcatalog`); }); + it('removes tenant property using tenant app catalog URL when appCatalogUrl is not specified', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves('https://contoso.sharepoint.com/sites/appcatalog'); + + const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { + return JSON.stringify([{ "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7018.1204", "ErrorInfo": null, "TraceCorrelationId": "4456299e-d09e-4000-ae61-ddde716daa27" }, 31, { "IsNull": false }, 33, { "IsNull": false }, 35, { "IsNull": false }]); + } + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ key: 'existingproperty', force: true }) }); + assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); + assert(postStub.lastCall.args[0].data.includes('https://contoso.sharepoint.com/sites/appcatalog')); + }); + + it('throws error when tenant app catalog is not found and appCatalogUrl is not specified', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); + + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ key: 'existingproperty', force: true }) }), + new CommandError('Tenant app catalog URL not found. Specify the URL of the app catalog site using the appCatalogUrl option.')); + }); + it('prompts before removing tenant property when confirmation argument not passed', async () => { - await command.action(logger, { options: { debug: true, key: 'existingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'existingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert(promptIssued); }); it('aborts removing property when prompt not confirmed', async () => { const postStub: sinon.SinonStub = sinon.stub(request, 'post').rejects(new Error('Invalid request')); - await command.action(logger, { options: { debug: true, key: 'existingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'existingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert(postStub.notCalled); }); it('removes tenant property when prompt confirmed', async () => { const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_vti_bin/client.svc/ProcessQuery') > -1) { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { return JSON.stringify([{ "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7018.1204", "ErrorInfo": null, "TraceCorrelationId": "4456299e-d09e-4000-ae61-ddde716daa27" }, 31, { "IsNull": false }, 33, { "IsNull": false }, 35, { "IsNull": false }]); } throw 'Invalid request'; @@ -118,7 +141,7 @@ describe(commands.STORAGEENTITY_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { debug: true, key: 'existingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'existingproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); assert.strictEqual(postStub.lastCall.args[0].headers['X-RequestDigest'], 'ABC'); assert.strictEqual(postStub.lastCall.args[0].data, `existingpropertyhttps://contoso.sharepoint.com/sites/appcatalog`); @@ -126,7 +149,7 @@ describe(commands.STORAGEENTITY_REMOVE, () => { it('correctly reports when trying to remove an nonexistent property', async () => { const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_vti_bin/client.svc/ProcessQuery') > -1) { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { return JSON.stringify([ { "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7018.1204", "ErrorInfo": { @@ -142,72 +165,25 @@ describe(commands.STORAGEENTITY_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await assert.rejects(command.action(logger, { options: { debug: true, key: 'nonexistentproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } } as any), new CommandError('File Not Found.')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'nonexistentproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }), new CommandError('File Not Found.')); assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); assert.strictEqual(postStub.lastCall.args[0].headers['X-RequestDigest'], 'ABC'); assert.strictEqual(postStub.lastCall.args[0].data, `nonexistentpropertyhttps://contoso.sharepoint.com/sites/appcatalog`); }); - it('requires app catalog URL', () => { - const options = command.options; - let requiresAppCatalogUrl = false; - options.forEach(o => { - if (o.option.indexOf('') > -1) { - requiresAppCatalogUrl = true; - } - }); - assert(requiresAppCatalogUrl); + it('fails validation if appCatalogUrl is not a valid URL', () => { + const actual = commandOptionsSchema.safeParse({ appCatalogUrl: 'foo', key: 'prop' }); + assert.strictEqual(actual.success, false); }); - it('supports suppressing confirmation prompt', () => { - const options = command.options; - let containsConfirmOption = false; - options.forEach(o => { - if (o.option.indexOf('--force') > -1) { - containsConfirmOption = true; - } - }); - assert(containsConfirmOption); - }); - - it('requires tenant property name', () => { - const options = command.options; - let requiresTenantPropertyName = false; - options.forEach(o => { - if (o.option.indexOf('') > -1) { - requiresTenantPropertyName = true; - } - }); - assert(requiresTenantPropertyName); - }); - - it('accepts valid SharePoint Online app catalog URL', async () => { - const actual = await command.validate({ options: { appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog', key: 'prop' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when appCatalogUrl is a valid SharePoint URL', () => { + const actual = commandOptionsSchema.safeParse({ appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog', key: 'prop' }); + assert.strictEqual(actual.success, true); }); - it('accepts valid SharePoint Online site URL', async () => { - const actual = await command.validate({ options: { appCatalogUrl: 'https://contoso.sharepoint.com', key: 'prop' } }, commandInfo); - assert.strictEqual(actual, true); - }); - - it('rejects invalid SharePoint Online URL', async () => { - const url = 'http://contoso'; - const actual = await command.validate({ options: { appCatalogUrl: url, key: 'prop' } }, commandInfo); - assert.strictEqual(actual, `'${url}' is not a valid SharePoint Online site URL.`); - }); - - it('fails validation when no SharePoint Online app catalog URL specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: {} }, commandInfo); - assert.strictEqual(actual, 'Required option appCatalogUrl not specified'); + it('passes validation when appCatalogUrl is not specified', () => { + const actual = commandOptionsSchema.safeParse({ key: 'prop' }); + assert.strictEqual(actual.success, true); }); it('handles promise rejection', async () => { @@ -217,6 +193,6 @@ describe(commands.STORAGEENTITY_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await assert.rejects(command.action(logger, { options: { debug: true, key: 'nonexistentproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } } as any), new CommandError('getRequestDigest error')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'nonexistentproperty', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }), new CommandError('getRequestDigest error')); }); }); diff --git a/src/m365/spo/commands/storageentity/storageentity-remove.ts b/src/m365/spo/commands/storageentity/storageentity-remove.ts index 65e304fdb3a..a5bca841054 100644 --- a/src/m365/spo/commands/storageentity/storageentity-remove.ts +++ b/src/m365/spo/commands/storageentity/storageentity-remove.ts @@ -1,24 +1,32 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; import config from '../../../../config.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; -import request from '../../../../request.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { ClientSvcResponse, ClientSvcResponseContents, ContextInfo, spo } from '../../../../utils/spo.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + appCatalogUrl: z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `'${e.input}' is not a valid SharePoint Online site URL.` + }) + .optional() + .alias('u'), + key: z.string().alias('k'), + force: z.boolean().optional().alias('f') +}); +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appCatalogUrl: string; - key: string; - force?: boolean; -} - class SpoStorageEntityRemoveCommand extends SpoCommand { public get name(): string { return commands.STORAGEENTITY_REMOVE; @@ -28,40 +36,8 @@ class SpoStorageEntityRemoveCommand extends SpoCommand { return 'Removes tenant property stored on the specified SharePoint Online app catalog'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: (!(!args.options.force)).toString() - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --appCatalogUrl ' - }, - { - option: '-k, --key ' - }, - { - option: '-f, --force' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.appCatalogUrl) - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -78,20 +54,30 @@ class SpoStorageEntityRemoveCommand extends SpoCommand { } private async removeTenantProperty(logger: Logger, args: CommandArgs): Promise { - if (this.verbose) { - await logger.logToStderr(`Removing tenant property ${args.options.key} from ${args.options.appCatalogUrl}...`); - } - try { + let appCatalogUrl = args.options.appCatalogUrl; + + if (!appCatalogUrl) { + appCatalogUrl = await spo.getTenantAppCatalogUrl(logger, this.debug) as string; + + if (!appCatalogUrl) { + throw 'Tenant app catalog URL not found. Specify the URL of the app catalog site using the appCatalogUrl option.'; + } + } + + if (this.verbose) { + await logger.logToStderr(`Removing tenant property ${args.options.key} from ${appCatalogUrl}...`); + } + const spoAdminUrl: string = await spo.getSpoAdminUrl(logger, this.debug); const digestInfo: ContextInfo = await spo.getRequestDigest(spoAdminUrl); - const requestOptions: any = { + const requestOptions: CliRequestOptions = { url: `${spoAdminUrl}/_vti_bin/client.svc/ProcessQuery`, headers: { 'X-RequestDigest': digestInfo.FormDigestValue }, - data: `${formatting.escapeXml(args.options.key)}${formatting.escapeXml(args.options.appCatalogUrl)}` + data: `${formatting.escapeXml(args.options.key)}${formatting.escapeXml(appCatalogUrl)}` }; const processQuery: string = await request.post(requestOptions); diff --git a/src/m365/spo/commands/storageentity/storageentity-set.spec.ts b/src/m365/spo/commands/storageentity/storageentity-set.spec.ts index d286b94c7ec..ae9fb86b350 100644 --- a/src/m365/spo/commands/storageentity/storageentity-set.spec.ts +++ b/src/m365/spo/commands/storageentity/storageentity-set.spec.ts @@ -13,13 +13,14 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './storageentity-set.js'; +import command, { options } from './storageentity-set.js'; describe(commands.STORAGEENTITY_SET, () => { let log: string[]; let logger: Logger; let loggerLogToStderrSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -35,6 +36,7 @@ describe(commands.STORAGEENTITY_SET, () => { auth.connection.active = true; auth.connection.spoUrl = 'https://contoso.sharepoint.com'; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -56,7 +58,8 @@ describe(commands.STORAGEENTITY_SET, () => { afterEach(() => { sinonUtil.restore([ request.post, - spo.getSpoAdminUrl + spo.getSpoAdminUrl, + spo.getTenantAppCatalogUrl ]); }); @@ -76,7 +79,7 @@ describe(commands.STORAGEENTITY_SET, () => { it('sets tenant property', async () => { const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_vti_bin/client.svc/ProcessQuery') > -1) { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { if (opts.headers && opts.headers['X-RequestDigest'] && opts.data) { @@ -87,22 +90,45 @@ describe(commands.STORAGEENTITY_SET, () => { } throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, key: 'Property1', value: 'Lorem', description: 'ipsum', comment: 'dolor', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'Property1', value: 'Lorem', description: 'ipsum', comment: 'dolor', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); assert.strictEqual((postStub.lastCall.args[0].headers as any)['X-RequestDigest'], 'ABC'); assert.strictEqual(postStub.lastCall.args[0].data, `Property1Loremipsumdolorhttps://contoso.sharepoint.com/sites/appcatalog`); }); + it('sets tenant property using tenant app catalog URL when appCatalogUrl is not specified', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves('https://contoso.sharepoint.com/sites/appcatalog'); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { + return JSON.stringify([{ "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7018.1204", "ErrorInfo": null, "TraceCorrelationId": "4456299e-d09e-4000-ae61-ddde716daa27" }, 31, { "IsNull": false }, 33, { "IsNull": false }, 35, { "IsNull": false }]); + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ key: 'Property1', value: 'Lorem' }) }); + assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); + assert(postStub.lastCall.args[0].data.includes('https://contoso.sharepoint.com/sites/appcatalog')); + }); + + it('throws error when tenant app catalog is not found and appCatalogUrl is not specified', async () => { + sinon.stub(spo, 'getTenantAppCatalogUrl').resolves(null); + + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ key: 'Property1', value: 'Lorem' }) }), + new CommandError('Tenant app catalog URL not found. Specify the URL of the app catalog site using the appCatalogUrl option.')); + }); + it('sets tenant property without description and comment', async () => { const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_vti_bin/client.svc/ProcessQuery') > -1) { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { return JSON.stringify([{ "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7018.1204", "ErrorInfo": null, "TraceCorrelationId": "4456299e-d09e-4000-ae61-ddde716daa27" }, 31, { "IsNull": false }, 33, { "IsNull": false }, 35, { "IsNull": false }]); } throw 'Invalid request'; }); - await command.action(logger, { options: { key: 'Property1', value: 'Lorem', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ key: 'Property1', value: 'Lorem', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); assert.strictEqual(postStub.lastCall.args[0].headers['X-RequestDigest'], 'ABC'); assert.strictEqual(postStub.lastCall.args[0].data, `Property1Loremhttps://contoso.sharepoint.com/sites/appcatalog`); @@ -111,14 +137,14 @@ describe(commands.STORAGEENTITY_SET, () => { it('sets tenant property without description and comment (debug)', async () => { const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_vti_bin/client.svc/ProcessQuery') > -1) { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { return JSON.stringify([{ "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7018.1204", "ErrorInfo": null, "TraceCorrelationId": "4456299e-d09e-4000-ae61-ddde716daa27" }, 31, { "IsNull": false }, 33, { "IsNull": false }, 35, { "IsNull": false }]); } throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, key: 'Property1', value: 'Lorem', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'Property1', value: 'Lorem', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); assert.strictEqual(postStub.lastCall.args[0].headers['X-RequestDigest'], 'ABC'); assert.strictEqual(postStub.lastCall.args[0].data, `Property1Loremhttps://contoso.sharepoint.com/sites/appcatalog`); @@ -126,13 +152,13 @@ describe(commands.STORAGEENTITY_SET, () => { it('escapes XML in user input', async () => { const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_vti_bin/client.svc/ProcessQuery') > -1) { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { return JSON.stringify([{ "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7018.1204", "ErrorInfo": null, "TraceCorrelationId": "4456299e-d09e-4000-ae61-ddde716daa27" }, 31, { "IsNull": false }, 33, { "IsNull": false }, 35, { "IsNull": false }]); } throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, key: '', value: '"Lorem"', description: '"ipsum"', comment: '', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: '', value: '"Lorem"', description: '"ipsum"', comment: '', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }); assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); assert.strictEqual(postStub.lastCall.args[0].headers['X-RequestDigest'], 'ABC'); assert.strictEqual(postStub.lastCall.args[0].data, `<Property1>"Lorem""ipsum"<dolor & samet>https://contoso.sharepoint.com/sites/appcatalog`); @@ -140,7 +166,7 @@ describe(commands.STORAGEENTITY_SET, () => { it('correctly handles a generic error when setting tenant property', async () => { const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_vti_bin/client.svc/ProcessQuery') > -1) { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { return JSON.stringify([ { "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7018.1204", "ErrorInfo": { @@ -154,14 +180,14 @@ describe(commands.STORAGEENTITY_SET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ key: 'Property1', value: 'Lorem', description: 'ipsum', comment: 'dolor', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' - } - } as any), new CommandError('An error has occurred')); + }) + }), new CommandError('An error has occurred')); assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); assert.strictEqual(postStub.lastCall.args[0].headers['X-RequestDigest'], 'ABC'); assert.strictEqual(postStub.lastCall.args[0].data, `Property1Loremipsumdolorhttps://contoso.sharepoint.com/sites/appcatalog`); @@ -169,7 +195,7 @@ describe(commands.STORAGEENTITY_SET, () => { it('correctly handles access denied error when setting tenant property', async () => { const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_vti_bin/client.svc/ProcessQuery') > -1) { + if (opts.url === 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery') { return JSON.stringify([ { "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7018.1204", "ErrorInfo": { @@ -183,95 +209,39 @@ describe(commands.STORAGEENTITY_SET, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, key: 'Property1', value: 'Lorem', description: 'ipsum', comment: 'dolor', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' - } - } as any), new CommandError('Access denied.')); + }) + }), new CommandError('Access denied.')); assert.strictEqual(postStub.lastCall.args[0].url, 'https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery'); assert.strictEqual(postStub.lastCall.args[0].headers['X-RequestDigest'], 'ABC'); assert.strictEqual(postStub.lastCall.args[0].data, `Property1Loremipsumdolorhttps://contoso.sharepoint.com/sites/appcatalog`); assert.strictEqual(loggerLogToStderrSpy.calledWithMatch('This error is often caused by invalid URL of the app catalog site'), true); }); - it('requires app catalog URL', () => { - const options = command.options; - let requiresAppCatalogUrl = false; - options.forEach(o => { - if (o.option.indexOf('') > -1) { - requiresAppCatalogUrl = true; - } - }); - assert(requiresAppCatalogUrl); - }); - - it('requires tenant property name', () => { - const options = command.options; - let requiresTenantPropertyName = false; - options.forEach(o => { - if (o.option.indexOf('') > -1) { - requiresTenantPropertyName = true; - } - }); - assert(requiresTenantPropertyName); - }); - - it('requires tenant property value', () => { - const options = command.options; - let requiresTenantPropertyValue = false; - options.forEach(o => { - if (o.option.indexOf('') > -1) { - requiresTenantPropertyValue = true; - } - }); - assert(requiresTenantPropertyValue); - }); - - it('supports setting tenant property description', () => { - const options = command.options; - let supportsTenantPropertyDescription = false; - options.forEach(o => { - if (o.option.indexOf('[description]') > -1) { - supportsTenantPropertyDescription = true; - } - }); - assert(supportsTenantPropertyDescription); - }); - - it('supports setting tenant property comment', () => { - const options = command.options; - let supportsTenantPropertyComment = false; - options.forEach(o => { - if (o.option.indexOf('[comment]') > -1) { - supportsTenantPropertyComment = true; - } - }); - assert(supportsTenantPropertyComment); - }); - - it('accepts valid SharePoint Online app catalog URL', async () => { - const actual = await command.validate({ options: { appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog', key: 'prop', value: 'val' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if appCatalogUrl is not a valid URL', () => { + const actual = commandOptionsSchema.safeParse({ appCatalogUrl: 'foo', key: 'prop', value: 'val' }); + assert.strictEqual(actual.success, false); }); - it('accepts valid SharePoint Online site URL', async () => { - const actual = await command.validate({ options: { appCatalogUrl: 'https://contoso.sharepoint.com', key: 'prop', value: 'val' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when appCatalogUrl is a valid SharePoint URL', () => { + const actual = commandOptionsSchema.safeParse({ appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog', key: 'prop', value: 'val' }); + assert.strictEqual(actual.success, true); }); - it('rejects invalid SharePoint Online URL', async () => { - const url = 'http://contoso'; - const actual = await command.validate({ options: { appCatalogUrl: url, key: 'prop', value: 'val' } }, commandInfo); - assert.strictEqual(actual, `'${url}' is not a valid SharePoint Online site URL.`); + it('passes validation when appCatalogUrl is not specified', () => { + const actual = commandOptionsSchema.safeParse({ key: 'prop', value: 'val' }); + assert.strictEqual(actual.success, true); }); it('handles promise rejection', async () => { sinon.stub(spo, 'getSpoAdminUrl').rejects(new Error('error')); - await assert.rejects(command.action(logger, { options: { debug: true, key: 'Property1', value: 'Lorem', description: 'ipsum', comment: 'dolor', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' } } as any), new CommandError('error')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, key: 'Property1', value: 'Lorem', description: 'ipsum', comment: 'dolor', appCatalogUrl: 'https://contoso.sharepoint.com/sites/appcatalog' }) }), new CommandError('error')); }); }); diff --git a/src/m365/spo/commands/storageentity/storageentity-set.ts b/src/m365/spo/commands/storageentity/storageentity-set.ts index f8829195354..910d994737e 100644 --- a/src/m365/spo/commands/storageentity/storageentity-set.ts +++ b/src/m365/spo/commands/storageentity/storageentity-set.ts @@ -1,25 +1,33 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; import config from '../../../../config.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; -import request from '../../../../request.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { ClientSvcResponse, ClientSvcResponseContents, ContextInfo, spo } from '../../../../utils/spo.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + appCatalogUrl: z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `'${e.input}' is not a valid SharePoint Online site URL.` + }) + .optional() + .alias('u'), + key: z.string().alias('k'), + value: z.string().alias('v'), + description: z.string().optional().alias('d'), + comment: z.string().optional().alias('c') +}); +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appCatalogUrl: string; - key: string; - value: string; - description?: string; - comment?: string; -} - class SpoStorageEntitySetCommand extends SpoCommand { public get name(): string { return commands.STORAGEENTITY_SET; @@ -29,63 +37,34 @@ class SpoStorageEntitySetCommand extends SpoCommand { return 'Sets tenant property on the specified SharePoint Online app catalog'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); + public get schema(): z.ZodType | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - description: (!(!args.options.description)).toString(), - comment: (!(!args.options.comment)).toString() - }); - }); - } + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + let appCatalogUrl = args.options.appCatalogUrl; - #initOptions(): void { - this.options.unshift( - { - option: '-u, --appCatalogUrl ' - }, - { - option: '-k, --key ' - }, - { - option: '-v, --value ' - }, - { - option: '-d, --description [description]' - }, - { - option: '-c, --comment [comment]' - } - ); - } + if (!appCatalogUrl) { + appCatalogUrl = await spo.getTenantAppCatalogUrl(logger, this.debug) as string; - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => validation.isValidSharePointUrl(args.options.appCatalogUrl) - ); - } + if (!appCatalogUrl) { + throw 'Tenant app catalog URL not found. Specify the URL of the app catalog site using the appCatalogUrl option.'; + } + } - public async commandAction(logger: Logger, args: CommandArgs): Promise { - try { const spoAdminUrl: string = await spo.getSpoAdminUrl(logger, this.debug); const res: ContextInfo = await spo.getRequestDigest(spoAdminUrl); if (this.verbose) { - await logger.logToStderr(`Setting tenant property ${args.options.key} in ${args.options.appCatalogUrl}...`); + await logger.logToStderr(`Setting tenant property ${args.options.key} in ${appCatalogUrl}...`); } - const requestOptions: any = { + const requestOptions: CliRequestOptions = { url: `${spoAdminUrl}/_vti_bin/client.svc/ProcessQuery`, headers: { 'X-RequestDigest': res.FormDigestValue }, - data: `${formatting.escapeXml(args.options.key)}${formatting.escapeXml(args.options.value)}${formatting.escapeXml(args.options.description || '')}${formatting.escapeXml(args.options.comment || '')}${formatting.escapeXml(args.options.appCatalogUrl)}` + data: `${formatting.escapeXml(args.options.key)}${formatting.escapeXml(args.options.value)}${formatting.escapeXml(args.options.description || '')}${formatting.escapeXml(args.options.comment || '')}${formatting.escapeXml(appCatalogUrl)}` }; const processQuery: string = await request.post(requestOptions);