From c7dc6abd90d366258d509e535a6cf880f93f850b Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Thu, 26 Feb 2026 18:08:15 +0100 Subject: [PATCH 1/5] Adds command `outlook event remove`. Closes #7104 --- docs/docs/cmd/outlook/event/event-remove.mdx | 85 +++++ docs/src/config/sidebars.ts | 9 + src/m365/outlook/commands.ts | 1 + .../commands/event/event-remove.spec.ts | 314 ++++++++++++++++++ .../outlook/commands/event/event-remove.ts | 124 +++++++ 5 files changed, 533 insertions(+) create mode 100644 docs/docs/cmd/outlook/event/event-remove.mdx create mode 100644 src/m365/outlook/commands/event/event-remove.spec.ts create mode 100644 src/m365/outlook/commands/event/event-remove.ts diff --git a/docs/docs/cmd/outlook/event/event-remove.mdx b/docs/docs/cmd/outlook/event/event-remove.mdx new file mode 100644 index 00000000000..fd9533d5d98 --- /dev/null +++ b/docs/docs/cmd/outlook/event/event-remove.mdx @@ -0,0 +1,85 @@ +import Global from '/docs/cmd/_global.mdx'; +import TabItem from '@theme/TabItem'; +import Tabs from '@theme/Tabs'; + +# outlook event remove + +Removes an event from a calendar + +## Usage + +```sh +m365 outlook event remove [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. + +`--permanent` +: Permanently remove the event, don't send it to the recycle bin. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |-----------------|---------------------------| + | Microsoft Graph | Calendars.ReadWrite | + + + + + | Resource | Permissions | + |-----------------|--------------------------------| + | Microsoft Graph | Calendars.ReadWrite | + + + + +## Remarks + +:::warning + +When using the `--permanent` option, the event will be permanently deleted and cannot be recovered. + +::: + +## Examples + +Remove a calendar event from the current logged-in user + +```sh +m365 outlook event remove --id AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA= +``` + +Permanently remove a calendar event from a specific user + +```sh +m365 outlook event remove --userName "john.doe@contoso.com" --permanent --id AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA= +``` + +Remove a calendar event from a specific user specified by user ID + +```sh +m365 outlook event remove --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..cdfa3ed5a23 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 remove', + id: 'cmd/outlook/event/event-remove' + } + ] + }, { mail: [ { diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts index 79e8dc9f0d6..4926c28d571 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_REMOVE: `${prefix} event remove`, 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-remove.spec.ts b/src/m365/outlook/commands/event/event-remove.spec.ts new file mode 100644 index 00000000000..1611c567212 --- /dev/null +++ b/src/m365/outlook/commands/event/event-remove.spec.ts @@ -0,0 +1,314 @@ +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-remove.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; + +describe(commands.EVENT_REMOVE, () => { + const eventId = 'AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAENAAAiIsqMbYjsT5e-T7KzowPTAAAa_WKzAAA='; + const userId = '6799fd1a-723b-4eb7-8e52-41ae530274ca'; + const userPrincipalName = 'john.doe@contoso.com'; + + 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.delete, + 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_REMOVE); + }); + + 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('removes a specific event using delegated permissions without prompting for confirmation', async () => { + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: eventId, force: true, verbose: true } }); + assert(deleteRequestStub.calledOnce); + }); + + it('permanently removes 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}/permanentDelete`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: eventId, permanent: true, force: true, verbose: true } }); + assert(postRequestStub.calledOnce); + }); + + it('removes a specific event using delegated permissions while prompting for confirmation', async () => { + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}`) { + return; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { options: { id: eventId, verbose: true } }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes 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 deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/events/${eventId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: eventId, userId: userId, force: true, verbose: true } }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes 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 deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(userPrincipalName)}/events/${eventId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true, verbose: true } }); + assert(deleteRequestStub.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 remove 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 remove your own events when using delegated permissions. The specified userName '${userPrincipalName}' does not match the current user 'other.user@contoso.com'.`)); + }); + + it('succeeds when userName matches current user case-insensitively using delegated permissions', async () => { + sinon.stub(accessToken, 'getUserNameFromAccessToken').returns('John.Doe@Contoso.com'); + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(userPrincipalName)}/events/${eventId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true } }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes 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 deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/events/${eventId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: eventId, userId: userId, force: true, verbose: true } }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes 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 deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(userPrincipalName)}/events/${eventId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: eventId, userName: userPrincipalName, force: true, verbose: true } }); + assert(deleteRequestStub.calledOnce); + }); + + it('permanently removes a specific event using application permissions from a calendar specified by userId', 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}/permanentDelete`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: eventId, userId: userId, permanent: true, force: true } }); + assert(postRequestStub.calledOnce); + }); + + it('throws an error when both userId and userName are not defined when removing 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 removing an event using application permissions.`)); + }); + + it('throws an error when both userId and userName are defined when removing 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 removing an event using application permissions.`)); + }); + + it('throws an error when both userId and userName are defined when removing 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 removing an event using delegated permissions.`)); + }); + + 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, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}`) { + throw error; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { id: eventId, force: true } }), + new CommandError(error.error.message)); + }); + + it('prompts before removing the event when confirm option not passed', async () => { + await command.action(logger, { options: { id: eventId } }); + + assert(promptIssued); + }); + + it('aborts removing the event when prompt not confirmed', async () => { + const deleteSpy = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { options: { id: eventId } }); + assert(deleteSpy.notCalled); + }); +}); diff --git a/src/m365/outlook/commands/event/event-remove.ts b/src/m365/outlook/commands/event/event-remove.ts new file mode 100644 index 00000000000..d3998d2093a --- /dev/null +++ b/src/m365/outlook/commands/event/event-remove.ts @@ -0,0 +1,124 @@ +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(), + permanent: z.boolean().optional(), + force: z.boolean().optional().alias('f') +}); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class OutlookEventRemoveCommand extends GraphCommand { + public get name(): string { + return commands.EVENT_REMOVE; + } + + public get description(): string { + return 'Removes an event from a calendar'; + } + + 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); + 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 removing an event using application permissions.`; + } + + if (args.options.userId && args.options.userName) { + throw `Both options 'userId' and 'userName' cannot be used together when removing an event using application permissions.`; + } + } + else { + if (args.options.userId && args.options.userName) { + throw `Both options 'userId' and 'userName' cannot be used together when removing an event using delegated permissions.`; + } + + if (args.options.userId) { + const currentUserId = accessToken.getUserIdFromAccessToken(token); + if (args.options.userId !== currentUserId) { + throw `You can only remove 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 remove your own events when using delegated permissions. The specified userName '${args.options.userName}' does not match the current user '${currentUserName}'.`; + } + } + } + + let principalUrl = ''; + if (args.options.userId || args.options.userName) { + principalUrl += `users/${args.options.userId || formatting.encodeQueryParameter(args.options.userName!)}`; + } + else { + principalUrl += 'me'; + } + + const removeEvent = async (): Promise => { + try { + if (this.verbose) { + await logger.logToStderr(`Removing event with id '${args.options.id}'...`); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/${principalUrl}/events/${args.options.id}`, + headers: { + accept: 'application/json;odata.metadata=none' + } + }; + + if (args.options.permanent) { + requestOptions.url += '/permanentDelete'; + await request.post(requestOptions); + } + else { + await request.delete(requestOptions); + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await removeEvent(); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove event with id '${args.options.id}'?` }); + + if (result) { + await removeEvent(); + } + } + } +} + +export default new OutlookEventRemoveCommand(); From e4317f0a32ce01e60c199f614ad3643b0bfac325 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Fri, 6 Mar 2026 23:25:10 +0100 Subject: [PATCH 2/5] Changes PR7160 --- docs/docs/cmd/outlook/event/event-remove.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/cmd/outlook/event/event-remove.mdx b/docs/docs/cmd/outlook/event/event-remove.mdx index fd9533d5d98..ef6cfaea780 100644 --- a/docs/docs/cmd/outlook/event/event-remove.mdx +++ b/docs/docs/cmd/outlook/event/event-remove.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 bb9f8bb353814b81f0a015191a95ab16dac71aa4 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Fri, 6 Mar 2026 23:29:36 +0100 Subject: [PATCH 3/5] Changes PR7160 --- docs/docs/cmd/outlook/event/event-remove.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/cmd/outlook/event/event-remove.mdx b/docs/docs/cmd/outlook/event/event-remove.mdx index ef6cfaea780..cf1eee02bab 100644 --- a/docs/docs/cmd/outlook/event/event-remove.mdx +++ b/docs/docs/cmd/outlook/event/event-remove.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 28fb2d199085b83d9fe86fd7643a1d1cf36a7a68 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Fri, 6 Mar 2026 23:33:54 +0100 Subject: [PATCH 4/5] Changes PR7160 --- docs/docs/cmd/outlook/event/event-remove.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/cmd/outlook/event/event-remove.mdx b/docs/docs/cmd/outlook/event/event-remove.mdx index cf1eee02bab..3f1078741e8 100644 --- a/docs/docs/cmd/outlook/event/event-remove.mdx +++ b/docs/docs/cmd/outlook/event/event-remove.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 27ad41dbedc78717308f910131ee137052555398 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Sun, 5 Apr 2026 20:20:50 +0200 Subject: [PATCH 5/5] Feedback Martin --- docs/docs/cmd/outlook/event/event-remove.mdx | 12 +++---- .../commands/event/event-remove.spec.ts | 31 +++++++------------ .../outlook/commands/event/event-remove.ts | 24 +++++++------- 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/docs/docs/cmd/outlook/event/event-remove.mdx b/docs/docs/cmd/outlook/event/event-remove.mdx index 3f1078741e8..7b20eb56c80 100644 --- a/docs/docs/cmd/outlook/event/event-remove.mdx +++ b/docs/docs/cmd/outlook/event/event-remove.mdx @@ -38,16 +38,16 @@ m365 outlook event remove [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-remove.spec.ts b/src/m365/outlook/commands/event/event-remove.spec.ts index 1611c567212..412a2caabe0 100644 --- a/src/m365/outlook/commands/event/event-remove.spec.ts +++ b/src/m365/outlook/commands/event/event-remove.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-remove.js'; -import { formatting } from '../../../../utils/formatting.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; describe(commands.EVENT_REMOVE, () => { @@ -111,6 +110,11 @@ describe(commands.EVENT_REMOVE, () => { assert.notStrictEqual(actual.success, true); }); + it('fails validation if both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ id: eventId, userId: userId, userName: userPrincipalName }); + assert.notStrictEqual(actual.success, true); + }); + it('removes a specific event using delegated permissions without prompting for confirmation', async () => { const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/me/events/${eventId}`) { @@ -156,7 +160,7 @@ describe(commands.EVENT_REMOVE, () => { it('removes 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 deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/events/${eventId}`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events/${eventId}`) { return; } @@ -170,7 +174,7 @@ describe(commands.EVENT_REMOVE, () => { it('removes 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 deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(userPrincipalName)}/events/${eventId}`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}`) { return; } @@ -198,7 +202,7 @@ describe(commands.EVENT_REMOVE, () => { it('succeeds when userName matches current user case-insensitively using delegated permissions', async () => { sinon.stub(accessToken, 'getUserNameFromAccessToken').returns('John.Doe@Contoso.com'); const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(userPrincipalName)}/events/${eventId}`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}`) { return; } @@ -213,7 +217,7 @@ describe(commands.EVENT_REMOVE, () => { sinonUtil.restore([accessToken.isAppOnlyAccessToken]); sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/events/${eventId}`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events/${eventId}`) { return; } @@ -228,7 +232,7 @@ describe(commands.EVENT_REMOVE, () => { sinonUtil.restore([accessToken.isAppOnlyAccessToken]); sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(userPrincipalName)}/events/${eventId}`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userPrincipalName}')/events/${eventId}`) { return; } @@ -243,7 +247,7 @@ describe(commands.EVENT_REMOVE, () => { 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}/permanentDelete`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events/${eventId}/permanentDelete`) { return; } @@ -262,19 +266,6 @@ describe(commands.EVENT_REMOVE, () => { new CommandError(`The option 'userId' or 'userName' is required when removing an event using application permissions.`)); }); - it('throws an error when both userId and userName are defined when removing 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 removing an event using application permissions.`)); - }); - - it('throws an error when both userId and userName are defined when removing 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 removing an event using delegated permissions.`)); - }); - it('correctly handles API errors', async () => { const error = { error: { diff --git a/src/m365/outlook/commands/event/event-remove.ts b/src/m365/outlook/commands/event/event-remove.ts index d3998d2093a..1aaf4bc8c7f 100644 --- a/src/m365/outlook/commands/event/event-remove.ts +++ b/src/m365/outlook/commands/event/event-remove.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,12 @@ class OutlookEventRemoveCommand extends GraphCommand { return options; } + public getRefinedSchema(schema: typeof options): z.ZodType | 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); const token = auth.connection.accessTokens[auth.defaultResource].accessToken; @@ -49,16 +56,8 @@ class OutlookEventRemoveCommand extends GraphCommand { if (!args.options.userId && !args.options.userName) { throw `The option 'userId' or 'userName' is required when removing an event using application permissions.`; } - - if (args.options.userId && args.options.userName) { - throw `Both options 'userId' and 'userName' cannot be used together when removing an event using application permissions.`; - } } else { - if (args.options.userId && args.options.userName) { - throw `Both options 'userId' and 'userName' cannot be used together when removing an event using delegated permissions.`; - } - if (args.options.userId) { const currentUserId = accessToken.getUserIdFromAccessToken(token); if (args.options.userId !== currentUserId) { @@ -75,8 +74,9 @@ class OutlookEventRemoveCommand extends GraphCommand { } let principalUrl = ''; - 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; + if (userIdentifier) { + principalUrl += `users('${userIdentifier}')`; } else { principalUrl += 'me';