Skip to content

New command: spo file version keep. Closes #6774 #6826

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .devproxy/api-specs/sharepoint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,47 @@ paths:
responses:
200:
description: OK
/_api/web/GetFileById({fileId})/versions/:
get:
parameters:
- name: fileId
in: path
required: true
schema:
type: string
example: "'19bbfec4-4425-4660-95cb-da1887baa7b9'"
security:
- delegated:
- AllSites.Read
- AllSites.Write
- AllSites.Manage
- AllSites.FullControl
responses:
200:
description: OK
/_api/web/GetFileById({fileId})/versions({versionId})/SetExpirationDate():
post:
parameters:
- name: fileId
in: path
required: true
schema:
type: string
example: "'19bbfec4-4425-4660-95cb-da1887baa7b9'"
- name: versionId
in: path
required: true
schema:
type: integer
example: 1030
security:
- delegated:
- AllSites.Write
- AllSites.Manage
- AllSites.FullControl
responses:
200:
description: OK
/_api/web/GetFolderByServerRelativePath(DecodedUrl={folderPath}):
get:
parameters:
Expand Down
68 changes: 68 additions & 0 deletions docs/docs/cmd/spo/file/file-version-keep.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Global from '/docs/cmd/_global.mdx';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

# spo file version keep

Ensure that a specific file version will never expire.

## Usage

```sh
m365 spo file version keep [options]
```

## Options

```md definition-list
`-u, --webUrl <webUrl>`
: The URL of the site where the file is located.

`--fileUrl [fileUrl]`
: The server- or site-relative decoded URL. Specify either `fileUrl` or `fileId` but not both.

`-i, --fileId [fileId]`
: The UniqueId (GUID) of the file. Specify either `fileUrl` or `fileId` but not both.

`--label <label>`
: Label of the version.
```

<Global />

## Permissions

<Tabs>
<TabItem value="Delegated" label="Delegated">

| Resource | Permissions |
|------------|--------------------------------------------------|
| SharePoint | AllSites.Write, AllSites.Manage, AllSites.FullControl |

</TabItem>
<TabItem value="Application" label="Application">

| Resource | Permissions |
|------------|-----------------------------------------------------|
| SharePoint | Sites.ReadWrite.All, Sites.Manage.All, Sites.FullControl.All |

</TabItem>
</Tabs>

## Examples

Mark a file version as never expiring by file URL.

```sh
m365 spo file version keep --webUrl "https://contoso.sharepoint.com/sites/marketing" --fileUrl "/sites/marketing/Documents/report.docx" --label "6.0"
```

Mark a file version as never expiring by file ID.

```sh
m365 spo file version keep --webUrl "https://contoso.sharepoint.com/sites/marketing" --fileId "12345678-90ab-cdef-1234-567890abcdef" --label "6.0"
```

## Response

The command won't return a response on success.
5 changes: 5 additions & 0 deletions docs/src/config/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2758,6 +2758,11 @@ const sidebars: SidebarsConfig = {
label: 'file version get',
id: 'cmd/spo/file/file-version-get'
},
{
type: 'doc',
label: 'file version keep',
id: 'cmd/spo/file/file-version-keep'
},
{
type: 'doc',
label: 'file version list',
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 @@ -84,6 +84,7 @@ export default {
FILE_SHARINGLINK_SET: `${prefix} file sharinglink set`,
FILE_VERSION_CLEAR: `${prefix} file version clear`,
FILE_VERSION_GET: `${prefix} file version get`,
FILE_VERSION_KEEP: `${prefix} file version keep`,
FILE_VERSION_LIST: `${prefix} file version list`,
FILE_VERSION_REMOVE: `${prefix} file version remove`,
FILE_VERSION_RESTORE: `${prefix} file version restore`,
Expand Down
192 changes: 192 additions & 0 deletions src/m365/spo/commands/file/file-version-keep.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 { CommandError } from '../../../../Command.js';
import request from '../../../../request.js';
import { telemetry } from '../../../../telemetry.js';
import { formatting } from '../../../../utils/formatting.js';
import { pid } from '../../../../utils/pid.js';
import { session } from '../../../../utils/session.js';
import { sinonUtil } from '../../../../utils/sinonUtil.js';
import { z } from 'zod';
import commands from '../../commands.js';
import command from './file-version-keep.js';

describe(commands.FILE_VERSION_KEEP, () => {
let log: any[];
let logger: Logger;
let commandInfo: CommandInfo;
let commandOptionsSchema: z.ZodTypeAny;
const validWebUrl = "https://contoso.sharepoint.com";
const validFileUrl = "/Shared Documents/Document.docx";
const validFileId = "7a9b8bb6-d5c4-4de9-ab76-5210a7879e89";
const validLabel = "1.0";

before(() => {
sinon.stub(auth, 'restoreAuth').resolves();
sinon.stub(telemetry, 'trackEvent').resolves();
sinon.stub(pid, 'getProcessName').returns('');
sinon.stub(session, 'getId').returns('');
commandInfo = cli.getCommandInfo(command);
commandOptionsSchema = commandInfo.command.getSchemaToParse()!;
auth.connection.active = true;
});

beforeEach(() => {
log = [];
logger = {
log: async (msg: string) => {
log.push(msg);
},
logRaw: async (msg: string) => {
log.push(msg);
},
logToStderr: async (msg: string) => {
log.push(msg);
}
};
});

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

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

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

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

it('fails validation if webUrl is not a valid URL', async () => {
const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', label: validLabel, fileUrl: validFileUrl });
assert.strictEqual(actual.success, false);
});

it('fails validation if fileId is not a valid GUID', async () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, fileId: 'invalid' });
assert.strictEqual(actual.success, false);
});

it('fails validation if fileUrl and fileId are specified', async () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, fileUrl: validFileUrl, fileId: validFileId });
assert.strictEqual(actual.success, false);
});

