Skip to content

Commit 2b79658

Browse files
Adds command outlook event cancel. Closes #7105
1 parent 9aa5f29 commit 2b79658

5 files changed

Lines changed: 506 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import Global from '../../_global.mdx';
2+
import TabItem from '@theme/TabItem';
3+
import Tabs from '@theme/Tabs';
4+
5+
# outlook event cancel
6+
7+
Cancels a calendar event
8+
9+
## Usage
10+
11+
```sh
12+
m365 outlook event cancel [options]
13+
```
14+
15+
## Options
16+
17+
```md definition-list
18+
`-i, --id <id>`
19+
: ID of the event.
20+
21+
`--userId [userId]`
22+
: ID of the user that owns the calendar. Specify either `userId` or `userName`, but not both. This option is required when using application permissions.
23+
24+
`--userName [userName]`
25+
: UPN of the user that owns the calendar. Specify either `userId` or `userName`, but not both. This option is required when using application permissions.
26+
27+
`--comment [comment]`
28+
: A comment about the cancellation sent to all the attendees.
29+
30+
`-f, --force`
31+
: Don't prompt for confirmation.
32+
```
33+
34+
<Global />
35+
36+
## Permissions
37+
38+
<Tabs>
39+
<TabItem value="Delegated">
40+
41+
| Resource | Permissions |
42+
|-----------------|---------------------|
43+
| Microsoft Graph | Calendars.ReadWrite |
44+
45+
</TabItem>
46+
<TabItem value="Application">
47+
48+
| Resource | Permissions |
49+
|-----------------|---------------------|
50+
| Microsoft Graph | Calendars.ReadWrite |
51+
52+
</TabItem>
53+
</Tabs>
54+
55+
## Remarks
56+
57+
:::info
58+
59+
This action is only available to the organizer of the event.
60+
61+
:::
62+
63+
## Examples
64+
65+
Cancel a calendar event from the current logged-in user without a comment
66+
67+
```sh
68+
m365 outlook event cancel --id AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA=
69+
```
70+
71+
Cancel a calendar event from a specific user with a comment
72+
73+
```sh
74+
m365 outlook event cancel --userName "john.doe@contoso.com" --comment "Cancelling for this week due to all hands" --id AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA=
75+
```
76+
77+
Cancel a calendar event from a specific user specified by user ID
78+
79+
```sh
80+
m365 outlook event cancel --userId 6799fd1a-723b-4eb7-8e52-41ae530274ca --id AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA=
81+
```
82+
83+
## Response
84+
85+
The command won't return a response on success.

