diff --git a/docs/docs/cmd/outlook/calendargroup/calendargroup-remove.mdx b/docs/docs/cmd/outlook/calendargroup/calendargroup-remove.mdx new file mode 100644 index 00000000000..36cd813b0b1 --- /dev/null +++ b/docs/docs/cmd/outlook/calendargroup/calendargroup-remove.mdx @@ -0,0 +1,91 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# outlook calendargroup remove + +Removes a calendar group. + +## Usage + +```sh +m365 outlook calendargroup remove [options] +``` + +## Options + +```md definition-list +`--id [id]` +: ID of the calendar group to remove. Specify either `id` or `name`, but not both. + +`--name [name]` +: Name of the calendar group to remove. Specify either `id` or `name`, but not both. + +`--userId [userId]` +: ID of the user. Specify either `userId` or `userName`, but not both. This option is required when using application permissions. + +`--userName [userName]` +: UPN of the user. Specify either `userId` or `userName`, but not both. This option is required when using application permissions. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |-----------------|----------------------| + | Microsoft Graph | Calendars.ReadWrite | + + + + + | Resource | Permissions | + |-----------------|----------------------| + | Microsoft Graph | Calendars.ReadWrite | + + + + +## Remarks + +:::warning + +The calendar group must be empty before it can be removed. Make sure to delete all calendars in the group first. + +::: + +## Examples + +Remove a calendar group specified by id for the signed-in user. + +```sh +m365 outlook calendargroup remove --id "AAMkAGE0MGM1Y2M5LWEzMmUtNGVlNy05MjRlLTk0YmYyY2I5NTM3ZAAuAAAAAAC_0WfqSjt_SqLtNkuO-bj1AQAbfYq5lmBxQ6a4t1fGbeYAAAAAAEOAAA=" +``` + +Remove a calendar group specified by name for the signed-in user. + +```sh +m365 outlook calendargroup remove --name "Personal Events" +``` + +Remove a calendar group specified by id for a user specified by id. + +```sh +m365 outlook calendargroup remove --id "AAMkADIxYjJiYm" --userId "44288f7d-7710-4293-8c8e-36f310ed2e6a" +``` + +Remove a calendar group specified by name for a user specified by UPN without prompting for confirmation. + +```sh +m365 outlook calendargroup remove --name "Personal Events" --userName "john.doe@contoso.com" --force +``` + +## 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 e0f837d676a..f80219aa048 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1327,6 +1327,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'calendargroup list', id: 'cmd/outlook/calendargroup/calendargroup-list' + }, + { + type: 'doc', + label: 'calendargroup remove', + id: 'cmd/outlook/calendargroup/calendargroup-remove' } ] }, diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts index fb433f5842c..8ad0d08071c 100644 --- a/src/m365/outlook/commands.ts +++ b/src/m365/outlook/commands.ts @@ -5,6 +5,7 @@ export default { CALENDAR_GET: `${prefix} calendar get`, CALENDAR_REMOVE: `${prefix} calendar remove`, CALENDARGROUP_LIST: `${prefix} calendargroup list`, + CALENDARGROUP_REMOVE: `${prefix} calendargroup remove`, EVENT_CANCEL: `${prefix} event cancel`, EVENT_LIST: `${prefix} event list`, EVENT_REMOVE: `${prefix} event remove`, diff --git a/src/m365/outlook/commands/calendargroup/calendargroup-remove.spec.ts b/src/m365/outlook/commands/calendargroup/calendargroup-remove.spec.ts new file mode 100644 index 00000000000..31c956a538b --- /dev/null +++ b/src/m365/outlook/commands/calendargroup/calendargroup-remove.spec.ts @@ -0,0 +1,312 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { CommandError } from '../../../../Command.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { cli } from '../../../../cli/cli.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import { calendarGroup } from '../../../../utils/calendarGroup.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command, { options } from './calendargroup-remove.js'; + +describe(commands.CALENDARGROUP_REMOVE, () => { + const calendarGroupId = 'AAMkAGE0MGM1Y2M5LWEzMmUtNGVlNy05MjRlLTk0YmYyY2I5NTM3ZAAuAAAAAAC_0WfqSjt_SqLtNkuO-bj1AQAbfYq5lmBxQ6a4t1fGbeYAAAAAAEOAAA='; + const calendarGroupName = 'Personal Events'; + const userId = 'b743445a-112c-4fda-9afd-05943f9c7b36'; + const userName = 'john.doe@contoso.com'; + + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; + let promptIssued: boolean; + + 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; + if (!auth.connection.accessTokens[auth.defaultResource]) { + 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(() => { + promptIssued = true; + return Promise.resolve(false); + }); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + promptIssued = false; + }); + + afterEach(() => { + sinonUtil.restore([ + accessToken.isAppOnlyAccessToken, + calendarGroup.getUserCalendarGroupByName, + request.get, + request.delete, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CALENDARGROUP_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation when neither id nor name is specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when both id and name are specified', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, name: calendarGroupName }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userId, userName }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userId: 'foo' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when userName is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userName: 'foo' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ unknownOption: 'value' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation with id', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with name', () => { + const actual = commandOptionsSchema.safeParse({ name: calendarGroupName }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with id and userId', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with id and userName', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userName }); + assert.strictEqual(actual.success, true); + }); + + it('prompts before removing when force option not passed', async () => { + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId }) }); + + assert(promptIssued); + }); + + it('aborts removing when prompt not confirmed', async () => { + const deleteSpy = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId }) }); + assert(deleteSpy.notCalled); + }); + + it('removes the calendar group specified by id for the signed-in user without prompting', async () => { + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, force: true }) }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes the calendar group specified by id for the signed-in user (verbose)', async () => { + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, force: true, verbose: true }) }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes the calendar group specified by name for the signed-in user', async () => { + sinon.stub(calendarGroup, 'getUserCalendarGroupByName').resolves({ id: calendarGroupId, name: calendarGroupName }); + + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}`) { + return; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName }) }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes the calendar group specified by name for the signed-in user (verbose)', async () => { + sinon.stub(calendarGroup, 'getUserCalendarGroupByName').resolves({ id: calendarGroupId, name: calendarGroupName }); + + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, force: true, verbose: true }) }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes the calendar group specified by id for a user specified by userId', async () => { + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups/${calendarGroupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, userId, force: true }) }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes the calendar group specified by id for a user specified by userName', async () => { + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('john.doe%40contoso.com')/calendarGroups/${calendarGroupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, userName, force: true }) }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes the calendar group specified by name for a user specified by userId', async () => { + sinon.stub(calendarGroup, 'getUserCalendarGroupByName').resolves({ id: calendarGroupId, name: calendarGroupName }); + + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups/${calendarGroupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userId, force: true }) }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes the calendar group specified by name for a user specified by userName', async () => { + sinon.stub(calendarGroup, 'getUserCalendarGroupByName').resolves({ id: calendarGroupId, name: calendarGroupName }); + + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('john.doe%40contoso.com')/calendarGroups/${calendarGroupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userName, force: true }) }); + assert(deleteRequestStub.calledOnce); + }); + + it('removes the calendar group specified by id using app-only permissions', 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}')/calendarGroups/${calendarGroupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, userId, force: true }) }); + assert(deleteRequestStub.calledOnce); + }); + + it('throws error when running with app-only permissions without userId or userName', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, force: true }) }), + new CommandError('When running with application permissions either userId or userName is required.') + ); + }); + + it('throws error when calendar group specified by name is not found', async () => { + sinon.stub(calendarGroup, 'getUserCalendarGroupByName').rejects(new Error(`The specified calendar group '${calendarGroupName}' does not exist.`)); + + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, force: true }) }), + new CommandError(`The specified calendar group '${calendarGroupName}' does not exist.`) + ); + }); + + it('correctly handles API OData error', async () => { + const errorMessage = `Your request can't be completed. The calendar group '${calendarGroupName}' is not empty.`; + sinon.stub(request, 'delete').rejects({ error: { error: { code: 'ErrorInvalidRequest', message: errorMessage } } }); + + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, force: true }) }), + new CommandError(errorMessage) + ); + }); +}); diff --git a/src/m365/outlook/commands/calendargroup/calendargroup-remove.ts b/src/m365/outlook/commands/calendargroup/calendargroup-remove.ts new file mode 100644 index 00000000000..e1fcc47ed3c --- /dev/null +++ b/src/m365/outlook/commands/calendargroup/calendargroup-remove.ts @@ -0,0 +1,123 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { cli } from '../../../../cli/cli.js'; +import commands from '../../commands.js'; +import { validation } from '../../../../utils/validation.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import auth from '../../../../Auth.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { calendarGroup } from '../../../../utils/calendarGroup.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().optional(), + name: z.string().optional(), + userId: z.string().refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID.` + }).optional(), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid UPN.` + }).optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class OutlookCalendarGroupRemoveCommand extends GraphCommand { + public get name(): string { + return commands.CALENDARGROUP_REMOVE; + } + + public get description(): string { + return 'Removes a calendar group'; + } + + public get schema(): z.ZodType | undefined { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => options.id || options.name, { + error: 'Specify either id or name.' + }) + .refine(options => !(options.id && options.name), { + error: 'Specify either id or name, but not both.' + }) + .refine(options => !(options.userId && options.userName), { + error: 'Specify either userId or userName, but not both.' + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const removeCalendarGroup = async (): Promise => { + try { + const token = auth.connection.accessTokens[auth.defaultResource].accessToken; + const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(token); + + if (isAppOnlyAccessToken && !args.options.userId && !args.options.userName) { + throw 'When running with application permissions either userId or userName is required.'; + } + + let endpoint: string; + let graphUserId: string; + + if (args.options.userId || args.options.userName) { + graphUserId = (args.options.userId ?? args.options.userName)!; + endpoint = `${this.resource}/v1.0/users('${formatting.encodeQueryParameter(graphUserId)}')`; + } + else { + graphUserId = 'me'; + endpoint = `${this.resource}/v1.0/me`; + } + + let calendarGroupId = args.options.id; + + if (args.options.name) { + if (this.verbose) { + await logger.logToStderr(`Retrieving calendar group by name '${args.options.name}'...`); + } + + const calendarGroupResult = await calendarGroup.getUserCalendarGroupByName(graphUserId, args.options.name); + calendarGroupId = calendarGroupResult.id!; + } + + if (this.verbose) { + await logger.logToStderr(`Removing calendar group '${calendarGroupId}'...`); + } + + const requestOptions: CliRequestOptions = { + url: `${endpoint}/calendarGroups/${calendarGroupId}`, + headers: { + accept: 'application/json;odata.metadata=none' + } + }; + + await request.delete(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await removeCalendarGroup(); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove calendar group '${args.options.id || args.options.name}'?` }); + + if (result) { + await removeCalendarGroup(); + } + } + } +} + +export default new OutlookCalendarGroupRemoveCommand();