From 6f78d793e2455219d733a6c08c56bb23981a7e88 Mon Sep 17 00:00:00 2001 From: Milan Holemans <11723921+milanholemans@users.noreply.github.com> Date: Tue, 3 Sep 2024 23:50:03 +0200 Subject: [PATCH] Adds command 'spo site sharingpermission set'. Closes #6266 --- .../spo/site/site-sharingpermission-set.mdx | 58 ++++++ docs/src/config/sidebars.ts | 5 + src/m365/spo/commands.ts | 1 + .../site/site-sharingpermission-set.spec.ts | 168 ++++++++++++++++++ .../site/site-sharingpermission-set.ts | 85 +++++++++ 5 files changed, 317 insertions(+) create mode 100644 docs/docs/cmd/spo/site/site-sharingpermission-set.mdx create mode 100644 src/m365/spo/commands/site/site-sharingpermission-set.spec.ts create mode 100644 src/m365/spo/commands/site/site-sharingpermission-set.ts diff --git a/docs/docs/cmd/spo/site/site-sharingpermission-set.mdx b/docs/docs/cmd/spo/site/site-sharingpermission-set.mdx new file mode 100644 index 00000000000..f7670f0a819 --- /dev/null +++ b/docs/docs/cmd/spo/site/site-sharingpermission-set.mdx @@ -0,0 +1,58 @@ +import Global from '/docs/cmd/_global.mdx'; + +# spo site sharingpermission set + +Controls how a site and its components can be shared + +## Usage + +```sh +m365 spo site sharingpermission set [options] +``` + +## Options + +```md definition-list +`-u, --url ` +: URL of the site. + +`--capability ` +: Define how the site is shared. Possible values: `full`, `limited`, `ownersOnly`. +``` + + + +## Remarks + +When specifying `capability`, consider the following: +- `full`: Site owners and members can share files, folders, and the site. People with Edit permissions can share files and folders. +- `limited`: Site owners and members, and people with Edit permissions can share files and folders, but only site owners can share the site. +- `ownersOnly`: Only site owners can share files, folders, and the site. + +## Examples + +Update the sharing permissions for a site so only owners can share files and the site + +```sh +m365 spo site sharingpermission set --siteUrl https://siteaddress.com/sites/sitename --capability ownersOnly +``` + +Update the sharing permissions for a site where so both owners and members can share files and the site + +```sh +m365 spo site sharingpermission set --siteUrl https://siteaddress.com/sites/sitename --capability full +``` + +Update the sharing permissions for a site where so owners can share the site, but members can only share files + +```sh +m365 spo site sharingpermission set --siteUrl https://siteaddress.com/sites/sitename --capability full +``` + +## Response + +The command won't return a response on success. + +## More information + +- Sharing a SharePoint site: [https://support.microsoft.com/office/share-a-site-958771a8-d041-4eb8-b51c-afea2eae3658](https://support.microsoft.com/office/share-a-site-958771a8-d041-4eb8-b51c-afea2eae3658) diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index ed0cd101d38..0e622422ac6 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -3525,6 +3525,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'site recyclebinitem restore', id: 'cmd/spo/site/site-recyclebinitem-restore' + }, + { + type: 'doc', + label: 'site sharingpermission set', + id: 'cmd/spo/site/site-sharingpermission-set' } ] }, diff --git a/src/m365/spo/commands.ts b/src/m365/spo/commands.ts index 24e5f9621c5..485fcf533f4 100644 --- a/src/m365/spo/commands.ts +++ b/src/m365/spo/commands.ts @@ -274,6 +274,7 @@ export default { SITE_REMOVE: `${prefix} site remove`, SITE_RENAME: `${prefix} site rename`, SITE_SET: `${prefix} site set`, + SITE_SHARINGPERMISSION_SET: `${prefix} site sharingpermission set`, SITE_CHROME_SET: `${prefix} site chrome set`, SITEDESIGN_ADD: `${prefix} sitedesign add`, SITEDESIGN_APPLY: `${prefix} sitedesign apply`, diff --git a/src/m365/spo/commands/site/site-sharingpermission-set.spec.ts b/src/m365/spo/commands/site/site-sharingpermission-set.spec.ts new file mode 100644 index 00000000000..1a61b6c05cb --- /dev/null +++ b/src/m365/spo/commands/site/site-sharingpermission-set.spec.ts @@ -0,0 +1,168 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { CommandError } from '../../../../Command.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './site-sharingpermission-set.js'; +import { z } from 'zod'; +import { cli } from '../../../../cli/cli.js'; + +describe(commands.SITE_SHARINGPERMISSION_SET, () => { + const siteUrl = 'https://contoso.sharepoint.com/sites/marketing'; + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let patchStub: sinon.SinonStub; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + auth.connection.active = true; + }); + + 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'); + + patchStub = sinon.stub(request, 'patch').callsFake(async opts => { + if (opts.url === `${siteUrl}/_api/Web`) { + return; + } + if (opts.url === `${siteUrl}/_api/Web/AssociatedMemberGroup`) { + return; + } + + throw 'Invalid request :' + opts.url; + }); + }); + + afterEach(() => { + sinonUtil.restore([ + request.patch + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.SITE_SHARINGPERMISSION_SET); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if siteUrl is not a valid URL', async () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: 'invalid', capability: 'full' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation when capability is not a valid value', async () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: siteUrl, capability: 'invalid' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation when the input is correct', async () => { + const actual = commandOptionsSchema.safeParse({ siteUrl: siteUrl, capability: 'limited' }); + assert.strictEqual(actual.success, true); + }); + + it('outputs no command output', async () => { + patchStub.restore(); + sinon.stub(request, 'patch').resolves(); + + await command.action(logger, { + options: { + siteUrl: siteUrl, + capability: 'full', + verbose: true + } + }); + + assert(loggerLogSpy.notCalled); + }); + + it('correctly sets sharing permissions to full', async () => { + await command.action(logger, { + options: { + siteUrl: siteUrl, + capability: 'full' + } + }); + + assert.deepStrictEqual(patchStub.firstCall.args[0].data, { MembersCanShare: true }); + assert.deepStrictEqual(patchStub.secondCall.args[0].data, { AllowMembersEditMembership: true }); + }); + + it('correctly sets sharing permissions to limited', async () => { + await command.action(logger, { + options: { + siteUrl: siteUrl, + capability: 'limited' + } + }); + + assert.deepStrictEqual(patchStub.firstCall.args[0].data, { MembersCanShare: true }); + assert.deepStrictEqual(patchStub.secondCall.args[0].data, { AllowMembersEditMembership: false }); + }); + + it('correctly sets sharing permissions to ownersOnly', async () => { + await command.action(logger, { + options: { + siteUrl: siteUrl, + capability: 'ownersOnly' + } + }); + + assert.deepStrictEqual(patchStub.firstCall.args[0].data, { MembersCanShare: false }); + assert.deepStrictEqual(patchStub.secondCall.args[0].data, { AllowMembersEditMembership: false }); + }); + + it('correctly handles error when updating sharing permissions', async () => { + patchStub.restore(); + const errorMessage = 'Access is denied.'; + + sinon.stub(request, 'patch').rejects({ + error: { + 'odata.error': { + message: { + lang: 'en-US', + value: errorMessage + } + } + } + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl, capability: 'limited' } }), + new CommandError(errorMessage)); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/site/site-sharingpermission-set.ts b/src/m365/spo/commands/site/site-sharingpermission-set.ts new file mode 100644 index 00000000000..9b33959fbaa --- /dev/null +++ b/src/m365/spo/commands/site/site-sharingpermission-set.ts @@ -0,0 +1,85 @@ +import SpoCommand from '../../../base/SpoCommand.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { z } from 'zod'; +import { zod } from '../../../../utils/zod.js'; +import { Logger } from '../../../../cli/Logger.js'; +import commands from '../../commands.js'; +import { validation } from '../../../../utils/validation.js'; +import request, { CliRequestOptions } from '../../../../request.js'; + +const options = globalOptionsZod + .extend({ + siteUrl: zod.alias('u', z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, url => ({ + message: `'${url}' is not a valid SharePoint Online site URL.` + })) + ), + capability: z.enum(['full', 'limited', 'ownersOnly']) + }) + .strict(); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class SpoSiteSharingPermissionSetCommand extends SpoCommand { + public get name(): string { + return commands.SITE_SHARINGPERMISSION_SET; + } + + public get description(): string { + return 'Controls how a site and its components can be shared'; + } + + public get schema(): z.ZodTypeAny { + return options; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (this.verbose) { + await logger.logToStderr(`Updating sharing permissions for site '${args.options.siteUrl}'...`); + } + + const { capability } = args.options; + + if (this.verbose) { + await logger.logToStderr(`Updating site sharing permissions...`); + } + const requestOptionsWeb: CliRequestOptions = { + url: `${args.options.siteUrl}/_api/Web`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json', + data: { + MembersCanShare: capability === 'full' || capability === 'limited' + } + }; + await request.patch(requestOptionsWeb); + + if (this.verbose) { + await logger.logToStderr(`Updating associated member group sharing permissions...`); + } + + const requestOptionsMemberGroup: CliRequestOptions = { + url: `${args.options.siteUrl}/_api/Web/AssociatedMemberGroup`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json', + data: { + AllowMembersEditMembership: capability === 'full' + } + }; + + await request.patch(requestOptionsMemberGroup); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new SpoSiteSharingPermissionSetCommand(); \ No newline at end of file