Skip to content

Commit

Permalink
Adds command m365 spo site unarchive
Browse files Browse the repository at this point in the history
  • Loading branch information
MathijsVerbeeck committed May 9, 2024
1 parent 5ca5055 commit c2b2a8e
Show file tree
Hide file tree
Showing 5 changed files with 320 additions and 0 deletions.
61 changes: 61 additions & 0 deletions docs/docs/cmd/spo/site/site-unarchive.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Global from '/docs/cmd/_global.mdx';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

# spo site unarchive

Unarchives a site collection

## Usage

```sh
m365 spo site unarchive [options]
```

## Options

```md definition-list
`-u, --url <url>`
: URL of the site collection.

`-f, --force`
: Don't prompt for confirmation.
```

<Global />

## Remarks

:::info

To use this command you must be a Global or SharePoint administrator.

:::

:::warning

If a site remains archived for more than seven days, the reactivation fee will be calculated based on the entire storage capacity of the site.

:::

## Examples

Unarchive a specific site collection.

```sh
m365 spo site unarchive --url "https://contoso.sharepoint.com/sites/Marketing"
```

Unarchive a specific site collection without confirmation prompt.

```sh
m365 spo site unarchive --url "https://contoso.sharepoint.com/sites/Marketing" --force
```

## Response

The command won't return a response on success.

## More information

Pricing model for Microsoft 365 Archive: [https://learn.microsoft.com/en-us/microsoft-365/archive/archive-pricing?view=o365-worldwide](https://learn.microsoft.com/en-us/microsoft-365/archive/archive-pricing?view=o365-worldwide)
5 changes: 5 additions & 0 deletions docs/src/config/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3290,6 +3290,11 @@ const sidebars: SidebarsConfig = {
label: 'site set',
id: 'cmd/spo/site/site-set'
},
{
type: 'doc',
label: 'site remove',
id: 'cmd/spo/site/site-unarchive'
},
{
type: 'doc',
label: 'site appcatalog add',
Expand Down
1 change: 1 addition & 0 deletions src/m365/spo/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export default {
SITE_RENAME: `${prefix} site rename`,
SITE_SET: `${prefix} site set`,
SITE_CHROME_SET: `${prefix} site chrome set`,
SITE_UNARCHIVE: `${prefix} site unarchive`,
SITEDESIGN_ADD: `${prefix} sitedesign add`,
SITEDESIGN_APPLY: `${prefix} sitedesign apply`,
SITEDESIGN_GET: `${prefix} sitedesign get`,
Expand Down
149 changes: 149 additions & 0 deletions src/m365/spo/commands/site/site-unarchive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import assert from 'assert';
import sinon from 'sinon';
import auth from '../../../../Auth.js';
import { cli } from '../../../../cli/cli.js';
import { CommandInfo } from '../../../../cli/CommandInfo.js';
import { Logger } from '../../../../cli/Logger.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 commands from '../../commands.js';
import command from './site-unarchive.js';
import request from '../../../../request.js';
import { spo } from '../../../../utils/spo.js';
import { CommandError } from '../../../../Command.js';

describe(commands.SITE_UNARCHIVE, () => {
const url = 'https://contoso.sharepoint.com/sites/project-x';
const adminUrl = 'https://contoso-admin.sharepoint.com';
const response = '[{"SchemaVersion":"15.0.0.0","LibraryVersion":"16.0.24817.12008","ErrorInfo":null,"TraceCorrelationId":"ab1127a1-5044-8000-b17c-4aafdd265386"},2,{"IsNull":false},4,{"IsNull":false}]';

let log: any[];
let logger: Logger;
let commandInfo: CommandInfo;
let promptIssued: boolean = false;

before(() => {
sinon.stub(auth, 'restoreAuth').resolves();
sinon.stub(telemetry, 'trackEvent').returns();
sinon.stub(pid, 'getProcessName').returns('');
sinon.stub(session, 'getId').returns('');
sinon.stub(spo, 'getSpoAdminUrl').resolves(adminUrl);
sinon.stub(spo, 'getRequestDigest').resolves({
FormDigestValue: 'abc',
FormDigestTimeoutSeconds: 1800,
FormDigestExpiresAt: new Date(),
WebFullUrl: 'https://contoso.sharepoint.com'
});
auth.connection.active = true;
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);
}
};

sinon.stub(cli, 'promptForConfirmation').callsFake(async () => {
promptIssued = true;
return false;
});
promptIssued = false;
});

afterEach(() => {
sinonUtil.restore([
cli.promptForConfirmation,
request.post
]);
});

after(() => {
sinon.restore();
auth.connection.active = false;
});

it('has correct name', () => {
assert.strictEqual(command.name, commands.SITE_UNARCHIVE);
});

it('has a description', () => {
assert.notStrictEqual(command.description, null);
});

it('fails validation if url is not a valid SharePoint URL', async () => {
const actual = await command.validate({ options: { url: 'invalid' } }, commandInfo);
assert.notStrictEqual(actual, true);
});

it('passes validation when a valid url is specified', async () => {
const actual = await command.validate({ options: { url: url } }, commandInfo);
assert.strictEqual(actual, true);
});

it('aborts unarchiving site when prompt not confirmed', async () => {
const postStub = sinon.stub(request, 'post');

await command.action(logger, { options: { url: url } });
assert(postStub.notCalled);
});

it('prompts before unarchiving the site when --force option is not passed', async () => {
await command.action(logger, { options: { url: url } });
assert(promptIssued);
});

it('unarchives the site when prompt confirmed', async () => {
sinonUtil.restore(cli.promptForConfirmation);
sinon.stub(cli, 'promptForConfirmation').resolves(true);

const postStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `${adminUrl}/_vti_bin/client.svc/ProcessQuery`) {
return response;
}

throw 'Invalid request';
});

await command.action(logger, { options: { url: url, verbose: true } });
assert(postStub.calledOnce);
});

