diff --git a/docs/docs/cmd/spe/container/container-permission-list.mdx b/docs/docs/cmd/spe/container/container-permission-list.mdx index afce124c306..61e3a59436f 100644 --- a/docs/docs/cmd/spe/container/container-permission-list.mdx +++ b/docs/docs/cmd/spe/container/container-permission-list.mdx @@ -15,8 +15,11 @@ m365 spe container permission list [options] ## Options ```md definition-list -`-i, --containerId ` -: The ID of the SharePoint Embedded Container. +`-i, --containerId [id]` +: The ID of the SharePoint Embedded Container. Specify either `containerId` or `containerName` but not both. + +`-n, --containerName [containerName]` +: Display name of the SharePoint Embedded Container. Specify either `containerId` or `containerName` but not both. ``` @@ -42,12 +45,18 @@ m365 spe container permission list [options] ## Examples -Lists Container permissions. +Lists Container permissions by id. ```sh m365 spe container permission list --containerId "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" ``` +Lists Container permissions by display name. + +```sh +m365 spe container permission list --containerName "My Application Storage Container" +``` + ## Response diff --git a/src/m365/spe/commands/container/container-permission-list.spec.ts b/src/m365/spe/commands/container/container-permission-list.spec.ts index 32cf0695669..7924506a52e 100644 --- a/src/m365/spe/commands/container/container-permission-list.spec.ts +++ b/src/m365/spe/commands/container/container-permission-list.spec.ts @@ -3,20 +3,25 @@ import sinon from 'sinon'; import auth from '../../../../Auth.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; -import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './container-permission-list.js'; -import { formatting } from '../../../../utils/formatting.js'; +import { z } from 'zod'; +import { spe } from '../../../../utils/spe.js'; +import { odata } from '../../../../utils/odata.js'; +import { cli } from '../../../../cli/cli.js'; describe(commands.CONTAINER_PERMISSION_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let loggerLogToStderrSpy: sinon.SinonSpy; + let schema: z.ZodTypeAny; const containerId = "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z"; + const containerName = 'My Application Storage Container'; const containerPermissionResponse = { "value": [ { @@ -66,6 +71,7 @@ describe(commands.CONTAINER_PERMISSION_LIST, () => { sinon.stub(telemetry, 'trackEvent').resolves(); sinon.stub(pid, 'getProcessName').returns(''); auth.connection.active = true; + schema = command.getSchemaToParse()!; }); beforeEach(() => { @@ -82,12 +88,17 @@ describe(commands.CONTAINER_PERMISSION_LIST, () => { } }; loggerLogSpy = sinon.spy(logger, 'log'); + loggerLogToStderrSpy = sinon.spy(logger, 'logToStderr'); }); afterEach(() => { sinonUtil.restore([ - request.get + spe.getContainerIdByName, + odata.getAllItems, + cli.handleMultipleResultsFound ]); + loggerLogSpy.restore(); + loggerLogToStderrSpy.restore(); }); after(() => { @@ -108,13 +119,7 @@ describe(commands.CONTAINER_PERMISSION_LIST, () => { }); it('correctly lists permissions of a SharePoint Embedded Container', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions`) { - return containerPermissionResponse; - } - - throw 'Invalid request'; - }); + sinon.stub(odata, 'getAllItems').resolves(containerPermissionResponse.value); await command.action(logger, { options: { @@ -127,13 +132,7 @@ describe(commands.CONTAINER_PERMISSION_LIST, () => { }); it('correctly lists permissions of a SharePoint Embedded Container (TEXT)', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions`) { - return containerPermissionResponse; - } - - throw 'Invalid request'; - }); + sinon.stub(odata, 'getAllItems').resolves(containerPermissionResponse.value); await command.action(logger, { options: { @@ -146,8 +145,99 @@ describe(commands.CONTAINER_PERMISSION_LIST, () => { assert(loggerLogSpy.calledWith(textOutput)); }); + it('correctly lists permissions of a SharePoint Embedded Container by name', async () => { + sinon.stub(odata, 'getAllItems').onFirstCall().resolves([ + { + id: containerId, + displayName: containerName + } + ]).onSecondCall().resolves(containerPermissionResponse.value); + + await command.action(logger, { + options: { + containerName, + debug: true + } + }); + + assert(loggerLogSpy.calledWith(containerPermissionResponse.value)); + }); + + it('logs progress when resolving container id by name in verbose mode', async () => { + sinon.stub(odata, 'getAllItems').onFirstCall().resolves([ + { + id: containerId, + displayName: containerName + } + ]).onSecondCall().resolves(containerPermissionResponse.value); + + await command.action(logger, { + options: { + containerName, + verbose: true + } + }); + + assert(loggerLogToStderrSpy.calledWith(`Resolving container id from name '${containerName}'...`)); + }); + + it('fails when container with specified name does not exist', async () => { + sinon.stub(odata, 'getAllItems').resolves([]); + + await assert.rejects( + command.action(logger, { + options: { + containerName + } + }), + new CommandError(`The specified container '${containerName}' does not exist.`) + ); + }); + + it('handles multiple containers with same name when resolving id', async () => { + sinon.stub(odata, 'getAllItems').onFirstCall().resolves([ + { + id: '1', + displayName: containerName + }, + { + id: containerId, + displayName: containerName + } + ]).onSecondCall().resolves(containerPermissionResponse.value); + sinon.stub(cli, 'handleMultipleResultsFound').resolves({ + id: containerId + }); + + await command.action(logger, { + options: { + containerName + } + }); + + assert(loggerLogSpy.calledWith(containerPermissionResponse.value)); + }); + + it('rethrows unexpected errors when resolving container id by name', async () => { + sinon.stub(odata, 'getAllItems').rejects({ + error: { + 'odata.error': { + message: { + value: 'unexpected error' + } + } + } + }); + + await assert.rejects(command.action(logger, { + options: { + containerName + } + }), new CommandError('unexpected error')); + }); + it('correctly handles error when SharePoint Embedded Container is not found', async () => { - sinon.stub(request, 'get').rejects({ + sinon.stub(odata, 'getAllItems').rejects({ error: { 'odata.error': { message: { value: 'Item Not Found.' } } } }); @@ -157,7 +247,7 @@ describe(commands.CONTAINER_PERMISSION_LIST, () => { it('correctly handles error when retrieving permissions of a SharePoint Embedded Container', async () => { const error = 'An error has occurred'; - sinon.stub(request, 'get').rejects(new Error(error)); + sinon.stub(odata, 'getAllItems').rejects(new Error(error)); await assert.rejects(command.action(logger, { options: { @@ -165,4 +255,29 @@ describe(commands.CONTAINER_PERMISSION_LIST, () => { } }), new CommandError(error)); }); -}); \ No newline at end of file + + it('fails validation when neither containerId nor containerName is specified', () => { + const result = schema.safeParse({}); + assert.strictEqual(result.success, false); + assert(result.error?.issues.some(issue => issue.message.includes('Specify either containerId or containerName'))); + }); + + it('fails validation when both containerId and containerName are specified', () => { + const result = schema.safeParse({ + containerId, + containerName + }); + assert.strictEqual(result.success, false); + assert(result.error?.issues.some(issue => issue.message.includes('Specify either containerId or containerName'))); + }); + + it('passes validation when only containerId is specified', () => { + const result = schema.safeParse({ containerId }); + assert.strictEqual(result.success, true); + }); + + it('passes validation when only containerName is specified', () => { + const result = schema.safeParse({ containerName }); + assert.strictEqual(result.success, true); + }); +}); diff --git a/src/m365/spe/commands/container/container-permission-list.ts b/src/m365/spe/commands/container/container-permission-list.ts index 9204cf1b6e2..f286d4ea76d 100644 --- a/src/m365/spe/commands/container/container-permission-list.ts +++ b/src/m365/spe/commands/container/container-permission-list.ts @@ -2,15 +2,17 @@ import { cli } from '../../../../cli/cli.js'; import { z } from 'zod'; import { zod } from '../../../../utils/zod.js'; import { Logger } from '../../../../cli/Logger.js'; -import { globalOptionsZod } from '../../../../Command.js'; +import { CommandError, globalOptionsZod } from '../../../../Command.js'; import commands from '../../commands.js'; import GraphCommand from '../../../base/GraphCommand.js'; import { odata } from '../../../../utils/odata.js'; import { formatting } from '../../../../utils/formatting.js'; +import { spe } from '../../../../utils/spe.js'; const options = globalOptionsZod .extend({ - containerId: zod.alias('i', z.string()) + containerId: zod.alias('i', z.string().optional()), + containerName: zod.alias('n', z.string().optional()) }) .strict(); declare type Options = z.infer; @@ -36,13 +38,21 @@ class SpeContainerPermissionListCommand extends GraphCommand { return options; } + public getRefinedSchema(schema: z.ZodTypeAny): z.ZodEffects | undefined { + return schema.refine((opts: Options) => [opts.containerId, opts.containerName].filter(value => value !== undefined).length === 1, { + message: 'Specify either containerId or containerName, but not both.' + }); + } + public async commandAction(logger: Logger, args: CommandArgs): Promise { try { + const containerId = await this.resolveContainerId(args.options, logger); + if (this.verbose) { - await logger.logToStderr(`Retrieving permissions of a SharePoint Embedded Container with id '${args.options.containerId}'...`); + await logger.logToStderr(`Retrieving permissions of a SharePoint Embedded Container with id '${containerId}'...`); } - const containerPermission = await odata.getAllItems(`${this.resource}/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(args.options.containerId)}/permissions`); + const containerPermission = await odata.getAllItems(`${this.resource}/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions`); if (!cli.shouldTrimOutput(args.options.output)) { await logger.log(containerPermission); @@ -58,9 +68,30 @@ class SpeContainerPermissionListCommand extends GraphCommand { } } catch (err: any) { + if (err instanceof CommandError) { + throw err; + } this.handleRejectedODataJsonPromise(err); } } + + private async resolveContainerId(options: Options, logger: Logger): Promise { + if (options.containerId) { + return options.containerId; + } + + if (this.verbose) { + await logger.logToStderr(`Resolving container id from name '${options.containerName}'...`); + } + + try { + return await spe.getContainerIdByName(options.containerName!); + } + catch (error: any) { + this.handleRejectedODataJsonPromise(error); + throw error; + } + } } -export default new SpeContainerPermissionListCommand(); \ No newline at end of file +export default new SpeContainerPermissionListCommand(); diff --git a/src/m365/spe/commands/container/container-remove.spec.ts b/src/m365/spe/commands/container/container-remove.spec.ts index dcdaad689a8..697feac5d8d 100644 --- a/src/m365/spe/commands/container/container-remove.spec.ts +++ b/src/m365/spe/commands/container/container-remove.spec.ts @@ -34,7 +34,7 @@ describe(commands.CONTAINER_REMOVE, () => { sinon.stub(session, 'getId').returns(''); sinon.stub(spe, 'getContainerTypeIdByName').withArgs(containerTypeName).resolves(containerTypeId); - sinon.stub(spe, 'getContainerIdByName').withArgs(containerTypeId, containerName).resolves(containerId); + sinon.stub(spe, 'getContainerIdByNameAndContainerTypeId').withArgs(containerTypeId, containerName).resolves(containerId); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); @@ -196,4 +196,4 @@ describe(commands.CONTAINER_REMOVE, () => { await assert.rejects(command.action(logger, { options: { id: containerId, force: true } }), new CommandError(errorMessage)); }); -}); \ No newline at end of file +}); diff --git a/src/m365/spe/commands/container/container-remove.ts b/src/m365/spe/commands/container/container-remove.ts index 8b07a41c0a1..29af258ab20 100644 --- a/src/m365/spe/commands/container/container-remove.ts +++ b/src/m365/spe/commands/container/container-remove.ts @@ -104,7 +104,7 @@ class SpeContainerRemoveCommand extends GraphCommand { await logger.logToStderr(`Getting container ID for container with name '${options.name}'...`); } - return spe.getContainerIdByName(containerTypeId, options.name!); + return spe.getContainerIdByNameAndContainerTypeId(containerTypeId, options.name!); } private async getContainerTypeId(options: Options, logger: Logger): Promise { @@ -120,4 +120,4 @@ class SpeContainerRemoveCommand extends GraphCommand { } } -export default new SpeContainerRemoveCommand(); \ No newline at end of file +export default new SpeContainerRemoveCommand(); diff --git a/src/utils/spe.spec.ts b/src/utils/spe.spec.ts index bf75dda02ae..b188b6d4df0 100644 --- a/src/utils/spe.spec.ts +++ b/src/utils/spe.spec.ts @@ -113,7 +113,7 @@ describe('utils/spe', () => { assert.strictEqual(actual, 'de988700-d700-020e-0a00-0831f3042f00'); }); - it('correctly gets a container by its name using getContainerIdByName', async () => { + it('correctly gets a container by its name using getContainerIdByNameAndContainerTypeId', async () => { const containerTypeId = '0e95d161-d90d-4e3f-8b94-788a6b40aa48'; sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers?$filter=containerTypeId eq ${containerTypeId}&$select=id,displayName`) { @@ -134,11 +134,11 @@ describe('utils/spe', () => { throw 'Invalid GET request:' + opts.url; }); - const actual = await spe.getContainerIdByName(containerTypeId, 'my FILE storage Container 2'); + const actual = await spe.getContainerIdByNameAndContainerTypeId(containerTypeId, 'my FILE storage Container 2'); assert.strictEqual(actual, 'b!t18F8ybsHUq1z3LTz8xvZqP8zaSWjkFNhsME-Fepo75dTf9vQKfeRblBZjoSQrd7'); }); - it('correctly throws error when container was not found using getContainerIdByName', async () => { + it('correctly throws error when container was not found using getContainerIdByNameAndContainerTypeId', async () => { const containerTypeId = '0e95d161-d90d-4e3f-8b94-788a6b40aa48'; sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers?$filter=containerTypeId eq ${containerTypeId}&$select=id,displayName`) { @@ -159,11 +159,11 @@ describe('utils/spe', () => { throw 'Invalid GET request:' + opts.url; }); - await assert.rejects(spe.getContainerIdByName(containerTypeId, 'nonexistent container'), + await assert.rejects(spe.getContainerIdByNameAndContainerTypeId(containerTypeId, 'nonexistent container'), new Error(`The specified container 'nonexistent container' does not exist.`)); }); - it('correctly handles multiple results when using getContainerIdByName', async () => { + it('correctly handles multiple results when using getContainerIdByNameAndContainerTypeId', async () => { const containerTypeId = '0e95d161-d90d-4e3f-8b94-788a6b40aa48'; const containers = [ { @@ -192,8 +192,8 @@ describe('utils/spe', () => { const stubMultiResults = sinon.stub(cli, 'handleMultipleResultsFound').resolves(containers.find(c => c.id === 'b!McTeU0-dW0GxKwECWdW04TIvEK-Js9xJib_RFqF-CqZxNe3OHVAIT4SqBxGm4fND')!); - const actual = await spe.getContainerIdByName(containerTypeId, 'My File Storage Container'); + const actual = await spe.getContainerIdByNameAndContainerTypeId(containerTypeId, 'My File Storage Container'); assert(stubMultiResults.calledOnce); assert.strictEqual(actual, 'b!McTeU0-dW0GxKwECWdW04TIvEK-Js9xJib_RFqF-CqZxNe3OHVAIT4SqBxGm4fND'); }); -}); \ No newline at end of file +}); diff --git a/src/utils/spe.ts b/src/utils/spe.ts index aed7f19c6ed..b3090e18247 100644 --- a/src/utils/spe.ts +++ b/src/utils/spe.ts @@ -59,7 +59,7 @@ export const spe = { * @param name Name of the container to search for. * @returns ID of the container. */ - async getContainerIdByName(containerTypeId: string, name: string): Promise { + async getContainerIdByNameAndContainerTypeId(containerTypeId: string, name: string): Promise { const containers = await odata.getAllItems(`${graphResource}/v1.0/storage/fileStorage/containers?$filter=containerTypeId eq ${containerTypeId}&$select=id,displayName`); const matchingContainers = containers.filter(c => c.displayName.toLowerCase() === name.toLowerCase()); @@ -73,6 +73,28 @@ export const spe = { return container.id; } + return matchingContainers[0].id; + }, + + /** + * Get the ID of a container by its display name. + * @param name Name of the container to search for. + * @returns ID of the container. + */ + async getContainerIdByName(name: string): Promise { + const containers = await odata.getAllItems(`${graphResource}/v1.0/storage/fileStorage/containers?$select=id,displayName`); + const matchingContainers = containers.filter(c => c.displayName === name); + + if (matchingContainers.length === 0) { + throw new Error(`The specified container '${name}' does not exist.`); + } + + if (matchingContainers.length > 1) { + const containerKeyValuePair = formatting.convertArrayToHashTable('id', matchingContainers); + const container = await cli.handleMultipleResultsFound(`Multiple containers with name '${name}' found.`, containerKeyValuePair); + return container.id; + } + return matchingContainers[0].id; } -}; \ No newline at end of file +};