diff --git a/docs/docs/cmd/outlook/calendargroup/calendargroup-add.mdx b/docs/docs/cmd/outlook/calendargroup/calendargroup-add.mdx new file mode 100644 index 00000000000..4c8d328ca21 --- /dev/null +++ b/docs/docs/cmd/outlook/calendargroup/calendargroup-add.mdx @@ -0,0 +1,126 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# outlook calendargroup add + +Creates a new calendar group for a user. + +## Usage + +```sh +m365 outlook calendargroup add [options] +``` + +## Options + +```md definition-list +`--userId [userId]` +: ID of the user. Specify either `userId` or `userName`, but not both. + +`--userName [userName]` +: UPN of the user. Specify either `userId` or `userName`, but not both. + +`--name ` +: Name of the calendar group. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |-----------------|----------------------------------------| + | Microsoft Graph | Calendars.ReadWrite, Calendars.ReadWrite.Shared | + + + + + | Resource | Permissions | + |-----------------|----------------------| + | Microsoft Graph | Calendars.ReadWrite | + + + + +:::note + +When using delegated permissions, specifying `userId` or `userName` for a different user requires the `Calendars.ReadWrite.Shared` scope. When the specified user matches the signed-in user, no shared scope is needed. + +::: + +## Examples + +Create a new calendar group for the current user + +```sh +m365 outlook calendargroup add --name "Personal Events" --userId "@meId" +``` + +Create a new calendar group for a user + +```sh +m365 outlook calendargroup add --name "Personal Events" --userId "44288f7d-7710-4293-8c8e-36f310ed2e6a" +``` + +Create a new calendar group for a user specified by email + +```sh +m365 outlook calendargroup add --name "Personal Events" --userName "john.doe@contoso.com" +``` + +## Response + + + + + ```json + { + "id": "AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgEGAAAAfJKVL07sbkmIfHqjbDnRgQADK5c4ngAAAA==", + "name": "My Work Calendars", + "classId": "c02d4ddf-4809-485f-9cd4-1ef0937e03be", + "changeKey": "fJKVL07sbkmIfHqjbDnRgQADKqwfAA==" + } + ``` + + + + + ```text + changeKey: fJKVL07sbkmIfHqjbDnRgQADKqwfAA== + classId : c02d4ddf-4809-485f-9cd4-1ef0937e03be + id : AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgEGAAAAfJKVL07sbkmIfHqjbDnRgQADK5c4ngAAAA== + name : My Work Calendars + ``` + + + + + ```csv + id,name,classId,changeKey + AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgEGAAAAfJKVL07sbkmIfHqjbDnRgQADK5c4ngAAAA==,My Work Calendars,c02d4ddf-4809-485f-9cd4-1ef0937e03be,fJKVL07sbkmIfHqjbDnRgQADKqwfAA== + ``` + + + + + ```md + # outlook calendargroup add --debug "false" --verbose "false" --name "My Work Calendars" + + Date: 7/9/2026 + + ## My Work Calendars (AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgEGAAAAfJKVL07sbkmIfHqjbDnRgQADK5c4ngAAAA==) + + Property | Value + ---------|------- + id | AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgEGAAAAfJKVL07sbkmIfHqjbDnRgQADK5c4ngAAAA== + name | My Work Calendars + classId | c02d4ddf-4809-485f-9cd4-1ef0937e03be + changeKey | fJKVL07sbkmIfHqjbDnRgQADKqwfAA== + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 5ef3981c7ad..3cd196602a9 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1333,6 +1333,11 @@ const sidebars: SidebarsConfig = { }, { calendargroup: [ + { + type: 'doc', + label: 'calendargroup add', + id: 'cmd/outlook/calendargroup/calendargroup-add' + }, { type: 'doc', label: 'calendargroup list', diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts index c4a8efe9ab6..5abae61172b 100644 --- a/src/m365/outlook/commands.ts +++ b/src/m365/outlook/commands.ts @@ -6,6 +6,7 @@ export default { CALENDAR_LIST: `${prefix} calendar list`, CALENDAR_REMOVE: `${prefix} calendar remove`, CALENDAR_SET: `${prefix} calendar set`, + CALENDARGROUP_ADD: `${prefix} calendargroup add`, CALENDARGROUP_GET: `${prefix} calendargroup get`, CALENDARGROUP_LIST: `${prefix} calendargroup list`, CALENDARGROUP_REMOVE: `${prefix} calendargroup remove`, diff --git a/src/m365/outlook/commands/calendargroup/calendargroup-add.spec.ts b/src/m365/outlook/commands/calendargroup/calendargroup-add.spec.ts new file mode 100644 index 00000000000..312e14be960 --- /dev/null +++ b/src/m365/outlook/commands/calendargroup/calendargroup-add.spec.ts @@ -0,0 +1,320 @@ +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-add.js'; + +describe(commands.CALENDARGROUP_ADD, () => { + const calendarGroupName = 'My Work Calendars'; + const userId = 'b743445a-112c-4fda-9afd-05943f9c7b36'; + const userName = 'john.doe@contoso.com'; + const currentUserId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const currentUserName = 'current.user@contoso.com'; + const response = { + "id": "AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgEGAAAAfJKVL07sbkmIfHqjbDnRgQADK5c4ngAAAA==", + "name": "My Work Calendars", + "classId": "c02d4ddf-4809-485f-9cd4-1ef0937e03be", + "changeKey": "fJKVL07sbkmIfHqjbDnRgQADKqwfAA==" + }; + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + 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; + 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); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns([]); + sinon.stub(accessToken, 'getUserIdFromAccessToken').returns(currentUserId); + sinon.stub(accessToken, 'getUserNameFromAccessToken').returns(currentUserName); + }); + + afterEach(() => { + sinonUtil.restore([ + accessToken.isAppOnlyAccessToken, + accessToken.getScopesFromAccessToken, + accessToken.getUserIdFromAccessToken, + accessToken.getUserNameFromAccessToken, + calendarGroup.getUserCalendarGroupByName, + request.get, + request.post + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CALENDARGROUP_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('passes validation with name and userId', () => { + const actual = commandOptionsSchema.safeParse({ name: calendarGroupName, userId: userId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with name and userName', () => { + const actual = commandOptionsSchema.safeParse({ name: calendarGroupName, userName: userName }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation if both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ name: calendarGroupName, userId: userId, userName: userName }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if name is specified', () => { + const actual = commandOptionsSchema.safeParse({ userId: userId }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ name: calendarGroupName, userId: 'foo' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userName is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ name: calendarGroupName, userName: 'foo' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ name: calendarGroupName, unknownOption: 'value' }); + assert.notStrictEqual(actual.success, true); + }); + + it('creates a calendar group for the signed-in user', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/calendarGroups') { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('creates a calendar group for the signed-in user (verbose)', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/calendarGroups') { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, verbose: true }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('creates a calendar group for a user specified by userId using app-only permissions (verbose)', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userId: userId, verbose: true }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('creates a calendar group for a user specified by userId using delegated permissions (verbose)', async () => { + sinonUtil.restore(accessToken.getScopesFromAccessToken); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns(['Calendars.ReadWrite.Shared']); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userId: userId, verbose: true }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('creates a calendar group for a user specified by userId using app-only permissions', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userId: userId }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('creates a calendar group for a user specified by userName using app-only permissions', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('john.doe%40contoso.com')/calendarGroups`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userName: userName }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('creates a calendar group for a user specified by userId using delegated permissions with Calendars.ReadWrite.Shared scope', async () => { + sinonUtil.restore(accessToken.getScopesFromAccessToken); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns(['Calendars.ReadWrite.Shared']); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userId: userId }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('creates a calendar group for a user specified by userName using delegated permissions with Calendars.ReadWrite.Shared scope', async () => { + sinonUtil.restore(accessToken.getScopesFromAccessToken); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns(['Calendars.ReadWrite.Shared']); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('john.doe%40contoso.com')/calendarGroups`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userName: userName }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('does not check shared scope when userId matches the signed-in user', async () => { + sinonUtil.restore(accessToken.getUserIdFromAccessToken); + sinon.stub(accessToken, 'getUserIdFromAccessToken').returns(userId); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userId: userId }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('does not check shared scope when userName matches the signed-in user', async () => { + sinonUtil.restore(accessToken.getUserNameFromAccessToken); + sinon.stub(accessToken, 'getUserNameFromAccessToken').returns(userName); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('john.doe%40contoso.com')/calendarGroups`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userName: userName }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + 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({ name: calendarGroupName }) }), + new CommandError('When running with application permissions either userId or userName is required.') + ); + }); + + it('throws error when using delegated permissions with other userId without shared scope', async () => { + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userId: userId }) }), + new CommandError('To create calendar groups of other users, the Entra ID application used for authentication must have the Calendars.ReadWrite.Shared delegated permission assigned.') + ); + }); + + it('throws error when using delegated permissions with other userName without shared scope', async () => { + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userName: userName }) }), + new CommandError('To create calendar groups of other users, the Entra ID application used for authentication must have the Calendars.ReadWrite.Shared delegated permission assigned.') + ); + }); + + it('correctly handles API OData error', async () => { + const errorMessage = 'Something went wrong'; + sinon.stub(request, 'post').rejects({ error: { error: { message: errorMessage } } }); + + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName }) }), + new CommandError(errorMessage) + ); + }); +}); diff --git a/src/m365/outlook/commands/calendargroup/calendargroup-add.ts b/src/m365/outlook/commands/calendargroup/calendargroup-add.ts new file mode 100644 index 00000000000..ebba76205d1 --- /dev/null +++ b/src/m365/outlook/commands/calendargroup/calendargroup-add.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import { Logger } from '../../../../cli/Logger.js'; +import commands from '../../commands.js'; +import { validation } from '../../../../utils/validation.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import auth from '../../../../Auth.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { CalendarGroup } from '@microsoft/microsoft-graph-types'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + name: z.string(), + 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() +}); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class OutlookCalendarGroupAddCommand extends GraphCommand { + public get name(): string { + return commands.CALENDARGROUP_ADD; + } + + public get description(): string { + return 'Creates a new calendar group for a user'; + } + + public get schema(): z.ZodType | undefined { + 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 { + try { + const token = auth.connection.accessTokens[auth.defaultResource].accessToken; + const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(token); + + let userUrl: string; + let graphUserId: string; + + if (isAppOnlyAccessToken) { + if (!args.options.userId && !args.options.userName) { + throw 'When running with application permissions either userId or userName is required.'; + } + + graphUserId = (args.options.userId ?? args.options.userName)!; + userUrl = `${this.resource}/v1.0/users('${formatting.encodeQueryParameter(graphUserId)}')`; + + if (this.verbose) { + await logger.logToStderr(`Creating calendar group using application permissions for user '${graphUserId}'...`); + } + } + else if (args.options.userId || args.options.userName) { + const currentUserId = accessToken.getUserIdFromAccessToken(token); + const currentUserName = accessToken.getUserNameFromAccessToken(token); + const isOtherUser = (args.options.userId && args.options.userId !== currentUserId) || + (args.options.userName && args.options.userName.toLowerCase() !== currentUserName?.toLowerCase()); + + if (isOtherUser) { + const scopes = accessToken.getScopesFromAccessToken(token); + const hasSharedScope = scopes.some(s => s === 'Calendars.ReadWrite.Shared'); + + if (!hasSharedScope) { + throw `To create calendar groups of other users, the Entra ID application used for authentication must have the Calendars.ReadWrite.Shared delegated permission assigned.`; + } + } + + graphUserId = (args.options.userId ?? args.options.userName)!; + userUrl = `${this.resource}/v1.0/users('${formatting.encodeQueryParameter(graphUserId)}')`; + + if (this.verbose) { + await logger.logToStderr(`Creating calendar group using delegated permissions for user '${graphUserId}'...`); + } + } + else { + graphUserId = accessToken.getUserIdFromAccessToken(token); + userUrl = `${this.resource}/v1.0/me`; + + if (this.verbose) { + await logger.logToStderr('Creating calendar group for the signed-in user...'); + } + } + + const requestOptions: CliRequestOptions = { + url: `${userUrl}/calendarGroups`, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json' + }, + responseType: 'json', + data: { + name: args.options.name + } + }; + + const result = await request.post(requestOptions); + await logger.log(result); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new OutlookCalendarGroupAddCommand();