From cdddadfb101d4fe84454290a96e0f6858f24d4bc Mon Sep 17 00:00:00 2001 From: mkm17 Date: Sun, 9 Jun 2024 23:30:09 +0200 Subject: [PATCH] Adds `site membership list` command Closes #5980 --- .../cmd/spo/site/site-membership-list.mdx | 89 +++++ docs/src/config/sidebars.ts | 5 + src/m365/spo/commands.ts | 1 + src/m365/spo/commands/site/SiteProperties.ts | 1 + .../site/site-membership-list.spec.ts | 360 ++++++++++++++++++ .../spo/commands/site/site-membership-list.ts | 185 +++++++++ 6 files changed, 641 insertions(+) create mode 100644 docs/docs/cmd/spo/site/site-membership-list.mdx create mode 100644 src/m365/spo/commands/site/site-membership-list.spec.ts create mode 100644 src/m365/spo/commands/site/site-membership-list.ts diff --git a/docs/docs/cmd/spo/site/site-membership-list.mdx b/docs/docs/cmd/spo/site/site-membership-list.mdx new file mode 100644 index 00000000000..f75d8ecfeaa --- /dev/null +++ b/docs/docs/cmd/spo/site/site-membership-list.mdx @@ -0,0 +1,89 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spo site membership list + +Retrieve information about default site groups' membership + +## Usage + +```sh +m365 spo site membership list [options] +``` + +## Options + +```md definition-list +`-u, --siteUrl ` +: The URL of the site. + +`-r, --role [role]` +: Filter the results to include only users with the specified roles: 'Owner', 'Member', or 'Visitor'.. + +``` + + + +## Remarks + +To execute this command successfully, ensure you are logged in to the CLI for M365 with an account that has SharePoint admin permissions. For other scenarios, refer to the `spo web get --withGroups` and `spo group member list` commands. + +## Examples + +Retrieves information about default site groups' owners, members, and visitors of the site. + +```sh +m365 spo site membership list --siteUrl https://contoso.sharepoint.com +``` + +Retrieves information about site owners. + +```sh +m365 spo site membership list --siteUrl https://contoso.sharepoint.com --role Owner +``` + +## Response + + + + + ```json + { + "AssociatedOwnerGroup": [ + { + "email": "uName@contoso.onmicrosoft.com", + "loginName": "i:0#.f|membership|uName@contoso.onmicrosoft.com", + "name": "UserName", + "userPrincipalName": "uName@contoso.onmicrosoft.com" + } + ], + "AssociatedMemberGroup": [ + { + "email": "internal@contoso.onmicrosoft.com", + "loginName": "i:0#.f|membership|internal@contoso.onmicrosoft.com", + "name": "internal", + "userPrincipalName": "internal@contoso.onmicrosoft.com" + } + ], + "AssociatedVisitorGroup": [ + { + "email": "", + "loginName": "c:0-.f|rolemanager|spo-grid-all-users/dc109ffd-4298-487e-9cbc-6b9b1a2cd3e2", + "name": "Everyone except external users", + "userPrincipalName": null + } + ] +} + ``` + + + + ```text + AssociatedMemberGroup : [{"email":"internal@contoso.onmicrosoft.com","loginName":"i:0#.f|membership|internal@contoso.onmicrosoft.com","name":"internal","userPrincipalName":"internal@contoso.onmicrosoft.com"}] + AssociatedOwnerGroup : [{"email":"uName@contoso.onmicrosoft.com","loginName":"i:0#.f|membership|uName@contoso.onmicrosoft.com","name":"UserName","userPrincipalName":"uName@contoso.onmicrosoft.com"}] + AssociatedVisitorGroup: [{"email":"","loginName":"c:0-.f|rolemanager|spo-grid-all-users/dc109ffd-4298-487e-9cbc-6b9b1a2cd3e2","name":"Everyone except external users","userPrincipalName":null}] + ``` + + + \ No newline at end of file diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index ce7f253af99..ea975ca23e9 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -3291,6 +3291,11 @@ const sidebars: SidebarsConfig = { label: 'site list', id: 'cmd/spo/site/site-list' }, + { + type: 'doc', + label: 'site membership list', + id: 'cmd/spo/site/site-membership-list' + }, { type: 'doc', label: 'site remove', diff --git a/src/m365/spo/commands.ts b/src/m365/spo/commands.ts index 3756aa1e966..bf8c9c42e8d 100644 --- a/src/m365/spo/commands.ts +++ b/src/m365/spo/commands.ts @@ -257,6 +257,7 @@ export default { SITE_HUBSITE_THEME_SYNC: `${prefix} site hubsite theme sync`, SITE_LIST: `${prefix} site list`, SITE_INPLACERECORDSMANAGEMENT_SET: `${prefix} site inplacerecordsmanagement set`, + SITE_MEMBERSHIP_LIST: `${prefix} site membership list`, SITE_RECYCLEBINITEM_CLEAR: `${prefix} site recyclebinitem clear`, SITE_RECYCLEBINITEM_LIST: `${prefix} site recyclebinitem list`, SITE_RECYCLEBINITEM_MOVE: `${prefix} site recyclebinitem move`, diff --git a/src/m365/spo/commands/site/SiteProperties.ts b/src/m365/spo/commands/site/SiteProperties.ts index 0f6df78181e..b87f91af528 100644 --- a/src/m365/spo/commands/site/SiteProperties.ts +++ b/src/m365/spo/commands/site/SiteProperties.ts @@ -1,4 +1,5 @@ export interface SiteProperties { + SiteId: string; Status: string; Title: string; Url: string; diff --git a/src/m365/spo/commands/site/site-membership-list.spec.ts b/src/m365/spo/commands/site/site-membership-list.spec.ts new file mode 100644 index 00000000000..5d7750fc48b --- /dev/null +++ b/src/m365/spo/commands/site/site-membership-list.spec.ts @@ -0,0 +1,360 @@ +import assert from 'assert'; +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 { 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 './site-membership-list.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { cli } from '../../../../cli/cli.js'; + +describe(commands.SITE_MEMBERSHIP_LIST, () => { + let log: any[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + const membershipList = [ + { + email: 'user1Email@email.com', + loginName: 'i:0#.f|membership|user1loginName@email.com', + name: 'user1DisplayName', + userPrincipalName: 'user1loginName' + }, + { + email: 'user2Email@email.com', + loginName: 'i:0#.f|membership|user2loginName@email.com', + name: 'user2DisplayName', + userPrincipalName: 'user2loginName' + } + ]; + const adminUrl = 'https://contoso-admin.sharepoint.com'; + const siteUrl = 'https://contoso.sharepoint.com/sites/site'; + const siteId = '00000000-0000-0000-0000-000000000010'; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(spo, 'getRequestDigest').resolves({ + FormDigestValue: 'ABC', + FormDigestTimeoutSeconds: 1800, + FormDigestExpiresAt: new Date(), + WebFullUrl: 'https://contoso.sharepoint.com' + }); + auth.connection.active = true; + auth.connection.spoUrl = 'https://contoso.sharepoint.com'; + commandInfo = cli.getCommandInfo(command); + }); + + 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.get, + request.post + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + auth.connection.spoUrl = undefined; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.SITE_MEMBERSHIP_LIST); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if the siteUrl option is not a valid SharePoint URL', () => { + const actual = command.validate({ options: { siteUrl: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the role option is not a valid role', () => { + const actual = command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com', role: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('handles client.svc promise error', async () => { + // get tenant app catalog + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).indexOf('_vti_bin/client.svc/ProcessQuery') > -1) { + throw 'An error has occurred'; + } + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + }); + + it('lists all site membership groups', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId=%27${siteId}%27&userGroupIds=[0,1,2]`) { + return { value: [{ userGroup: membershipList }, { userGroup: membershipList }, { userGroup: membershipList }] }; + }; + throw 'Invalid request'; + }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_vti_bin/client.svc/ProcessQuery`) { + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1203", "ErrorInfo": null, "TraceCorrelationId": "7cd0389e-6015-4000-979e-22c0a7af5f43" + }, 38, { + "IsNull": false + }, 40, { + "IsNull": false + }, 42, { + "IsNull": false + }, 44, { + "IsNull": false + }, 46, { + "IsNull": false + }, 48, { + "_Child_Items_": [ + { SiteId: `/Guid(${siteId})/` } + ] + } + ] + ); + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: siteUrl } }); + assert(loggerLogSpy.calledWith({ + AssociatedOwnerGroup: membershipList, + AssociatedMemberGroup: membershipList, + AssociatedVisitorGroup: membershipList + })); + }); + + it('lists all site membership groups - just Owners group', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId=%27${siteId}%27&userGroupIds=[0]`) { + return { value: [{ userGroup: membershipList }] }; + }; + throw 'Invalid request'; + }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_vti_bin/client.svc/ProcessQuery`) { + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1203", "ErrorInfo": null, "TraceCorrelationId": "7cd0389e-6015-4000-979e-22c0a7af5f43" + }, 38, { + "IsNull": false + }, 40, { + "IsNull": false + }, 42, { + "IsNull": false + }, 44, { + "IsNull": false + }, 46, { + "IsNull": false + }, 48, { + "_Child_Items_": [ + { SiteId: `/Guid(${siteId})/` } + ] + } + ] + ); + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, role: "Owner" } }); + assert(loggerLogSpy.calledWith({ + AssociatedOwnerGroup: membershipList + })); + }); + + it('lists all site membership groups - just Members group', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId=%27${siteId}%27&userGroupIds=[1]`) { + return { value: [{ userGroup: membershipList }] }; + }; + throw 'Invalid request'; + }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_vti_bin/client.svc/ProcessQuery`) { + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1203", "ErrorInfo": null, "TraceCorrelationId": "7cd0389e-6015-4000-979e-22c0a7af5f43" + }, 38, { + "IsNull": false + }, 40, { + "IsNull": false + }, 42, { + "IsNull": false + }, 44, { + "IsNull": false + }, 46, { + "IsNull": false + }, 48, { + "_Child_Items_": [ + { SiteId: `/Guid(${siteId})/` } + ] + } + ] + ); + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, role: "Member" } }); + assert(loggerLogSpy.calledWith({ + AssociatedMemberGroup: membershipList + })); + }); + + it('lists all site membership groups - just Visitors group', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId=%27${siteId}%27&userGroupIds=[2]`) { + return { value: [{ userGroup: membershipList }] }; + }; + throw 'Invalid request'; + }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_vti_bin/client.svc/ProcessQuery`) { + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1203", "ErrorInfo": null, "TraceCorrelationId": "7cd0389e-6015-4000-979e-22c0a7af5f43" + }, 38, { + "IsNull": false + }, 40, { + "IsNull": false + }, 42, { + "IsNull": false + }, 44, { + "IsNull": false + }, 46, { + "IsNull": false + }, 48, { + "_Child_Items_": [ + { SiteId: `/Guid(${siteId})/` } + ] + } + ] + ); + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, role: "Visitor" } }); + assert(loggerLogSpy.calledWith({ + AssociatedVisitorGroup: membershipList + })); + }); + + it('correctly handles error when site is not found for specified site URL', async () => { + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${adminUrl}/_vti_bin/client.svc/ProcessQuery`) { + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1203", "ErrorInfo": null, "TraceCorrelationId": "7cd0389e-6015-4000-979e-22c0a7af5f43" + }, 38, { + "IsNull": false + }, 40, { + "IsNull": false + }, 42, { + "IsNull": false + }, 44, { + "IsNull": false + }, 46, { + "IsNull": false + }, 48, { + "_Child_Items_": [] + } + ] + ); + } + + throw 'Invalid request: ' + opts.url; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl } }), new CommandError(`Failed to obtain site Id from the provided site URL.`)); + }); + + it('correctly handles incorrect site Id guid', async () => { + const incorrectSiteId = 'foo'; + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${adminUrl}/_vti_bin/client.svc/ProcessQuery`) { + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1203", "ErrorInfo": null, "TraceCorrelationId": "7cd0389e-6015-4000-979e-22c0a7af5f43" + }, 38, { + "IsNull": false + }, 40, { + "IsNull": false + }, 42, { + "IsNull": false + }, 44, { + "IsNull": false + }, 46, { + "IsNull": false + }, 48, { + "_Child_Items_": [ + { SiteId: `/Guid(${incorrectSiteId})/` } + ] + } + ] + ); + } + + throw 'Invalid request: ' + opts.url; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl } }), new CommandError('Failed to obtain site Id from the provided site URL.')); + }); + + it('correctly handles error when site id is not found for specified site URL', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).indexOf(`/_vti_bin/client.svc/ProcessQuery`) > -1) { + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1203", "ErrorInfo": { + "ErrorMessage": "Unknown Error", "ErrorValue": null, "TraceCorrelationId": "d2d0389e-a040-4000-b24b-d16b0546a03c", "ErrorCode": -1, "ErrorTypeName": "Microsoft.SharePoint.Client.UnknownError" + }, "TraceCorrelationId": "7cd0389e-6015-4000-979e-22c0a7af5f43" + } + ] + ); + } + throw 'Invalid request: ' + opts.url; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl } }), new CommandError('Unknown Error')); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/site/site-membership-list.ts b/src/m365/spo/commands/site/site-membership-list.ts new file mode 100644 index 00000000000..3cac17e433c --- /dev/null +++ b/src/m365/spo/commands/site/site-membership-list.ts @@ -0,0 +1,185 @@ +import GlobalOptions from '../../../../GlobalOptions.js'; +import { Logger } from '../../../../cli/Logger.js'; +import config from '../../../../config.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { ClientSvcResponse, ClientSvcResponseContents, FormDigestInfo, spo } from '../../../../utils/spo.js'; +import { validation } from '../../../../utils/validation.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import commands from '../../commands.js'; +import { SPOSitePropertiesEnumerable } from './SPOSitePropertiesEnumerable.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + siteUrl: string; + role?: string; +} + +interface IMembershipResult { + userGroup: IUserInfo[]; +} + +interface IMembershipOutput { + AssociatedOwnerGroup?: IUserInfo[]; + AssociatedMemberGroup?: IUserInfo[]; + AssociatedVisitorGroup?: IUserInfo[]; +} + +interface IUserInfo { + email: string; + loginName: string; + name: string; + userPrincipalName: string; +} + +class SpoSiteMemberShipListCommand extends SpoCommand { + public static readonly RoleName: string[] = ['Owner', 'Member', 'Visitor']; + + public get name(): string { + return commands.SITE_MEMBERSHIP_LIST; + } + + public get description(): string { + return `Retrieve information about default site groups' membership.`; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + siteUrl: typeof args.options.siteUrl !== 'undefined', + role: typeof args.options.role !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-u, --siteUrl ' + }, + { + option: '-r, --role [role]', + autocomplete: SpoSiteMemberShipListCommand.RoleName + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.role && SpoSiteMemberShipListCommand.RoleName.indexOf(args.options.role) === -1) { + return 'The value of parameter role must be Visitor|Member|Owner'; + } + + return validation.isValidSharePointUrl(args.options.siteUrl); + } + ); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + const spoAdminUrl: string = await spo.getSpoAdminUrl(logger, this.debug); + const roleIds: string = this.getRoleIds(args.options.role); + const siteId = await this.getSiteIdBasedOnUrl(logger, args.options.siteUrl, spoAdminUrl); + + const requestOptions: CliRequestOptions = { + url: `${spoAdminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId=%27${siteId}%27&userGroupIds=[${roleIds}]`, + headers: { + accept: 'application/json;odata=nometadata', + 'content-Type': 'application/json' + }, + responseType: 'json' + }; + + const response = await request.get<{ value: any }>(requestOptions); + const result = this.mapResult(response.value, args); + + await logger.log(result); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private getRoleIds(role: string | undefined): string { + if (role === 'Owner') { + return '0'; + } + else if (role === 'Member') { + return '1'; + } + else if (role === 'Visitor') { + return '2'; + } + else { + return '0,1,2'; + } + } + + private async getSiteIdBasedOnUrl(logger: Logger, siteUrl: string, spoAdminUrl: string): Promise { + const res: FormDigestInfo = await spo.ensureFormDigest(spoAdminUrl, logger, undefined, this.debug); + + const urlFilter = formatting.escapeXml(`Url -eq '${siteUrl}'`); + const requestBody: string = `${urlFilter}false10`; + + const requestOptions: CliRequestOptions = { + url: `${spoAdminUrl}/_vti_bin/client.svc/ProcessQuery`, + headers: { + 'X-RequestDigest': res.FormDigestValue + }, + data: requestBody + }; + + const response: string = await request.post(requestOptions); + const jsonData: ClientSvcResponse = JSON.parse(response); + const responseContent: ClientSvcResponseContents = jsonData[0]; + + if (responseContent.ErrorInfo) { + throw responseContent.ErrorInfo.ErrorMessage; + } + + const sites: SPOSitePropertiesEnumerable = jsonData[jsonData.length - 1]; + const siteId = sites?._Child_Items_?.[0]?.SiteId ?? undefined; + + if (!siteId) { + throw 'Failed to obtain site Id from the provided site URL.'; + } + + const guid = siteId.replace('/Guid(', '').replace(')/', ''); + + if (!validation.isValidGuid(guid)) { + throw 'Failed to obtain site Id from the provided site URL.'; + } + return guid; + } + + private mapResult(response: IMembershipResult[], args: CommandArgs): IMembershipOutput { + switch (args.options.role) { + case 'Owner': + return { 'AssociatedOwnerGroup': response[0].userGroup }; + case 'Member': + return { 'AssociatedMemberGroup': response[0].userGroup }; + case 'Visitor': + return { 'AssociatedVisitorGroup': response[0].userGroup }; + default: + return { + 'AssociatedOwnerGroup': response[0].userGroup, + 'AssociatedMemberGroup': response[1].userGroup, + 'AssociatedVisitorGroup': response[2].userGroup + }; + } + } +} + +export default new SpoSiteMemberShipListCommand(); \ No newline at end of file