it('unarchives the site without prompting for confirmation when --force option specified', async () => {
const postStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `${adminUrl}/_vti_bin/client.svc/ProcessQuery`) {
return response;
}

throw 'Invalid request';
});

await command.action(logger, { options: { url: url, force: true, verbose: true } });
assert(postStub.called);
});

it('correctly handles error when unarchiving site that does not exist', async () => {
const errorMessage = 'File Not Found.';

sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `${adminUrl}/_vti_bin/client.svc/ProcessQuery`) {
return `[{"SchemaVersion":"15.0.0.0","LibraryVersion":"16.0.24817.12008","ErrorInfo":{"ErrorMessage":"${errorMessage}","ErrorValue":null,"TraceCorrelationId":"731127a1-9041-8000-99fd-2865e0d78b49","ErrorCode":-2147024894,"ErrorTypeName":"System.IO.FileNotFoundException"},"TraceCorrelationId":"731127a1-9041-8000-99fd-2865e0d78b49"}]`;
}

throw 'Invalid request';
});

await assert.rejects(command.action(logger, { options: { url: url, force: true, verbose: true } }),
new CommandError(errorMessage));
});
});
104 changes: 104 additions & 0 deletions src/m365/spo/commands/site/site-unarchive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { cli } from '../../../../cli/cli.js';
import { Logger } from '../../../../cli/Logger.js';
import config from '../../../../config.js';
import GlobalOptions from '../../../../GlobalOptions.js';
import request, { CliRequestOptions } from '../../../../request.js';
import { ClientSvcResponse, ClientSvcResponseContents, spo } from '../../../../utils/spo.js';
import { validation } from '../../../../utils/validation.js';
import SpoCommand from '../../../base/SpoCommand.js';
import commands from '../../commands.js';

interface CommandArgs {
options: Options;
}

interface Options extends GlobalOptions {
url: string;
force?: boolean;
}

class SpoSiteUnarchiveCommand extends SpoCommand {
public get name(): string {
return commands.SITE_UNARCHIVE;
}

public get description(): string {
return 'Unarchives a site collection';
}

constructor() {
super();

this.#initOptions();
this.#initValidators();
this.#initTypes();
}

#initOptions(): void {
this.options.unshift(
{
option: '-u, --url <url>'
},
{
option: '-f, --force'
}
);
}

#initValidators(): void {
this.validators.push(
async (args) => validation.isValidSharePointUrl(args.options.url)
);
}

#initTypes(): void {
this.types.string.push('url');
}

public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {

if (args.options.force) {
await this.unarchiveSite(logger, args.options.url);
}
else {
const result = await cli.promptForConfirmation({ message: `Are you sure you want to unarchive the site ${args.options.url}?` });

if (result) {
await this.unarchiveSite(logger, args.options.url);
}
}
}

private async unarchiveSite(logger: Logger, url: string): Promise<void> {
if (this.verbose) {
await logger.logToStderr(`Unarchiving site ${url}...`);
}

try {
const adminCenterUrl = await spo.getSpoAdminUrl(logger, this.debug);
const requestDigest = await spo.getRequestDigest(adminCenterUrl);
const requestData = `<Request AddExpandoFieldTypeSuffix="true" SchemaVersion="15.0.0.0" LibraryVersion="16.0.0.0" ApplicationName="${config.applicationName}" xmlns="http://schemas.microsoft.com/sharepoint/clientquery/2009"><Actions><ObjectPath Id="2" ObjectPathId="1" /><ObjectPath Id="4" ObjectPathId="3" /></Actions><ObjectPaths><Constructor Id="1" TypeId="{268004ae-ef6b-4e9b-8425-127220d84719}" /><Method Id="3" ParentId="1" Name="UnarchiveSiteByUrl"><Parameters><Parameter Type="String">${url}</Parameter></Parameters></Method></ObjectPaths></Request>`;

const requestOptions: CliRequestOptions = {
url: `${adminCenterUrl}/_vti_bin/client.svc/ProcessQuery`,
headers: {
'X-RequestDigest': requestDigest.FormDigestValue
},
data: requestData
};

const response: string = await request.post(requestOptions);
const json: ClientSvcResponse = JSON.parse(response);
const responseContent: ClientSvcResponseContents = json[0];

if (responseContent.ErrorInfo) {
throw responseContent.ErrorInfo.ErrorMessage;
}
}
catch (err: any) {
this.handleRejectedPromise(err);
}
}
}

export default new SpoSiteUnarchiveCommand();

0 comments on commit c2b2a8e

Please sign in to comment.