it('fails validation if label is not specified', async () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, fileUrl: validFileUrl });
assert.strictEqual(actual.success, false);
});

it('passes validation if fileUrl is specified', async () => {
const actual = await command.validate({ options: { webUrl: validWebUrl, label: validLabel, fileUrl: validFileUrl } }, commandInfo);
assert.strictEqual(actual, true);
});

it('passes validation if fileId is specified', async () => {
const actual = await command.validate({ options: { webUrl: validWebUrl, label: validLabel, fileId: validFileId } }, commandInfo);
assert.strictEqual(actual, true);
});

it('ensures that a specific file version will never expire (fileUrl)', async () => {
sinon.stub(request, 'get').callsFake(async (opts) => {
if (opts.url === `${validWebUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(validFileUrl)}')/versions/?$filter=VersionLabel eq '${validLabel}'&$select=Id`) {
return {
value: [
{
ID: 1,
VersionLabel: validLabel
}
]
};
}

throw 'Invalid request';
});

const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `${validWebUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(validFileUrl)}')/versions(1)/SetExpirationDate()`) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { webUrl: validWebUrl, fileUrl: validFileUrl, label: validLabel, verbose: true } });
assert.strictEqual(postStub.lastCall.args[0].url, `${validWebUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(validFileUrl)}')/versions(1)/SetExpirationDate()`);
});

it('ensures that a specific file version will never expire (fileId)', async () => {
sinon.stub(request, 'get').callsFake(async (opts) => {
if (opts.url === `${validWebUrl}/_api/web/GetFileById('${validFileId}')/versions/?$filter=VersionLabel eq '${validLabel}'&$select=Id`) {
return {
value: [
{
ID: 1,
VersionLabel: validLabel
}
]
};
}

throw 'Invalid request';
});

const postStub: sinon.SinonStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `${validWebUrl}/_api/web/GetFileById('${validFileId}')/versions(1)/SetExpirationDate()`) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { webUrl: validWebUrl, fileId: validFileId, label: validLabel, verbose: true } });
assert.strictEqual(postStub.lastCall.args[0].url, `${validWebUrl}/_api/web/GetFileById('${validFileId}')/versions(1)/SetExpirationDate()`);
});

it('correctly handles error when the specified version does not exist', async () => {
sinon.stub(request, 'get').callsFake(async (opts) => {
if (opts.url === `${validWebUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(validFileUrl)}')/versions/?$filter=VersionLabel eq '${validLabel}'&$select=Id`) {
return { value: [] };
}

throw 'Invalid request';
});
await assert.rejects(command.action(logger, { options: { webUrl: validWebUrl, fileUrl: validFileUrl, label: validLabel } }),
new CommandError(`Version with label '${validLabel}' not found.`));
});

it('correctly handles API OData error', async () => {
sinon.stub(request, 'get').callsFake(async (opts) => {
if (opts.url === `${validWebUrl}/_api/web/GetFileById('${validFileId}')/versions/?$filter=VersionLabel eq '${validLabel}'&$select=Id`) {
throw {
error: {
'odata.error': {
code: '-1, Microsoft.SharePoint.Client.InvalidOperationException',
message: {
value: 'Invalid version request'
}
}
}
};
}

throw 'Invalid request';
});

await assert.rejects(command.action(logger, { options: { webUrl: validWebUrl, fileId: validFileId, label: validLabel } }),
new CommandError('Invalid version request'));
});
});
Loading
Loading