diff --git a/docs/docs/cmd/spo/tenant/tenant-recyclebinitem-restore.mdx b/docs/docs/cmd/spo/tenant/tenant-recyclebinitem-restore.mdx index 4c237d0d6f0..df4bfd690bd 100644 --- a/docs/docs/cmd/spo/tenant/tenant-recyclebinitem-restore.mdx +++ b/docs/docs/cmd/spo/tenant/tenant-recyclebinitem-restore.mdx @@ -16,21 +16,19 @@ m365 spo tenant recyclebinitem restore [options] ```md definition-list `-u, --siteUrl ` -: URL of the site to restore +: URL of the site. `--wait` -: Wait for the site collection to be restored before completing the command +: (deprecated) Wait for the site collection to be restored before completing the command. ``` ## Remarks -Restoring deleted site collections is by default asynchronous and depending on the current state of Microsoft 365, might take up to few minutes. If you're building a script with steps that require the site to be fully restored, you should use the `--wait` flag. When using this flag, the `spo tenant recyclebinitem restore` command will keep running until it received confirmation from Microsoft 365 that the site has been fully restored. - :::info -To use this command you have to have permissions to access the tenant admin site. +To use this command you must be a Global or SharePoint administrator. ::: @@ -42,12 +40,6 @@ Restore a deleted site collection from tenant recycle bin m365 spo tenant recyclebinitem restore --siteUrl https://contoso.sharepoint.com/sites/team ``` -Restore a deleted site collection from tenant recycle bin and wait for completion - -```sh -m365 spo tenant recyclebinitem restore --siteUrl https://contoso.sharepoint.com/sites/team --wait -``` - ## Response diff --git a/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.spec.ts b/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.spec.ts index 947e2f59ebe..ec4323fafc4 100644 --- a/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.spec.ts +++ b/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.spec.ts @@ -12,12 +12,17 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './tenant-recyclebinitem-restore.js'; +import { odata } from '../../../../utils/odata.js'; +import { formatting } from '../../../../utils/formatting.js'; describe(commands.TENANT_RECYCLEBINITEM_RESTORE, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; - let loggerLogSpy: sinon.SinonSpy; + + const siteUrl = 'https://contoso.sharepoint.com/sites/hr'; + const siteRestoreUrl = 'https://contoso-admin.sharepoint.com/_api/SPO.Tenant/RestoreDeletedSite'; + const odataUrl = `https://contoso-admin.sharepoint.com/_api/web/lists/GetByTitle('DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS')/items?$filter=SiteUrl eq '${formatting.encodeQueryParameter(siteUrl)}'&$select=GroupId`; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -42,13 +47,12 @@ describe(commands.TENANT_RECYCLEBINITEM_RESTORE, () => { log.push(msg); } }; - loggerLogSpy = sinon.spy(logger, 'log'); }); afterEach(() => { - (command as any).currentContext = undefined; sinonUtil.restore([ - request.post + request.post, + odata.getAllItems ]); }); @@ -66,83 +70,92 @@ describe(commands.TENANT_RECYCLEBINITEM_RESTORE, () => { assert.notStrictEqual(command.description, null); }); + it(`correctly shows deprecation warning for option 'wait'`, async () => { + const chalk = (await import('chalk')).default; + const loggerErrSpy = sinon.spy(logger, 'logToStderr'); + + sinon.stub(request, 'post').resolves(); + sinon.stub(odata, 'getAllItems').resolves([{ GroupId: '00000000-0000-0000-0000-000000000000' }]); + + await command.action(logger, { options: { siteUrl: siteUrl, wait: true } }); + assert(loggerErrSpy.calledWith(chalk.yellow(`Option 'wait' is deprecated and will be removed in the next major release.`))); + + sinonUtil.restore(loggerErrSpy); + }); + it('fails validation if the url option is not a valid SharePoint site URL', async () => { const actual = await command.validate({ options: { siteUrl: 'foo' } }, commandInfo); assert.notStrictEqual(actual, true); }); it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/hr' } }, commandInfo); - assert(actual); + const actual = await command.validate({ options: { siteUrl: siteUrl } }, commandInfo); + assert.strictEqual(actual, true); }); - it(`restores deleted site collection from the tenant recycle bin, without waiting for completion`, async () => { - sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/SPOInternalUseOnly.Tenant/RestoreDeletedSite`) > -1) { - if (opts.headers && - JSON.stringify(opts.data) === JSON.stringify({ - siteUrl: 'https://contoso.sharepoint.com/sites/hr' - })) { - return "{\"HasTimedout\":false,\"IsComplete\":true,\"PollingInterval\":15000}"; - } + it(`restores deleted group from a deleted team site`, async () => { + const groupId = '4b3f5e3f-6e1f-4b1e-8b5f-0f5f5f5f5f5f'; + const groupRestoreUrl = `https://graph.microsoft.com/v1.0/directory/deletedItems/${groupId}/restore`; + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === siteRestoreUrl) { + return; + } + + if (opts.url === groupRestoreUrl) { + return; } throw 'Invalid request'; }); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/hr' } }); - }); - - it(`restores deleted site collection from the tenant recycle bin and waits for completion`, async () => { - const postRequestStub = sinon.stub(request, 'post'); - postRequestStub.onFirstCall().callsFake(async (opts) => { - if (opts.url === 'https://contoso-admin.sharepoint.com/_api/SPOInternalUseOnly.Tenant/RestoreDeletedSite') { - if (opts.headers && JSON.stringify(opts.data) === JSON.stringify({ siteUrl: 'https://contoso.sharepoint.com/sites/hr' })) { - return "{\"HasTimedout\":false,\"IsComplete\":false,\"PollingInterval\":100}"; - } + sinon.stub(odata, 'getAllItems').callsFake(async (url: string) => { + if (url === odataUrl) { + return [{ GroupId: groupId }]; } throw 'Invalid request'; }); - postRequestStub.onSecondCall().callsFake(async (opts) => { - if (opts.url === 'https://contoso-admin.sharepoint.com/_api/SPOInternalUseOnly.Tenant/RestoreDeletedSite') { - if (opts.headers && JSON.stringify(opts.data) === JSON.stringify({ siteUrl: 'https://contoso.sharepoint.com/sites/hr' })) { - return "{\"HasTimedout\":false,\"IsComplete\":false,\"PollingInterval\":100}"; - } + await command.action(logger, { options: { siteUrl: siteUrl, verbose: true } }); + assert.strictEqual(postStub.lastCall.args[0].url, groupRestoreUrl); + }); + + it('restores a deleted SharePoint site without group', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === siteRestoreUrl) { + return; } throw 'Invalid request'; }); - postRequestStub.onThirdCall().callsFake(async (opts) => { - if (opts.url === 'https://contoso-admin.sharepoint.com/_api/SPOInternalUseOnly.Tenant/RestoreDeletedSite') { - if (opts.headers && JSON.stringify(opts.data) === JSON.stringify({ siteUrl: 'https://contoso.sharepoint.com/sites/hr' })) { - return "{\"HasTimedout\":false,\"IsComplete\":true,\"PollingInterval\":100}"; - } + sinon.stub(odata, 'getAllItems').callsFake(async (url: string) => { + if (url === odataUrl) { + return [{ GroupId: '00000000-0000-0000-0000-000000000000' }]; } throw 'Invalid request'; }); - await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/hr', wait: true, verbose: true } }); - assert(loggerLogSpy.calledWith({ HasTimedout: false, IsComplete: true, PollingInterval: 100 })); + await command.action(logger, { options: { siteUrl: siteUrl, verbose: true } }); + assert(postStub.lastCall.args[0].url === siteRestoreUrl); }); it('handles error when the site to restore is not found', async () => { - sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/SPOInternalUseOnly.Tenant/RestoreDeletedSite`) > -1) { - if (opts.headers && - JSON.stringify(opts.data) === JSON.stringify({ - siteUrl: 'https://contoso.sharepoint.com/sites/hr' - })) { - throw "{\"odata.error\":{\"code\":\"-2147024809, System.ArgumentException\",\"message\":{\"lang\":\"en-US\",\"value\":\"Unable to find the deleted site: https://contoso.sharepoint.com/sites/hr.\"}}}"; + const error = { + error: { + 'odata.error': { + code: '-2147024809, System.ArgumentException', + message: { + lang: 'en-US', + value: `Unable to find the deleted site: ${siteUrl}` + } } } + }; - throw 'Invalid request'; - }); + sinon.stub(request, 'post').rejects(error); - await assert.rejects(command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/hr' } } as any), new CommandError("{\"odata.error\":{\"code\":\"-2147024809, System.ArgumentException\",\"message\":{\"lang\":\"en-US\",\"value\":\"Unable to find the deleted site: https://contoso.sharepoint.com/sites/hr.\"}}}")); + await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl, verbose: true } } as any), new CommandError(error.error['odata.error'].message.value)); }); }); diff --git a/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.ts b/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.ts index 3ed756f22a8..74e427322d8 100644 --- a/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.ts +++ b/src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.ts @@ -1,25 +1,21 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { odata } from '../../../../utils/odata.js'; import { spo } from '../../../../utils/spo.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import { setTimeout } from 'timers/promises'; interface CommandArgs { options: Options; } -interface Response { - HasTimedout: boolean, - IsComplete: boolean, - PollingInterval: number -} - interface Options extends GlobalOptions { siteUrl: string; - wait: boolean; + wait?: boolean; } class SpoTenantRecycleBinItemRestoreCommand extends SpoCommand { @@ -37,12 +33,13 @@ class SpoTenantRecycleBinItemRestoreCommand extends SpoCommand { this.#initTelemetry(); this.#initOptions(); this.#initValidators(); + this.#initTypes(); } #initTelemetry(): void { this.telemetry.push((args: CommandArgs) => { Object.assign(this.telemetryProperties, { - wait: args.options.wait + wait: !!args.options.wait }); }); } @@ -64,49 +61,70 @@ class SpoTenantRecycleBinItemRestoreCommand extends SpoCommand { ); } + #initTypes(): void { + this.types.string.push('siteUrl'); + this.types.boolean.push('wait'); + } + public async commandAction(logger: Logger, args: CommandArgs): Promise { + if (args.options.wait) { + await this.warn(logger, `Option 'wait' is deprecated and will be removed in the next major release.`); + } + try { + if (this.verbose) { + await logger.logToStderr(`Restoring site collection '${args.options.siteUrl}' from recycle bin.`); + } + + const siteUrl = urlUtil.removeTrailingSlashes(args.options.siteUrl); const adminUrl: string = await spo.getSpoAdminUrl(logger, this.debug); const requestOptions: CliRequestOptions = { - url: `${adminUrl}/_api/SPOInternalUseOnly.Tenant/RestoreDeletedSite`, + url: `${adminUrl}/_api/SPO.Tenant/RestoreDeletedSite`, headers: { accept: 'application/json;odata=nometadata', 'content-type': 'application/json;charset=utf-8' }, - data: { - siteUrl: args.options.siteUrl - } + data: { siteUrl }, + responseType: 'json' }; - const response: string = await request.post(requestOptions); - let responseContent: Response = JSON.parse(response); + await request.post(requestOptions); + + const groupId = await this.getSiteGroupId(adminUrl, siteUrl); - if (args.options.wait && !responseContent.IsComplete) { - responseContent = await this.waitUntilTenantRestoreFinished(responseContent.PollingInterval, requestOptions, logger); + if (groupId && groupId !== '00000000-0000-0000-0000-000000000000') { + if (this.verbose) { + await logger.logToStderr(`Restoring Microsoft 365 group with ID '${groupId}' from recycle bin.`); + } + + const restoreOptions: CliRequestOptions = { + url: `https://graph.microsoft.com/v1.0/directory/deletedItems/${groupId}/restore`, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json' + }, + responseType: 'json' + }; + + await request.post(restoreOptions); } - await logger.log(responseContent); + // Here, we return a fixed response because this new API endpoint doesn't return a response while the previous API did. + // This has to be removed in the next major release. + await logger.log({ + HasTimedout: false, + IsComplete: !!args.options.wait, + PollingInterval: 15000 + }); } catch (err: any) { this.handleRejectedODataJsonPromise(err); } } - private async waitUntilTenantRestoreFinished(pollingInterval: number, requestOptions: CliRequestOptions, logger: Logger): Promise { - if (this.verbose) { - await logger.logToStderr(`Site collection still restoring. Retrying in ${pollingInterval / 1000} seconds...`); - } - - await setTimeout(pollingInterval); - - const response: string = await request.post(requestOptions); - const responseContent: Response = JSON.parse(response); - - if (responseContent.IsComplete) { - return responseContent; - } - - return await this.waitUntilTenantRestoreFinished(responseContent.PollingInterval, requestOptions, logger); + private async getSiteGroupId(adminUrl: string, url: string): Promise { + const sites = await odata.getAllItems<{ GroupId?: string }>(`${adminUrl}/_api/web/lists/GetByTitle('DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECOLLECTIONS')/items?$filter=SiteUrl eq '${formatting.encodeQueryParameter(url)}'&$select=GroupId`); + return sites[0].GroupId; } }