docs/src/config/sidebars.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,6 +1332,11 @@ const sidebars: SidebarsConfig = {
13321332
},
13331333
{
13341334
event: [
1335+
{
1336+
type: 'doc',
1337+
label: 'event cancel',
1338+
id: 'cmd/outlook/event/event-cancel'
1339+
},
13351340
{
13361341
type: 'doc',
13371342
label: 'event list',

src/m365/outlook/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default {
55
CALENDAR_GET: `${prefix} calendar get`,
66
CALENDAR_REMOVE: `${prefix} calendar remove`,
77
CALENDARGROUP_LIST: `${prefix} calendargroup list`,
8+
EVENT_CANCEL: `${prefix} event cancel`,
89
EVENT_LIST: `${prefix} event list`,
910
MAIL_SEARCHFOLDER_ADD: `${prefix} mail searchfolder add`,
1011
MAIL_SEND: `${prefix} mail send`,
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import assert from 'assert';
2+
import sinon from 'sinon';
3+
import auth from '../../../../Auth.js';
4+
import commands from '../../commands.js';
5+
import request from '../../../../request.js';
6+
import { telemetry } from '../../../../telemetry.js';
7+
import { Logger } from '../../../../cli/Logger.js';
8+
import { CommandError } from '../../../../Command.js';
9+
import { pid } from '../../../../utils/pid.js';
10+
import { session } from '../../../../utils/session.js';
11+
import { sinonUtil } from '../../../../utils/sinonUtil.js';
12+
import { cli } from '../../../../cli/cli.js';
13+
import { accessToken } from '../../../../utils/accessToken.js';
14+
import command, { options } from './event-cancel.js';
15+
import { CommandInfo } from '../../../../cli/CommandInfo.js';
16+
17+
describe(commands.EVENT_CANCEL, () => {
18+
const eventId = 'AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA=';
19+
const userId = '6799fd1a-723b-4eb7-8e52-41ae530274ca';
20+
const userPrincipalName = 'john.doe@contoso.com';
21+
const comment = 'Cancelling for this week due to all hands';
22+
23+
let log: string[];
24+
let logger: Logger;
25+
let promptIssued: boolean;
26+
let commandInfo: CommandInfo;
27+
let commandOptionsSchema: typeof options;
28+
29+
before(() => {
30+
sinon.stub(auth, 'restoreAuth').resolves();
31+
sinon.stub(telemetry, 'trackEvent').resolves();
32+
sinon.stub(pid, 'getProcessName').returns('');
33+
sinon.stub(session, 'getId').returns('');
34+
auth.connection.active = true;
35+
auth.connection.accessTokens[auth.defaultResource] = {
36+
expiresOn: 'abc',
37+
accessToken: 'abc'
38+
};
39+
commandInfo = cli.getCommandInfo(command);
40+
commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options;
41+
});
42+
43+
beforeEach(() => {
44+
log = [];
45+
logger = {
46+
log: async (msg: string) => {
47+
log.push(msg);
48+
},
49+
logRaw: async (msg: string) => {
50+
log.push(msg);
51+
},
52+
logToStderr: async (msg: string) => {
53+
log.push(msg);
54+
}
55+
};
56+
sinon.stub(cli, 'promptForConfirmation').callsFake(async () => {
57+
promptIssued = true;
58+
return false;
59+
});
60+
sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false);
61+
promptIssued = false;
62+
});
63+
64+
afterEach(() => {
65+
sinonUtil.restore([
66+
request.post,
67+
accessToken.isAppOnlyAccessToken,
68+
accessToken.getUserIdFromAccessToken,
69+
accessToken.getUserNameFromAccessToken,
70+
cli.promptForConfirmation
71+
]);
72+
});
73+
74+
after(() => {
75+
sinon.restore();
76+
auth.connection.active = false;
77+
auth.connection.accessTokens = {};
78+
});
79+
80+
it('has correct name', () => {
81+
assert.strictEqual(command.name, commands.EVENT_CANCEL);
82+
});
83+
84+
it('has a description', () => {
85+
assert.notStrictEqual(command.description, null);
86+
});
87+
88+
it('passes validation when userId is a valid GUID', () => {
89+
const actual = commandOptionsSchema.safeParse({ id: eventId, userId: userId });
90+
assert.strictEqual(actual.success, true);
91+
});
92+
93+
it('passes validation when userName is a valid UPN', () => {
94+
const actual = commandOptionsSchema.safeParse({ id: eventId, userName: userPrincipalName });
95+
assert.strictEqual(actual.success, true);
96+
});
97+
98+
it('passes validation when all required parameters are valid', () => {
99+
const actual = commandOptionsSchema.safeParse({ id: eventId });
100+
assert.strictEqual(actual.success, true);
101+
});
102+
103+
it('fails validation if userId is not a valid GUID', () => {
104+
const actual = commandOptionsSchema.safeParse({ id: eventId, userId: 'invalid' });
105+
assert.notStrictEqual(actual.success, true);
106+
});
107+
108+
it('fails validation if userName is not a valid UPN', () => {
109+
const actual = commandOptionsSchema.safeParse({ id: eventId, userName: 'invalid' });
110+
assert.notStrictEqual(actual.success, true);
111+
});
112+
113+
it('cancels a specific event using delegated permissions without prompting for confirmation', async () => {
114+
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
115+
if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}/cancel`) {
116+
return;
117+
}
118+
119+
throw 'Invalid request';
120+
});
121+
122+
await command.action(logger, { options: { id: eventId, force: true, verbose: true } });
123+
assert(postRequestStub.calledOnce);
124+
});
125+
126+
it('cancels a specific event with a comment using delegated permissions without prompting for confirmation', async () => {
127+
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
128+
if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}/cancel` && opts.data.comment === comment) {
129+
return;
130+
}
131+
132+
throw 'Invalid request';
133+
});
134+
135+
await command.action(logger, { options: { id: eventId, comment: comment, force: true } });
136+
assert(postRequestStub.calledOnce);
137+
});
138+
139+
it('cancels a specific event using delegated permissions while prompting for confirmation', async () => {
140+
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
141+
if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}/cancel`) {
142+
return;
143+
}
144+
145+
throw 'Invalid request';
146+
});
147+
148+
sinonUtil.restore(cli.promptForConfirmation);
149+
sinon.stub(cli, 'promptForConfirmation').resolves(true);
150+
151+
await command.action(logger, { options: { id: eventId, verbose: true } });
152+
assert(postRequestStub.calledOnce);
153+
});
154+
155+
it('cancels a specific event using delegated permissions from a calendar specified by userId matching the current user without prompting for confirmation', async () => {
156+
sinon.stub(accessToken, 'getUserIdFromAccessToken').returns(userId);
157+
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
158+
if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events/${eventId}/cancel`) {
159+
return;
160+
}
161+
162+
throw 'Invalid request';
163+
});
164+
165+
await command.action(logger, { options: { id: eventId, userId: userId, force: true, verbose: true } });
166+
assert(postRequestStub.calledOnce);
167+
});
168+
169+
it('cancels a specific event using delegated permissions from a calendar specified by userName matching the current user without prompting for confirmation', async () => {
170+
sinon.stub(accessToken, 'getUserNameFromAccessToken').returns(userPrincipalName);
171+
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
172+
if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}/cancel`) {
173+
return;
174+
}
175+
176+
throw 'Invalid request';
177+
});
178+
179+
await command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true, verbose: true } });
180+
assert(postRequestStub.calledOnce);
181+
});
182+
183+
it('throws an error when userId does not match current user when using delegated permissions', async () => {
184+
sinon.stub(accessToken, 'getUserIdFromAccessToken').returns('00000000-0000-0000-0000-000000000000');
185+
186+
await assert.rejects(command.action(logger, { options: { id: eventId, userId: userId, force: true } }),
187+
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'.`));
188+
});
189+
190+
it('throws an error when userName does not match current user when using delegated permissions', async () => {
191+
sinon.stub(accessToken, 'getUserNameFromAccessToken').returns('other.user@contoso.com');
192+
193+
await assert.rejects(command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true } }),
194+
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'.`));
195+
});
196+
197+
it('cancels a specific event using application permissions from a calendar specified by userId without prompting for confirmation', async () => {
198+
sinonUtil.restore([accessToken.isAppOnlyAccessToken]);
199+
sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true);
200+
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
201+
if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events/${eventId}/cancel`) {
202+
return;
203+
}
204+
205+
throw 'Invalid request';
206+
});
207+
208+
await command.action(logger, { options: { id: eventId, userId: userId, force: true, verbose: true } });
209+
assert(postRequestStub.calledOnce);
210+
});
211+
212+
it('cancels a specific event using application permissions from a calendar specified by userName without prompting for confirmation', async () => {
213+
sinonUtil.restore([accessToken.isAppOnlyAccessToken]);
214+
sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true);
215+
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
216+
if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}/cancel`) {
217+
return;
218+
}
219+
220+
throw 'Invalid request';
221+
});
222+
223+
await command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true, verbose: true } });
224+
assert(postRequestStub.calledOnce);
225+
});
226+
227+
it('throws an error when both userId and userName are not defined when cancelling an event using application permissions', async () => {
228+
sinonUtil.restore([accessToken.isAppOnlyAccessToken]);
229+
sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true);
230+
231+
await assert.rejects(command.action(logger, { options: { id: eventId } }),
232+
new CommandError(`The option 'userId' or 'userName' is required when cancelling an event using application permissions.`));
233+
});
234+
235+
it('fails validation when both userId and userName are specified', () => {
236+
const actual = commandOptionsSchema.safeParse({ id: eventId, userId: userId, userName: userPrincipalName });
237+
assert.strictEqual(actual.success, false);
238+
});
239+
240+
it('succeeds when userName matches current user case-insensitively using delegated permissions', async () => {
241+
sinon.stub(accessToken, 'getUserNameFromAccessToken').returns('John.Doe@Contoso.com');
242+
const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => {
243+
if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}/cancel`) {
244+
return;
245+
}
246+
247+
throw 'Invalid request';
248+
});
249+
250+
await command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true } });
251+
assert(postRequestStub.calledOnce);
252+
});
253+
254+
it('correctly handles API errors', async () => {
255+
const error = {
256+
error: {
257+
code: 'Request_ResourceNotFound',
258+
message: `The specified object was not found in the store., The process failed to get the correct properties.`,
259+
innerError: {
260+
date: '2023-10-27T12:24:36',
261+
'request-id': 'b7dee9ee-d85b-4e7a-8686-74852cbfd85b',
262+
'client-request-id': 'b7dee9ee-d85b-4e7a-8686-74852cbfd85b'
263+
}
264+
}
265+
};
266+
sinon.stub(request, 'post').callsFake(async (opts) => {
267+
if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}/cancel`) {
268+
throw error;
269+
}
270+
271+
throw 'Invalid request';
272+
});
273+
274+
await assert.rejects(command.action(logger, { options: { id: eventId, force: true } }),
275+
new CommandError(error.error.message));
276+
});
277+
278+
it('prompts before cancelling the event when confirm option not passed', async () => {
279+
await command.action(logger, { options: { id: eventId } });
280+
281+
assert(promptIssued);
282+
});
283+
284+
it('aborts cancelling the event when prompt not confirmed', async () => {
285+
const postSpy = sinon.stub(request, 'post').resolves();
286+
287+
await command.action(logger, { options: { id: eventId } });
288+
assert(postSpy.notCalled);
289+
});
290+
});

0 commit comments

Comments
 (0)