From ba7968615cdca7a7d29d676dabecd73b0f4772ee Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Thu, 26 Feb 2026 17:34:57 +0100 Subject: [PATCH 1/5] Adds command `outlook event cancel`. Closes New command: `outlook event cancel` Fixes #7105 --- docs/docs/cmd/outlook/event/event-cancel.mdx | 85 +++++ docs/src/config/sidebars.ts | 9 + src/m365/outlook/commands.ts | 1 + .../commands/event/event-cancel.spec.ts | 299 ++++++++++++++++++ .../outlook/commands/event/event-cancel.ts | 123 +++++++ 5 files changed, 517 insertions(+) create mode 100644 docs/docs/cmd/outlook/event/event-cancel.mdx create mode 100644 src/m365/outlook/commands/event/event-cancel.spec.ts create mode 100644 src/m365/outlook/commands/event/event-cancel.ts diff --git a/docs/docs/cmd/outlook/event/event-cancel.mdx b/docs/docs/cmd/outlook/event/event-cancel.mdx new file mode 100644 index 00000000000..5c66e1fc5b7 --- /dev/null +++ b/docs/docs/cmd/outlook/event/event-cancel.mdx @@ -0,0 +1,85 @@ +import Global from '/docs/cmd/_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 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. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |-----------------|---------------------------| + | Microsoft Graph | Calendars.ReadWrite | + + + + + | Resource | Permissions | + |-----------------|--------------------------------| + | Microsoft Graph | Calendars.ReadWrite | + + + + +## 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. diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 55e13dca27c..3791ef9e2d7 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1311,6 +1311,15 @@ const sidebars: SidebarsConfig = { } ] }, + { + event: [ + { + type: 'doc', + label: 'event cancel', + id: 'cmd/outlook/event/event-cancel' + } + ] + }, { mail: [ { diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts index 79e8dc9f0d6..a2c94bfa654 100644 --- a/src/m365/outlook/commands.ts +++ b/src/m365/outlook/commands.ts @@ -2,6 +2,7 @@ const prefix: string = 'outlook'; export default { CALENDARGROUP_LIST: `${prefix} calendargroup list`, + EVENT_CANCEL: `${prefix} event cancel`, MAIL_SEARCHFOLDER_ADD: `${prefix} mail searchfolder add`, MAIL_SEND: `${prefix} mail send`, MAILBOX_SETTINGS_GET: `${prefix} mailbox settings get`, diff --git a/src/m365/outlook/commands/event/event-cancel.spec.ts b/src/m365/outlook/commands/event/event-cancel.spec.ts new file mode 100644 index 00000000000..9bbfe96eb84 --- /dev/null +++ b/src/m365/outlook/commands/event/event-cancel.spec.ts @@ -0,0 +1,299 @@ +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 { formatting } from '../../../../utils/formatting.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/${formatting.encodeQueryParameter(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/${formatting.encodeQueryParameter(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('throws an error when both userId and userName are 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, userId: userId, userName: userPrincipalName } }), + new CommandError(`Both options 'userId' and 'userName' cannot be used together when cancelling an event using application permissions.`)); + }); + + it('throws an error when both userId and userName are defined when cancelling an event using delegated permissions', async () => { + await assert.rejects(command.action(logger, { options: { id: eventId, userId: userId, userName: userPrincipalName } }), + new CommandError(`Both options 'userId' and 'userName' cannot be used together when cancelling an event using delegated permissions.`)); + }); + + 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/${formatting.encodeQueryParameter(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); + }); +}); diff --git a/src/m365/outlook/commands/event/event-cancel.ts b/src/m365/outlook/commands/event/event-cancel.ts new file mode 100644 index 00000000000..d968428f68d --- /dev/null +++ b/src/m365/outlook/commands/event/event-cancel.ts @@ -0,0 +1,123 @@ +import auth from '../../../../Auth.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { validation } from '../../../../utils/validation.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { cli } from '../../../../cli/cli.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { z } from 'zod'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i'), + userId: z.uuid().optional(), + userName: z.string() + .refine(upn => validation.isValidUserPrincipalName(upn) === true, { + error: e => `'${e.input}' is not a valid user principal name for option 'userName'.` + }) + .optional(), + comment: z.string().optional(), + force: z.boolean().optional().alias('f') +}); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class OutlookEventCancelCommand extends GraphCommand { + public get name(): string { + return commands.EVENT_CANCEL; + } + + public get description(): string { + return 'Cancels a calendar event'; + } + + public get schema(): z.ZodTypeAny { + return options; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const isAppOnlyAccessToken: boolean | undefined = accessToken.isAppOnlyAccessToken(auth.connection.accessTokens[auth.defaultResource].accessToken); + let principalUrl = ''; + + const token = auth.connection.accessTokens[auth.defaultResource].accessToken; + + if (isAppOnlyAccessToken) { + if (!args.options.userId && !args.options.userName) { + throw `The option 'userId' or 'userName' is required when cancelling an event using application permissions.`; + } + + if (args.options.userId && args.options.userName) { + throw `Both options 'userId' and 'userName' cannot be used together when cancelling an event using application permissions.`; + } + } + else { + if (args.options.userId && args.options.userName) { + throw `Both options 'userId' and 'userName' cannot be used together when cancelling an event using delegated permissions.`; + } + + if (args.options.userId) { + const currentUserId = accessToken.getUserIdFromAccessToken(token); + if (args.options.userId !== currentUserId) { + throw `You can only cancel your own events when using delegated permissions. The specified userId '${args.options.userId}' does not match the current user '${currentUserId}'.`; + } + } + + if (args.options.userName) { + const currentUserName = accessToken.getUserNameFromAccessToken(token); + if (args.options.userName.toLowerCase() !== currentUserName.toLowerCase()) { + throw `You can only cancel your own events when using delegated permissions. The specified userName '${args.options.userName}' does not match the current user '${currentUserName}'.`; + } + } + } + + if (args.options.userId || args.options.userName) { + principalUrl += `users/${args.options.userId || formatting.encodeQueryParameter(args.options.userName!)}`; + } + else { + principalUrl += 'me'; + } + + const cancelEvent = async (): Promise => { + try { + if (this.verbose) { + await logger.logToStderr(`Cancelling event with id '${args.options.id}'...`); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/${principalUrl}/events/${args.options.id}/cancel`, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json' + }, + data: { + comment: args.options.comment + } + }; + + await request.post(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await cancelEvent(); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to cancel event with id '${args.options.id}'?` }); + + if (result) { + await cancelEvent(); + } + } + } +} + +export default new OutlookEventCancelCommand(); From bca64b30d4f43dba690cf0ba577f7ee06e7d6c41 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Fri, 6 Mar 2026 23:25:52 +0100 Subject: [PATCH 2/5] Changes PR7160 --- docs/docs/cmd/outlook/event/event-cancel.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/cmd/outlook/event/event-cancel.mdx b/docs/docs/cmd/outlook/event/event-cancel.mdx index 5c66e1fc5b7..d29a452fd66 100644 --- a/docs/docs/cmd/outlook/event/event-cancel.mdx +++ b/docs/docs/cmd/outlook/event/event-cancel.mdx @@ -1,4 +1,4 @@ -import Global from '/docs/cmd/_global.mdx'; +import Global from '../_global.mdx'; import TabItem from '@theme/TabItem'; import Tabs from '@theme/Tabs'; From afdc2f272e08a65438317b65df76c66545f94132 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Fri, 6 Mar 2026 23:28:23 +0100 Subject: [PATCH 3/5] Changes PR7160 --- docs/docs/cmd/outlook/event/event-cancel.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/cmd/outlook/event/event-cancel.mdx b/docs/docs/cmd/outlook/event/event-cancel.mdx index d29a452fd66..c1e534cccbb 100644 --- a/docs/docs/cmd/outlook/event/event-cancel.mdx +++ b/docs/docs/cmd/outlook/event/event-cancel.mdx @@ -1,4 +1,4 @@ -import Global from '../_global.mdx'; +import Global from '../../../cmd/_global.mdx'; import TabItem from '@theme/TabItem'; import Tabs from '@theme/Tabs'; From 83e1d2b5c4fe16da8e132a51e147debdf632a8b9 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Fri, 6 Mar 2026 23:34:12 +0100 Subject: [PATCH 4/5] Changes PR7160 --- docs/docs/cmd/outlook/event/event-cancel.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/cmd/outlook/event/event-cancel.mdx b/docs/docs/cmd/outlook/event/event-cancel.mdx index c1e534cccbb..9c160c973f4 100644 --- a/docs/docs/cmd/outlook/event/event-cancel.mdx +++ b/docs/docs/cmd/outlook/event/event-cancel.mdx @@ -1,4 +1,4 @@ -import Global from '../../../cmd/_global.mdx'; +import Global from '../../_global.mdx'; import TabItem from '@theme/TabItem'; import Tabs from '@theme/Tabs'; From d28d69e205e87ac1b1390a79dc6a08298c3dddf0 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Sat, 4 Apr 2026 23:36:11 +0200 Subject: [PATCH 5/5] Feedback from Martin --- docs/docs/cmd/outlook/event/event-cancel.mdx | 12 ++++----- .../commands/event/event-cancel.spec.ts | 25 ++++++------------- .../outlook/commands/event/event-cancel.ts | 22 ++++++++-------- 3 files changed, 26 insertions(+), 33 deletions(-) diff --git a/docs/docs/cmd/outlook/event/event-cancel.mdx b/docs/docs/cmd/outlook/event/event-cancel.mdx index 9c160c973f4..4d176f49b24 100644 --- a/docs/docs/cmd/outlook/event/event-cancel.mdx +++ b/docs/docs/cmd/outlook/event/event-cancel.mdx @@ -38,16 +38,16 @@ m365 outlook event cancel [options] - | Resource | Permissions | - |-----------------|---------------------------| - | Microsoft Graph | Calendars.ReadWrite | + | Resource | Permissions | + |-----------------|---------------------| + | Microsoft Graph | Calendars.ReadWrite | - | Resource | Permissions | - |-----------------|--------------------------------| - | Microsoft Graph | Calendars.ReadWrite | + | Resource | Permissions | + |-----------------|---------------------| + | Microsoft Graph | Calendars.ReadWrite | diff --git a/src/m365/outlook/commands/event/event-cancel.spec.ts b/src/m365/outlook/commands/event/event-cancel.spec.ts index 9bbfe96eb84..480dd3f23fc 100644 --- a/src/m365/outlook/commands/event/event-cancel.spec.ts +++ b/src/m365/outlook/commands/event/event-cancel.spec.ts @@ -12,7 +12,6 @@ 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 { formatting } from '../../../../utils/formatting.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; describe(commands.EVENT_CANCEL, () => { @@ -156,7 +155,7 @@ describe(commands.EVENT_CANCEL, () => { 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`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events/${eventId}/cancel`) { return; } @@ -170,7 +169,7 @@ describe(commands.EVENT_CANCEL, () => { 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/${formatting.encodeQueryParameter(userPrincipalName)}/events/${eventId}/cancel`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}/cancel`) { return; } @@ -199,7 +198,7 @@ describe(commands.EVENT_CANCEL, () => { 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`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events/${eventId}/cancel`) { return; } @@ -214,7 +213,7 @@ describe(commands.EVENT_CANCEL, () => { 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/${formatting.encodeQueryParameter(userPrincipalName)}/events/${eventId}/cancel`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}/cancel`) { return; } @@ -233,23 +232,15 @@ describe(commands.EVENT_CANCEL, () => { new CommandError(`The option 'userId' or 'userName' is required when cancelling an event using application permissions.`)); }); - it('throws an error when both userId and userName are 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, userId: userId, userName: userPrincipalName } }), - new CommandError(`Both options 'userId' and 'userName' cannot be used together when cancelling an event using application permissions.`)); - }); - - it('throws an error when both userId and userName are defined when cancelling an event using delegated permissions', async () => { - await assert.rejects(command.action(logger, { options: { id: eventId, userId: userId, userName: userPrincipalName } }), - new CommandError(`Both options 'userId' and 'userName' cannot be used together when cancelling an event using delegated 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/${formatting.encodeQueryParameter(userPrincipalName)}/events/${eventId}/cancel`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}/cancel`) { return; } diff --git a/src/m365/outlook/commands/event/event-cancel.ts b/src/m365/outlook/commands/event/event-cancel.ts index d968428f68d..9ea848be1d9 100644 --- a/src/m365/outlook/commands/event/event-cancel.ts +++ b/src/m365/outlook/commands/event/event-cancel.ts @@ -2,7 +2,6 @@ import auth from '../../../../Auth.js'; import { Logger } from '../../../../cli/Logger.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { accessToken } from '../../../../utils/accessToken.js'; -import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; @@ -13,7 +12,9 @@ import { z } from 'zod'; export const options = z.strictObject({ ...globalOptionsZod.shape, id: z.string().alias('i'), - userId: z.uuid().optional(), + userId: z.string().refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID.` + }).optional(), userName: z.string() .refine(upn => validation.isValidUserPrincipalName(upn) === true, { error: e => `'${e.input}' is not a valid user principal name for option 'userName'.` @@ -41,6 +42,13 @@ class OutlookEventCancelCommand extends GraphCommand { return options; } + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => !(options.userId && options.userName), { + error: `Specify either 'userId' or 'userName', but not both.` + }); + } + public async commandAction(logger: Logger, args: CommandArgs): Promise { const isAppOnlyAccessToken: boolean | undefined = accessToken.isAppOnlyAccessToken(auth.connection.accessTokens[auth.defaultResource].accessToken); let principalUrl = ''; @@ -52,15 +60,8 @@ class OutlookEventCancelCommand extends GraphCommand { throw `The option 'userId' or 'userName' is required when cancelling an event using application permissions.`; } - if (args.options.userId && args.options.userName) { - throw `Both options 'userId' and 'userName' cannot be used together when cancelling an event using application permissions.`; - } } else { - if (args.options.userId && args.options.userName) { - throw `Both options 'userId' and 'userName' cannot be used together when cancelling an event using delegated permissions.`; - } - if (args.options.userId) { const currentUserId = accessToken.getUserIdFromAccessToken(token); if (args.options.userId !== currentUserId) { @@ -77,7 +78,8 @@ class OutlookEventCancelCommand extends GraphCommand { } if (args.options.userId || args.options.userName) { - principalUrl += `users/${args.options.userId || formatting.encodeQueryParameter(args.options.userName!)}`; + const userIdentifier = args.options.userId ?? args.options.userName; + principalUrl += `users('${userIdentifier}')`; } else { principalUrl += 'me';