From d234b7ea809a25f95b42881a194f43a21540a244 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Tue, 22 Aug 2023 10:07:02 +0200 Subject: [PATCH] Removes M365 group and connected site. Closes #5224 --- .../cmd/aad/m365group/m365group-remove.mdx | 2 +- .../m365group/m365group-remove.spec.ts | 239 +++++++++--------- .../commands/m365group/m365group-remove.ts | 152 +++++++++-- 3 files changed, 250 insertions(+), 143 deletions(-) diff --git a/docs/docs/cmd/aad/m365group/m365group-remove.mdx b/docs/docs/cmd/aad/m365group/m365group-remove.mdx index 9f7e0e24f38..a3986b35452 100644 --- a/docs/docs/cmd/aad/m365group/m365group-remove.mdx +++ b/docs/docs/cmd/aad/m365group/m365group-remove.mdx @@ -27,7 +27,7 @@ m365 aad m365group remove [options] ## Remarks -If the specified _id_ doesn't refer to an existing group, you will get a `Resource does not exist` error. +If the specified _id_ doesn't refer to an existing group, you will get a `Resource does not exist` error. Additionally, if you do not have access to the group or the associated group-connected site, you will get an `Access denied` error. ## Examples diff --git a/src/m365/aad/commands/m365group/m365group-remove.spec.ts b/src/m365/aad/commands/m365group/m365group-remove.spec.ts index 02b7b6446c2..9fc9e426e39 100644 --- a/src/m365/aad/commands/m365group/m365group-remove.spec.ts +++ b/src/m365/aad/commands/m365group/m365group-remove.spec.ts @@ -11,21 +11,70 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; +import { spo } from '../../../../utils/spo.js'; import command from './m365group-remove.js'; describe(commands.M365GROUP_REMOVE, () => { let log: string[]; let logger: Logger; - let loggerLogSpy: sinon.SinonSpy; + let loggerLogToStderrSpy: sinon.SinonSpy; let commandInfo: CommandInfo; let promptOptions: any; + const groupId = "3e6e705d-6fb5-4ca7-84dc-3c8f5154fe2c"; + + const defaultGetStub = (): sinon.SinonStub => { + return sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}/drive?$select=webUrl`) { + return { webUrl: "https://contoso.sharepoint.com/teams/sales/Shared%20Documents" }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/directory/deletedItems/${groupId}`) { + return { id: groupId }; + } + + throw 'Invalid request'; + }); + }; + + const defaultPostStub = (): sinon.SinonStub => { + return sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso-admin.sharepoint.com/_api/GroupSiteManager/Delete?siteUrl='https://contoso.sharepoint.com/teams/sales'`) { + return Promise.resolve({ + "data": { + "odata.null": true + } + }); + } + + if ((opts.url as string).indexOf('/_vti_bin/client.svc/ProcessQuery') > -1) { + return JSON.stringify([{ "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7324.1200", "ErrorInfo": null, "TraceCorrelationId": "e13c489e-304e-5000-8242-705e26a87302" }, 185, { "IsNull": false }]); + } + + throw 'Invalid request'; + }); + }; + + const defaultDeleteStub = (): sinon.SinonStub => { + return sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/directory/deletedItems/${groupId}`) { + return; + } + throw 'Invalid request'; + }); + }; + before(() => { sinon.stub(auth, 'restoreAuth').resolves(); sinon.stub(telemetry, 'trackEvent').returns(); sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); + sinon.stub(global, 'setTimeout').callsFake((fn) => { + fn(); + return {} as any; + }); auth.service.connected = true; + auth.service.spoUrl = 'https://contoso.sharepoint.com'; commandInfo = Cli.getCommandInfo(command); }); @@ -42,18 +91,25 @@ describe(commands.M365GROUP_REMOVE, () => { log.push(msg); } }; + sinon.stub(spo, 'getSpoAdminUrl').callsFake(() => Promise.resolve('https://contoso-admin.sharepoint.com')); + const futureDate = new Date(); + futureDate.setSeconds(futureDate.getSeconds() + 1800); + sinon.stub(spo, 'ensureFormDigest').callsFake(() => { return Promise.resolve({ FormDigestValue: 'abc', FormDigestTimeoutSeconds: 1800, FormDigestExpiresAt: futureDate, WebFullUrl: 'https://contoso.sharepoint.com/sites/hr' }); }); sinon.stub(Cli, 'prompt').callsFake(async (options: any) => { promptOptions = options; return { continue: false }; }); - loggerLogSpy = sinon.spy(logger, 'log'); + loggerLogToStderrSpy = sinon.spy(logger, 'logToStderr'); promptOptions = undefined; }); afterEach(() => { sinonUtil.restore([ + request.get, + request.post, request.delete, - global.setTimeout, + spo.getSpoAdminUrl, + spo.ensureFormDigest, Cli.prompt ]); }); @@ -61,6 +117,7 @@ describe(commands.M365GROUP_REMOVE, () => { after(() => { sinon.restore(); auth.service.connected = false; + auth.service.spoUrl = undefined; }); it('has correct name', () => { @@ -71,30 +128,14 @@ describe(commands.M365GROUP_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('removes the specified group without prompting for confirmation when confirm option specified', async () => { - sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === 'https://graph.microsoft.com/v1.0/groups/28beab62-7540-4db1-a23f-29a6018a3848') { - return; - } - - throw 'Invalid request'; - }); - - await command.action(logger, { options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', force: false } }); - assert(loggerLogSpy.notCalled); + it('fails validation if the id is not a valid GUID', async () => { + const actual = await command.validate({ options: { id: 'abc' } }, commandInfo); + assert.notStrictEqual(actual, true); }); - it('removes the specified group without prompting for confirmation when confirm option specified (debug)', async () => { - sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === 'https://graph.microsoft.com/v1.0/groups/28beab62-7540-4db1-a23f-29a6018a3848') { - return; - } - - throw 'Invalid request'; - }); - - await command.action(logger, { options: { debug: true, id: '28beab62-7540-4db1-a23f-29a6018a3848', force: false } }); - assert(loggerLogSpy.notCalled); + it('passes validation when the id is a valid GUID', async () => { + const actual = await command.validate({ options: { id: '2c1ba4c4-cd9b-4417-832f-92a34bc34b2a' } }, commandInfo); + assert.strictEqual(actual, true); }); it('prompts before removing the specified group when confirm option not passed', async () => { @@ -108,130 +149,84 @@ describe(commands.M365GROUP_REMOVE, () => { assert(promptIssued); }); - it('prompts before removing the specified group when confirm option not passed (debug)', async () => { - await command.action(logger, { options: { debug: true, id: '28beab62-7540-4db1-a23f-29a6018a3848' } }); - let promptIssued = false; - - if (promptOptions && promptOptions.type === 'confirm') { - promptIssued = true; - } - - assert(promptIssued); - }); - it('aborts removing the group when prompt not confirmed', async () => { - const postSpy = sinon.spy(request, 'delete'); - sinonUtil.restore(Cli.prompt); - sinon.stub(Cli, 'prompt').resolves({ continue: false }); + const getGroupSpy: sinon.SinonStub = defaultGetStub(); await command.action(logger, { options: { id: '28beab62-7540-4db1-a23f-29a6018a3848' } }); - assert(postSpy.notCalled); + assert(getGroupSpy.notCalled); }); - it('aborts removing the group when prompt not confirmed (debug)', async () => { - const postSpy = sinon.spy(request, 'delete'); - sinonUtil.restore(Cli.prompt); - sinon.stub(Cli, 'prompt').resolves({ continue: false }); - - await command.action(logger, { options: { debug: true, id: '28beab62-7540-4db1-a23f-29a6018a3848' } }); - assert(postSpy.notCalled); - }); + it('deletes the group site for the sepcified group id when prompt confirmed', async () => { + defaultGetStub(); + const deletedGroupSpy: sinon.SinonStub = defaultPostStub(); - it('removes the group when prompt confirmed', async () => { - const postStub = sinon.stub(request, 'delete').resolves(); sinonUtil.restore(Cli.prompt); sinon.stub(Cli, 'prompt').callsFake(async () => ( { continue: true } )); - await command.action(logger, { options: { id: '28beab62-7540-4db1-a23f-29a6018a3848' } }); - assert(postStub.called); + + await command.action(logger, { options: { id: groupId, verbose: true } }); + assert(deletedGroupSpy.calledOnce); + assert(loggerLogToStderrSpy.calledWith(`Deleting the group site: 'https://contoso.sharepoint.com/teams/sales'...`)); }); - it('removes the group when prompt confirmed (debug)', async () => { - const postStub = sinon.stub(request, 'delete').resolves(); - sinonUtil.restore(Cli.prompt); - sinon.stub(Cli, 'prompt').resolves({ continue: true }); + it('deletes the group without moving it to the Recycle Bin', async () => { + defaultGetStub(); + defaultPostStub(); + const deleteStub: sinon.SinonStub = defaultDeleteStub(); - await command.action(logger, { options: { debug: true, id: '28beab62-7540-4db1-a23f-29a6018a3848' } }); - assert(postStub.called); + await command.action(logger, { options: { id: groupId, verbose: true, skipRecycleBin: true, force: true } }); + assert(deleteStub.called); + assert(loggerLogToStderrSpy.calledWith("Group has been deleted and is now available in the deleted items list. Removing permanently...")); }); - it('removes the group permanently when prompt confirmed', async () => { - let groupPermDeleteCallIssued = false; - sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/groups/28beab62-7540-4db1-a23f-29a6018a3848`) { - return; - } + it('verifies if the group is deleted and available in the deleted groups list, retry and delete the group', async () => { + const getCallStub: sinon.SinonStub = sinon.stub(request, 'get'); - if (opts.url === `https://graph.microsoft.com/v1.0/directory/deletedItems/28beab62-7540-4db1-a23f-29a6018a3848`) { - groupPermDeleteCallIssued = true; - return; - } + getCallStub.withArgs(sinon.match({ url: `https://graph.microsoft.com/v1.0/groups/${groupId}/drive?$select=webUrl` })) + .resolves({ webUrl: "https://contoso.sharepoint.com/teams/sales/Shared%20Documents" }); - throw 'Invalid request'; - }); - sinonUtil.restore(Cli.prompt); - sinon.stub(Cli, 'prompt').resolves({ continue: true }); + getCallStub.withArgs(sinon.match({ url: `https://graph.microsoft.com/v1.0/directory/deletedItems/${groupId}` })) + .onFirstCall().rejects({ response: { status: 404 } }) + .onSecondCall().resolves({ id: groupId }); - await command.action(logger, { options: { debug: true, id: '28beab62-7540-4db1-a23f-29a6018a3848', skipRecycleBin: true } }); - assert(groupPermDeleteCallIssued); - }); + defaultPostStub(); + const deleteStub: sinon.SinonStub = defaultDeleteStub(); - it('removes the group permanently when with confirm option', async () => { - let groupPermDeleteCallIssued = false; - sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/groups/28beab62-7540-4db1-a23f-29a6018a3848`) { - return; - } + await command.action(logger, { options: { id: groupId, verbose: true, skipRecycleBin: true, force: true } }); + assert(loggerLogToStderrSpy.calledWith("Group has not been deleted yet. Waiting and retrying...")); + assert(deleteStub.called); + }); - if (opts.url === `https://graph.microsoft.com/v1.0/directory/deletedItems/28beab62-7540-4db1-a23f-29a6018a3848`) { - groupPermDeleteCallIssued = true; - return; - } + it('handles error if unexpected error occurs while finding the group in the deleted groups list', async () => { + const getCallStub: sinon.SinonStub = sinon.stub(request, 'get'); - throw 'Invalid request'; - }); + getCallStub.withArgs(sinon.match({ url: `https://graph.microsoft.com/v1.0/groups/${groupId}/drive?$select=webUrl` })) + .resolves({ webUrl: "https://contoso.sharepoint.com/teams/sales/Shared%20Documents" }); - await command.action(logger, { options: { debug: true, id: '28beab62-7540-4db1-a23f-29a6018a3848', skipRecycleBin: true, force: true } }); - assert(groupPermDeleteCallIssued); - }); + getCallStub.withArgs(sinon.match({ url: `https://graph.microsoft.com/v1.0/directory/deletedItems/${groupId}` })) + .rejects(); - it('correctly handles error when group is not found', async () => { - sinon.stub(request, 'delete').rejects({ error: { 'odata.error': { message: { value: 'File Not Found.' } } } }); + defaultPostStub(); + defaultDeleteStub(); - await assert.rejects(command.action(logger, { options: { force: true, id: '28beab62-7540-4db1-a23f-29a6018a3848' } } as any), - new CommandError('File Not Found.')); + await assert.rejects(command.action(logger, { options: { id: groupId, verbose: true, skipRecycleBin: true, force: true } }), + new CommandError('Error')); }); - it('supports specifying id', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--id') > -1) { - containsOption = true; - } - }); - assert(containsOption); - }); + it('handles group not found after all retries', async () => { + const getCallStub: sinon.SinonStub = sinon.stub(request, 'get'); - it('supports specifying confirmation flag', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--force') > -1) { - containsOption = true; - } - }); - assert(containsOption); - }); + getCallStub.withArgs(sinon.match({ url: `https://graph.microsoft.com/v1.0/groups/${groupId}/drive?$select=webUrl` })) + .resolves({ webUrl: "https://contoso.sharepoint.com/teams/sales/Shared%20Documents" }); - it('fails validation if the id is not a valid GUID', async () => { - const actual = await command.validate({ options: { id: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); - }); + getCallStub.withArgs(sinon.match({ url: `https://graph.microsoft.com/v1.0/directory/deletedItems/${groupId}` })) + .rejects({ response: { status: 404 } }); - it('passes validation when the id is a valid GUID', async () => { - const actual = await command.validate({ options: { id: '2c1ba4c4-cd9b-4417-832f-92a34bc34b2a' } }, commandInfo); - assert.strictEqual(actual, true); + defaultPostStub(); + const deleteStub: sinon.SinonStub = defaultDeleteStub(); + + await command.action(logger, { options: { id: groupId, verbose: true, skipRecycleBin: true, force: true } }); + assert(deleteStub.notCalled); }); -}); +}); \ No newline at end of file diff --git a/src/m365/aad/commands/m365group/m365group-remove.ts b/src/m365/aad/commands/m365group/m365group-remove.ts index 6bea32c609b..f6727fa4f53 100644 --- a/src/m365/aad/commands/m365group/m365group-remove.ts +++ b/src/m365/aad/commands/m365group/m365group-remove.ts @@ -5,6 +5,9 @@ import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +import config from '../../../../config.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { FormDigestInfo, spo } from '../../../../utils/spo.js'; interface CommandArgs { options: Options; @@ -17,12 +20,14 @@ interface Options extends GlobalOptions { } class AadM365GroupRemoveCommand extends GraphCommand { + private spoAdminUrl?: string; + public get name(): string { return commands.M365GROUP_REMOVE; } public get description(): string { - return 'Removes an Microsoft 365 Group'; + return 'Removes a Microsoft 365 Group'; } constructor() { @@ -69,29 +74,18 @@ class AadM365GroupRemoveCommand extends GraphCommand { } public async commandAction(logger: Logger, args: CommandArgs): Promise { - const removeGroup = async (): Promise => { - if (this.verbose) { - await logger.logToStderr(`Removing Microsoft 365 Group: ${args.options.id}...`); - } + if (this.verbose) { + await logger.logToStderr(`Removing Microsoft 365 Group: ${args.options.id}...`); + } + const removeGroup = async (): Promise => { try { - const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/groups/${args.options.id}`, - headers: { - 'accept': 'application/json;odata.metadata=none' - } - }; - - await request.delete(requestOptions); + const siteUrl = await this.getM365GroupSiteURL(logger, args.options.id); + await this.deleteM365GroupSite(logger, siteUrl); if (args.options.skipRecycleBin) { - const requestOptions2: CliRequestOptions = { - url: `${this.resource}/v1.0/directory/deletedItems/${args.options.id}`, - headers: { - 'accept': 'application/json;odata.metadata=none' - } - }; - await request.delete(requestOptions2); + await this.deleteM365GroupFromRecyclebin(logger, args.options.id); + await this.deleteSiteFromRecycleBin(siteUrl, logger); } } catch (err: any) { @@ -115,6 +109,124 @@ class AadM365GroupRemoveCommand extends GraphCommand { } } } + + private async getM365GroupSiteURL(logger: Logger, id: string): Promise { + if (this.verbose) { + await logger.logToStderr(`Getting the site URL of Microsoft 365 Group: ${id}...`); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/groups/${id}/drive?$select=webUrl`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + const res = await request.get<{ webUrl: string }>(requestOptions); + return res.webUrl.substring(0, res.webUrl.lastIndexOf('/')); + } + + private async deleteM365GroupSite(logger: Logger, url: string): Promise { + if (this.verbose) { + await logger.logToStderr(`Deleting the group site: '${url}'...`); + } + + const spoAdminUrl = await spo.getSpoAdminUrl(logger, this.debug); + this.spoAdminUrl = spoAdminUrl; + + const requestOptions: CliRequestOptions = { + url: `${this.spoAdminUrl}/_api/GroupSiteManager/Delete?siteUrl='${url}'`, + headers: { + 'content-type': 'application/json;odata=nometadata', + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + await request.post(requestOptions); + } + + private async deleteM365GroupFromRecyclebin(logger: Logger, id: string): Promise { + const maxRetries = 10; + const intervalInMs: number = 6000; + + for (let retries = 0; retries < maxRetries; retries++) { + if (await this.isM365GroupInDeletedItemsList(id)) { + await this.removeM365GroupPermanently(logger, id); + return; + } + else { + if (this.verbose) { + await logger.logToStderr(`Group has not been deleted yet. Waiting and retrying...`); + } + + await this.sleep(logger, intervalInMs); + } + } + + await logger.logToStderr(`Group could not be removed from the recycle bin after all retries.`); + } + + private async removeM365GroupPermanently(logger: Logger, id: string): Promise { + if (this.verbose) { + await logger.logToStderr(`Group has been deleted and is now available in the deleted items list. Removing permanently...`); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/directory/deletedItems/${id}`, + headers: { + 'accept': 'application/json;odata.metadata=none' + } + }; + + await request.delete(requestOptions); + } + + private async isM365GroupInDeletedItemsList(id: string): Promise { + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/directory/deletedItems/${id}`, + headers: { + 'accept': 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + try { + const response = await request.get<{ id: string }>(requestOptions); + return Boolean(response && response.id); + } + catch (error: any) { + if (error.response && error.response.status === 404) { + return false; + } + else { + throw error; + } + } + } + + private async sleep(logger: Logger, ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private async deleteSiteFromRecycleBin(url: string, logger: Logger): Promise { + if (this.verbose) { + await logger.logToStderr(`Deleting the M365 group site '${url}' from the recycle bin...`); + } + + const res: FormDigestInfo = await spo.ensureFormDigest(this.spoAdminUrl as string, logger, undefined, this.debug); + + const requestOptions: any = { + url: `${this.spoAdminUrl}/_vti_bin/client.svc/ProcessQuery`, + headers: { + 'X-RequestDigest': res.FormDigestValue + }, + data: `${formatting.escapeXml(url)}` + }; + + await request.post(requestOptions); + } } export default new AadM365GroupRemoveCommand(); \ No newline at end of file