Skip to content

Commit

Permalink
Fixes 'spo tenant recyclebinitem restore' group restoration. Closes #…
Browse files Browse the repository at this point in the history
  • Loading branch information
MathijsVerbeeck authored and milanholemans committed May 24, 2024
1 parent 62874a2 commit 7530ed6
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 91 deletions.
14 changes: 3 additions & 11 deletions docs/docs/cmd/spo/tenant/tenant-recyclebinitem-restore.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,19 @@ m365 spo tenant recyclebinitem restore [options]

```md definition-list
`-u, --siteUrl <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.
```

<Global />

## 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.

:::

Expand All @@ -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

<Tabs>
Expand Down
107 changes: 60 additions & 47 deletions src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
]);
});

Expand All @@ -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));
});
});
84 changes: 51 additions & 33 deletions src/m365/spo/commands/tenant/tenant-recyclebinitem-restore.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
});
});
}
Expand All @@ -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<void> {
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<any> {
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<string | undefined> {
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;
}
}

Expand Down

0 comments on commit 7530ed6

Please sign in to comment.