diff --git a/docs/docs/cmd/spe/container/container-set.mdx b/docs/docs/cmd/spe/container/container-set.mdx new file mode 100644 index 00000000000..e35ce6296fd --- /dev/null +++ b/docs/docs/cmd/spe/container/container-set.mdx @@ -0,0 +1,160 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spe container set + +Updates a SharePoint Embedded container + +## Usage + +```sh +m365 spe container set [options] +``` + +## Options + +```md definition-list +`-i, --id ` +: The Id of the container. + +`--newName [newName]` +: New display name for the container. + +`--description [description]` +: Description of the container. + +`--isOcrEnabled [isOcrEnabled]` +: Indicates whether OCR is enabled for the container. Possible values: `true`, `false`. + +`--isItemVersioningEnabled [isItemVersioningEnabled]` +: Indicates whether item versioning is enabled. Possible values: `true`, `false`. + +`--itemMajorVersionLimit [itemMajorVersionLimit]` +: Maximum number of major versions to keep. Requires versioning to be enabled. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |-----------------|----------------------------------------------------------------| + | Microsoft Graph | FileStorageContainer.Selected, FileStorageContainer.Manage.All | + + + + + | Resource | Permissions | + |-----------------|-------------------------------| + | Microsoft Graph | FileStorageContainer.Selected | + + + + +## Remarks + +In addition to Graph permissions, the app/user must have container-type level permission for the respective container type. The caller must have write access to the container (for example, writer, manager, or owner role) to update metadata/settings. + +## Examples + +Update the container display name by Id. + +```sh +m365 spe container set --id "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --newName "Contoso Project A" +``` + +Update description only. + +```sh +m365 spe container set --id "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --description "Files for the Contoso Project A team" +``` + +Disable OCR for a container. + +```sh +m365 spe container set --id "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --isOcrEnabled false +``` + +Enable versioning and set the major version limit. + +```sh +m365 spe container set --id "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --isItemVersioningEnabled true --itemMajorVersionLimit 100 +``` + +Update name, description, and settings together. + +```sh +m365 spe container set --id "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --newName "Contoso Project A - Phase 2" --description "Phase 2 workspace" --isOcrEnabled true --isItemVersioningEnabled true --itemMajorVersionLimit 50 +``` + +## Response + + + + + ```json + { + "id": "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z", + "displayName": "Contoso Project A", + "description": "Files for the Contoso Project A team", + "containerTypeId": "91710488-5756-407f-9046-fbe5f0b4de73", + "status": "active", + "createdDateTime": "2021-11-24T15:41:52.347Z", + "lockState": "unlocked", + "settings": { + "isOcrEnabled": false, + "itemMajorVersionLimit": 50, + "isItemVersioningEnabled": true + } + } + ``` + + + + + ```text + containerTypeId: 91710488-5756-407f-9046-fbe5f0b4de73 + createdDateTime: 2021-11-24T15:41:52.347Z + description : Files for the Contoso Project A team + displayName : Contoso Project A + id : b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z + lockState : unlocked + settings : {"isOcrEnabled":false,"itemMajorVersionLimit":50,"isItemVersioningEnabled":true} + status : active + ``` + + + + + ```csv + id,displayName,description,containerTypeId,status,createdDateTime,lockState + b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z,Contoso Project A,Files for the Contoso Project A team,91710488-5756-407f-9046-fbe5f0b4de73,active,2021-11-24T15:41:52.347Z,unlocked + ``` + + + + + ```md + # spe container set --id "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --newName "Contoso Project A" + + Date: 07/04/2026 + + ## Contoso Project A (b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z) + + Property | Value + ---------|------- + id | b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z + displayName | Contoso Project A + description | Files for the Contoso Project A team + containerTypeId | 91710488-5756-407f-9046-fbe5f0b4de73 + status | active + createdDateTime | 2021-11-24T15:41:52.347Z + lockState | unlocked + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index e0f837d676a..45e0ab69f05 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -2201,6 +2201,11 @@ const sidebars: SidebarsConfig = { label: 'container remove', id: 'cmd/spe/container/container-remove' }, + { + type: 'doc', + label: 'container set', + id: 'cmd/spe/container/container-set' + }, { type: 'doc', label: 'container permission list', diff --git a/src/m365/spe/commands.ts b/src/m365/spe/commands.ts index 142615a6fc2..7a8abdbe902 100644 --- a/src/m365/spe/commands.ts +++ b/src/m365/spe/commands.ts @@ -6,6 +6,7 @@ export default { CONTAINER_GET: `${prefix} container get`, CONTAINER_LIST: `${prefix} container list`, CONTAINER_REMOVE: `${prefix} container remove`, + CONTAINER_SET: `${prefix} container set`, CONTAINER_PERMISSION_LIST: `${prefix} container permission list`, CONTAINER_RECYCLEBINITEM_LIST: `${prefix} container recyclebinitem list`, CONTAINER_RECYCLEBINITEM_REMOVE: `${prefix} container recyclebinitem remove`, diff --git a/src/m365/spe/commands/container/container-set.spec.ts b/src/m365/spe/commands/container/container-set.spec.ts new file mode 100644 index 00000000000..435103b9c80 --- /dev/null +++ b/src/m365/spe/commands/container/container-set.spec.ts @@ -0,0 +1,267 @@ +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'; +import { telemetry } from '../../../../telemetry.js'; +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 './container-set.js'; +import { z } from 'zod'; + +describe(commands.CONTAINER_SET, () => { + const containerId = 'b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z'; + + const patchResponse = { + id: containerId, + displayName: 'Updated Name', + description: 'Updated Description', + containerTypeId: '91710488-5756-407f-9046-fbe5f0b4de73', + status: 'active', + createdDateTime: '2021-11-24T15:41:52.347Z', + lockState: 'unlocked', + settings: { + isOcrEnabled: false, + itemMajorVersionLimit: 50, + isItemVersioningEnabled: true + } + }; + + let log: any[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let baseSchema: z.ZodTypeAny; + let refinedSchema: z.ZodTypeAny; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').resolves(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + + commandInfo = cli.getCommandInfo(command); + baseSchema = commandInfo.command.getSchemaToParse()!; + refinedSchema = commandInfo.command.getRefinedSchema!(baseSchema as any)!; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.patch + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CONTAINER_SET); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if no update options are provided', () => { + const actual = refinedSchema.safeParse({ id: containerId }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if itemMajorVersionLimit is not a positive integer', () => { + const actual = baseSchema.safeParse({ id: containerId, itemMajorVersionLimit: -1 }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if itemMajorVersionLimit is a decimal number', () => { + const actual = baseSchema.safeParse({ id: containerId, itemMajorVersionLimit: 12.5 }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation when newName is specified', () => { + const actual = refinedSchema.safeParse({ id: containerId, newName: 'New Name' }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation when description is specified', () => { + const actual = refinedSchema.safeParse({ id: containerId, description: 'New description' }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation when isOcrEnabled is specified', () => { + const actual = refinedSchema.safeParse({ id: containerId, isOcrEnabled: true }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation when isItemVersioningEnabled is specified', () => { + const actual = refinedSchema.safeParse({ id: containerId, isItemVersioningEnabled: false }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation when itemMajorVersionLimit is specified', () => { + const actual = refinedSchema.safeParse({ id: containerId, itemMajorVersionLimit: 100 }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation when all options are specified', () => { + const actual = refinedSchema.safeParse({ + id: containerId, + newName: 'New Name', + description: 'New description', + isOcrEnabled: true, + isItemVersioningEnabled: true, + itemMajorVersionLimit: 50 + }); + assert.strictEqual(actual.success, true); + }); + + it('correctly updates the container display name', async () => { + const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${containerId}`) { + return patchResponse; + } + + throw 'Invalid PATCH request: ' + opts.url; + }); + + await command.action(logger, { options: { id: containerId, newName: 'Updated Name' } }); + + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + displayName: 'Updated Name' + }); + }); + + it('correctly updates the container description', async () => { + const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${containerId}`) { + return patchResponse; + } + + throw 'Invalid PATCH request: ' + opts.url; + }); + + await command.action(logger, { options: { id: containerId, description: 'Updated Description' } }); + + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + description: 'Updated Description' + }); + }); + + it('correctly disables OCR for a container', async () => { + const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${containerId}`) { + return patchResponse; + } + + throw 'Invalid PATCH request: ' + opts.url; + }); + + await command.action(logger, { options: { id: containerId, isOcrEnabled: false } }); + + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + settings: { + isOcrEnabled: false + } + }); + }); + + it('correctly enables versioning and sets the major version limit', async () => { + const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${containerId}`) { + return patchResponse; + } + + throw 'Invalid PATCH request: ' + opts.url; + }); + + await command.action(logger, { options: { id: containerId, isItemVersioningEnabled: true, itemMajorVersionLimit: 100 } }); + + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + settings: { + isItemVersioningEnabled: true, + itemMajorVersionLimit: 100 + } + }); + }); + + it('correctly updates name, description, and settings together', async () => { + const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${containerId}`) { + return patchResponse; + } + + throw 'Invalid PATCH request: ' + opts.url; + }); + + await command.action(logger, { + options: { + id: containerId, + newName: 'Updated Name', + description: 'Updated Description', + isOcrEnabled: true, + isItemVersioningEnabled: true, + itemMajorVersionLimit: 50, + verbose: true + } + }); + + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + displayName: 'Updated Name', + description: 'Updated Description', + settings: { + isOcrEnabled: true, + isItemVersioningEnabled: true, + itemMajorVersionLimit: 50 + } + }); + }); + + it('correctly logs the output', async () => { + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${containerId}`) { + return patchResponse; + } + + throw 'Invalid PATCH request: ' + opts.url; + }); + + await command.action(logger, { options: { id: containerId, newName: 'Updated Name' } }); + + assert(loggerLogSpy.calledOnceWith(patchResponse)); + }); + + it('correctly handles error', async () => { + sinon.stub(request, 'patch').rejects({ + error: { + code: 'accessDenied', + message: 'Access denied' + } + }); + + await assert.rejects(command.action(logger, { options: { id: containerId, newName: 'Updated Name' } }), + new CommandError('Access denied')); + }); +}); diff --git a/src/m365/spe/commands/container/container-set.ts b/src/m365/spe/commands/container/container-set.ts new file mode 100644 index 00000000000..f0b3530e00a --- /dev/null +++ b/src/m365/spe/commands/container/container-set.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; +import { Logger } from '../../../../cli/Logger.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { validation } from '../../../../utils/validation.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i'), + newName: z.string().optional(), + description: z.string().optional(), + isOcrEnabled: z.boolean().optional(), + isItemVersioningEnabled: z.boolean().optional(), + itemMajorVersionLimit: z.number() + .refine(numb => validation.isValidPositiveInteger(numb), { + error: e => `'${e.input}' is not a valid positive integer.` + }).optional() +}); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class SpeContainerSetCommand extends GraphCommand { + public get name(): string { + return commands.CONTAINER_SET; + } + + public get description(): string { + return 'Updates a SharePoint Embedded container'; + } + + public get schema(): z.ZodType { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(o => o.newName !== undefined || o.description !== undefined || o.isOcrEnabled !== undefined || o.isItemVersioningEnabled !== undefined || o.itemMajorVersionLimit !== undefined, { + error: 'Specify at least one of newName, description, isOcrEnabled, isItemVersioningEnabled, or itemMajorVersionLimit.' + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + if (this.verbose) { + await logger.logToStderr(`Updating container '${args.options.id}'...`); + } + + try { + const data: any = {}; + + if (args.options.newName !== undefined) { + data.displayName = args.options.newName; + } + + if (args.options.description !== undefined) { + data.description = args.options.description; + } + + const settings: any = {}; + if (args.options.isOcrEnabled !== undefined) { + settings.isOcrEnabled = args.options.isOcrEnabled; + } + + if (args.options.isItemVersioningEnabled !== undefined) { + settings.isItemVersioningEnabled = args.options.isItemVersioningEnabled; + } + + if (args.options.itemMajorVersionLimit !== undefined) { + settings.itemMajorVersionLimit = args.options.itemMajorVersionLimit; + } + + if (Object.keys(settings).length > 0) { + data.settings = settings; + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/storage/fileStorage/containers/${args.options.id}`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data + }; + + const container = await request.patch(requestOptions); + await logger.log(container); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new SpeContainerSetCommand();