Skip to content
Merged
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
85 changes: 85 additions & 0 deletions docs/docs/cmd/outlook/event/event-cancel.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Global from '../../_global.mdx';
import TabItem from '@theme/TabItem';
import Tabs from '@theme/Tabs';

# outlook event cancel

Cancels a calendar event

## Usage

```sh
m365 outlook event cancel [options]
```

## Options

```md definition-list
`-i, --id <id>`
: ID of the event.

`--userId [userId]`
: ID of the user that owns the calendar. Specify either `userId` or `userName`, but not both. This option is required when using application permissions.

`--userName [userName]`
: UPN of the user that owns the calendar. Specify either `userId` or `userName`, but not both. This option is required when using application permissions.

`--comment [comment]`
: A comment about the cancellation sent to all the attendees.

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

<Global />

## Permissions

<Tabs>
<TabItem value="Delegated">

| Resource | Permissions |
|-----------------|---------------------|
| Microsoft Graph | Calendars.ReadWrite |

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

| Resource | Permissions |
|-----------------|---------------------|
| Microsoft Graph | Calendars.ReadWrite |

</TabItem>
</Tabs>

## Remarks

:::info

This action is only available to the organizer of the event.

:::

## Examples

Cancel a calendar event from the current logged-in user without a comment

```sh
m365 outlook event cancel --id AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA=
```

Cancel a calendar event from a specific user with a comment

```sh
m365 outlook event cancel --userName "john.doe@contoso.com" --comment "Cancelling for this week due to all hands" --id AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA=
```

Cancel a calendar event from a specific user specified by user ID

```sh
m365 outlook event cancel --userId 6799fd1a-723b-4eb7-8e52-41ae530274ca --id AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA=
```

## 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 @@ -1332,6 +1332,11 @@ const sidebars: SidebarsConfig = {
},
{
event: [
{
type: 'doc',
label: 'event cancel',
id: 'cmd/outlook/event/event-cancel'
},
{
type: 'doc',
label: 'event list',
Expand Down
1 change: 1 addition & 0 deletions src/m365/outlook/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default {
CALENDAR_GET: `${prefix} calendar get`,
CALENDAR_REMOVE: `${prefix} calendar remove`,
CALENDARGROUP_LIST: `${prefix} calendargroup list`,
EVENT_CANCEL: `${prefix} event cancel`,
EVENT_LIST: `${prefix} event list`,
MAIL_SEARCHFOLDER_ADD: `${prefix} mail searchfolder add`,
MAIL_SEND: `${prefix} mail send`,
Expand Down
290 changes: 290 additions & 0 deletions src/m365/outlook/commands/event/event-cancel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import assert from 'assert';
import sinon from 'sinon';
import auth from '../../../../Auth.js';
import commands from '../../commands.js';
import request from '../../../../request.js';
import { telemetry } from '../../../../telemetry.js';
import { Logger } from '../../../../cli/Logger.js';
import { CommandError } from '../../../../Command.js';
import { pid } from '../../../../utils/pid.js';
import { session } from '../../../../utils/session.js';
import { sinonUtil } from '../../../../utils/sinonUtil.js';
import { cli } from '../../../../cli/cli.js';
import { accessToken } from '../../../../utils/accessToken.js';
import command, { options } from './event-cancel.js';
import { CommandInfo } from '../../../../cli/CommandInfo.js';

describe(commands.EVENT_CANCEL, () => {
const eventId = 'AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA=';
const userId = '6799fd1a-723b-4eb7-8e52-41ae530274ca';
const userPrincipalName = 'john.doe@contoso.com';
const comment = 'Cancelling for this week due to all hands';

let log: string[];
let logger: Logger;
let promptIssued: boolean;
let commandInfo: CommandInfo;
let commandOptionsSchema: typeof options;

before(() => {
sinon.stub(auth, 'restoreAuth').resolves();
sinon.stub(telemetry, 'trackEvent').resolves();
sinon.stub(pid, 'getProcessName').returns('');
sinon.stub(session, 'getId').returns('');
auth.connection.active = true;
auth.connection.accessTokens[auth.defaultResource] = {
expiresOn: 'abc',
accessToken: 'abc'
};
commandInfo = cli.getCommandInfo(command);
commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options;
});

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;
});
sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false);
promptIssued = false;
});

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

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

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

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

it('passes validation when userId is a valid GUID', () => {
const actual = commandOptionsSchema.safeParse({ id: eventId, userId: userId });
assert.strictEqual(actual.success, true);
});

it('passes validation when userName is a valid UPN', () => {
const actual = commandOptionsSchema.safeParse({ id: eventId, userName: userPrincipalName });
assert.strictEqual(actual.success, true);
});

it('passes validation when all required parameters are valid', () => {
const actual = commandOptionsSchema.safeParse({ id: eventId });
assert.strictEqual(actual.success, true);
});

it('fails validation if userId is not a valid GUID', () => {
const actual = commandOptionsSchema.safeParse({ id: eventId, userId: 'invalid' });
assert.notStrictEqual(actual.success, true);
});

it('fails validation if userName is not a valid UPN', () => {
const actual = commandOptionsSchema.safeParse({ id: eventId, userName: 'invalid' });
assert.notStrictEqual(actual.success, true);
});

it('cancels a specific event using delegated permissions without prompting for confirmation', async () => {
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}/cancel`) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { id: eventId, force: true, verbose: true } });
assert(postRequestStub.calledOnce);
});

it('cancels a specific event with a comment using delegated permissions without prompting for confirmation', async () => {
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}/cancel` && opts.data.comment === comment) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { id: eventId, comment: comment, force: true } });
assert(postRequestStub.calledOnce);
});

it('cancels a specific event using delegated permissions while prompting for confirmation', async () => {
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}/cancel`) {
return;
}

throw 'Invalid request';
});

sinonUtil.restore(cli.promptForConfirmation);
sinon.stub(cli, 'promptForConfirmation').resolves(true);

await command.action(logger, { options: { id: eventId, verbose: true } });
assert(postRequestStub.calledOnce);
});

it('cancels a specific event using delegated permissions from a calendar specified by userId matching the current user without prompting for confirmation', async () => {
sinon.stub(accessToken, 'getUserIdFromAccessToken').returns(userId);
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events/${eventId}/cancel`) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { id: eventId, userId: userId, force: true, verbose: true } });
assert(postRequestStub.calledOnce);
});

it('cancels a specific event using delegated permissions from a calendar specified by userName matching the current user without prompting for confirmation', async () => {
sinon.stub(accessToken, 'getUserNameFromAccessToken').returns(userPrincipalName);
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}/cancel`) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true, verbose: true } });
assert(postRequestStub.calledOnce);
});

it('throws an error when userId does not match current user when using delegated permissions', async () => {
sinon.stub(accessToken, 'getUserIdFromAccessToken').returns('00000000-0000-0000-0000-000000000000');

await assert.rejects(command.action(logger, { options: { id: eventId, userId: userId, force: true } }),
new CommandError(`You can only cancel your own events when using delegated permissions. The specified userId '${userId}' does not match the current user '00000000-0000-0000-0000-000000000000'.`));
});

it('throws an error when userName does not match current user when using delegated permissions', async () => {
sinon.stub(accessToken, 'getUserNameFromAccessToken').returns('other.user@contoso.com');

await assert.rejects(command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true } }),
new CommandError(`You can only cancel your own events when using delegated permissions. The specified userName '${userPrincipalName}' does not match the current user 'other.user@contoso.com'.`));
});

it('cancels a specific event using application permissions from a calendar specified by userId without prompting for confirmation', async () => {
sinonUtil.restore([accessToken.isAppOnlyAccessToken]);
sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true);
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events/${eventId}/cancel`) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { id: eventId, userId: userId, force: true, verbose: true } });
assert(postRequestStub.calledOnce);
});

it('cancels a specific event using application permissions from a calendar specified by userName without prompting for confirmation', async () => {
sinonUtil.restore([accessToken.isAppOnlyAccessToken]);
sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true);
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}/cancel`) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true, verbose: true } });
assert(postRequestStub.calledOnce);
});

it('throws an error when both userId and userName are not defined when cancelling an event using application permissions', async () => {
sinonUtil.restore([accessToken.isAppOnlyAccessToken]);
sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true);

await assert.rejects(command.action(logger, { options: { id: eventId } }),
new CommandError(`The option 'userId' or 'userName' is required when cancelling an event using application permissions.`));
});

it('fails validation when both userId and userName are specified', () => {
const actual = commandOptionsSchema.safeParse({ id: eventId, userId: userId, userName: userPrincipalName });
assert.strictEqual(actual.success, false);
});

it('succeeds when userName matches current user case-insensitively using delegated permissions', async () => {
sinon.stub(accessToken, 'getUserNameFromAccessToken').returns('John.Doe@Contoso.com');
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}/cancel`) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true } });
assert(postRequestStub.calledOnce);
});

it('correctly handles API errors', async () => {
const error = {
error: {
code: 'Request_ResourceNotFound',
message: `The specified object was not found in the store., The process failed to get the correct properties.`,
innerError: {
date: '2023-10-27T12:24:36',
'request-id': 'b7dee9ee-d85b-4e7a-8686-74852cbfd85b',
'client-request-id': 'b7dee9ee-d85b-4e7a-8686-74852cbfd85b'
}
}
};
sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}/cancel`) {
throw error;
}

throw 'Invalid request';
});

await assert.rejects(command.action(logger, { options: { id: eventId, force: true } }),
new CommandError(error.error.message));
});

it('prompts before cancelling the event when confirm option not passed', async () => {
await command.action(logger, { options: { id: eventId } });

assert(promptIssued);
});

it('aborts cancelling the event when prompt not confirmed', async () => {
const postSpy = sinon.stub(request, 'post').resolves();

await command.action(logger, { options: { id: eventId } });
assert(postSpy.notCalled);
});
});
Loading