From d002fb8d0bd69bb797026508aa494e2fcf5c3db7 Mon Sep 17 00:00:00 2001 From: Alejandro Gispert Date: Fri, 20 Mar 2026 16:57:10 +0100 Subject: [PATCH 1/2] Add outlook calendargroup get command. Closes #7111 Made-with: Cursor --- .../calendargroup/calendargroup-get.mdx | 125 +++++++ docs/src/config/sidebars.ts | 5 + src/m365/outlook/commands.ts | 1 + .../calendargroup/calendargroup-get.spec.ts | 313 ++++++++++++++++++ .../calendargroup/calendargroup-get.ts | 146 ++++++++ 5 files changed, 590 insertions(+) create mode 100644 docs/docs/cmd/outlook/calendargroup/calendargroup-get.mdx create mode 100644 src/m365/outlook/commands/calendargroup/calendargroup-get.spec.ts create mode 100644 src/m365/outlook/commands/calendargroup/calendargroup-get.ts diff --git a/docs/docs/cmd/outlook/calendargroup/calendargroup-get.mdx b/docs/docs/cmd/outlook/calendargroup/calendargroup-get.mdx new file mode 100644 index 00000000000..5cbd682f51a --- /dev/null +++ b/docs/docs/cmd/outlook/calendargroup/calendargroup-get.mdx @@ -0,0 +1,125 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# outlook calendargroup get + +Retrieves a calendar group for a user. + +## Usage + +```sh +m365 outlook calendargroup get [options] +``` + +## Options + +```md definition-list +`--id [id]` +: ID of the calendar group. Specify either `id` or `name`, but not both. + +`--name [name]` +: Name of the calendar group. 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. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |-----------------|----------------------------------------------------------------------------------------| + | Microsoft Graph | Calendars.ReadBasic, Calendars.Read, Calendars.Read.Shared, Calendars.ReadWrite.Shared | + + + + + | Resource | Permissions | + |-----------------|-------------------------------------| + | Microsoft Graph | Calendars.ReadBasic, Calendars.Read | + + + + +::::note + +When using delegated permissions, specifying `userId` or `userName` for a different user requires the `Calendars.Read.Shared` or `Calendars.ReadWrite.Shared` scope. When the specified user matches the signed-in user, no shared scope is needed. + +:::: + +## Examples + +Get the calendar group specified by name for the signed-in user. + +```sh +m365 outlook calendargroup get --name "Personal Events" +``` + +Get the calendar group specified by name for a user using application permissions. + +```sh +m365 outlook calendargroup get --name "Personal Events" --userId "44288f7d-7710-4293-8c8e-36f310ed2e6a" +``` + +Get the calendar group specified by id for a user using application permissions. + +```sh +m365 outlook calendargroup get --id "AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAEMAAAiIsqMbYjsT5e-T7KzowPTAAABuC34AAA=" --userId "44288f7d-7710-4293-8c8e-36f310ed2e6a" +``` + +## Response + + + + + ```json + { + "id": "id-value", + "name": "name-value", + "changeKey": "changeKey-value", + "classId": "classId-value" + } + ``` + + + + + ```text + id | name + ------------------------------------------------------------------ ---------------- + id-value name-value + ``` + + + + + ```csv + id,name + id-value,name-value + ``` + + + + + ```md + # outlook calendargroup get + + Date: 3/20/2026 + + Property | Value + ---------|------- + id | id-value + name | name-value + ``` + + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 55e13dca27c..26efcb92323 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1308,6 +1308,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'calendargroup list', id: 'cmd/outlook/calendargroup/calendargroup-list' + }, + { + type: 'doc', + label: 'calendargroup get', + id: 'cmd/outlook/calendargroup/calendargroup-get' } ] }, diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts index 79e8dc9f0d6..05a40f5d52b 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`, + CALENDARGROUP_GET: `${prefix} calendargroup get`, 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/calendargroup/calendargroup-get.spec.ts b/src/m365/outlook/commands/calendargroup/calendargroup-get.spec.ts new file mode 100644 index 00000000000..30486435dff --- /dev/null +++ b/src/m365/outlook/commands/calendargroup/calendargroup-get.spec.ts @@ -0,0 +1,313 @@ +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 { 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-get.js'; + +describe(commands.CALENDARGROUP_GET, () => { + const calendarGroupId = 'AAMkAGE0MGM1Y2M5LWEzMmUtNGVlNy05MjRlLTk0YmYyY2I5NTM3ZAAuAAAAAAC_0WfqSjt_SqLtNkuO-bj1AQAbfYq5lmBxQ6a4t1fGbeYAAAAAAEOAAA='; + const calendarGroupName = 'Personal Events'; + const resolvedCalendarGroupId = 'AAMkAGE0MGM1Y2M5LWEzMmUtNGVlNy05MjRlLTk0YmYyY2I5NTM3ZAAuAAAAAAC_0WfqSjt_SqLtNkuO-bj1AQAbfYq5lmBxQ6a4t1fGbeYAAAAAAEPAAA='; + const otherUserId = '44288f7d-7710-4293-8c8e-36f310ed2e6a'; + 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 calendarGroupResponse = { + id: calendarGroupId, + name: 'My Calendars', + changeKey: 'nfZyf7VcrEKLNoU37KWlkQAAA0x0+w==', + classId: '0006f0b7-0000-0000-c000-000000000046' + }; + + const calendarGroupsResponseForFilter = { + value: [ + { + id: resolvedCalendarGroupId, + name: calendarGroupName + } + ] + }; + + let logger: Logger; + let commandInfo: CommandInfo; + let loggerLogSpy: sinon.SinonSpy; + 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(() => { + logger = { + log: async () => undefined, + logRaw: async () => undefined, + logToStderr: async () => undefined + }; + + 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, + request.get + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CALENDARGROUP_GET); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('defines correct properties for the default output', () => { + assert.deepStrictEqual(command.defaultProperties(), ['id', 'name']); + }); + + 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('fails validation if both id and name are specified', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, name: calendarGroupName }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if neither id nor name is specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if id is empty', () => { + const actual = commandOptionsSchema.safeParse({ id: '' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if name is empty', () => { + const actual = commandOptionsSchema.safeParse({ name: '' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userId: 'foo' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userName is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userName: 'foo' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, userId: userId, userName: userName }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarGroupId, unknownOption: 'value' }); + assert.notStrictEqual(actual.success, true); + }); + + it('retrieves calendar group for the signed-in user by id using delegated permissions', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}`) { + return calendarGroupResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId }) }); + assert(loggerLogSpy.calledOnceWith(calendarGroupResponse)); + }); + + it('retrieves calendar group for the signed-in user by name using delegated permissions', async () => { + const expectedFilterUrl = `https://graph.microsoft.com/v1.0/me/calendarGroups?$select=id,name&$filter=name eq 'Personal%20Events'`; + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === expectedFilterUrl) { + return calendarGroupsResponseForFilter; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${resolvedCalendarGroupId}`) { + return calendarGroupResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName }) }); + assert(loggerLogSpy.calledOnceWith(calendarGroupResponse)); + }); + + it('retrieves calendar group for a user specified by id using app-only permissions', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups/${calendarGroupId}`) { + return calendarGroupResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, userId }) }); + assert(loggerLogSpy.calledOnceWith(calendarGroupResponse)); + }); + + 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 }) }), + new CommandError('When running with application permissions either userId or userName is required.') + ); + }); + + it('throws error when using delegated permissions for other users without shared scope', async () => { + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, userId: otherUserId }) }), + new CommandError(`To retrieve calendar groups of other users, the Entra ID application used for authentication must have either the Calendars.Read.Shared or Calendars.ReadWrite.Shared delegated permission assigned.`) + ); + }); + + it('retrieves calendar group for a user specified by id using delegated permissions with shared scope', async () => { + sinonUtil.restore(accessToken.getScopesFromAccessToken); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns(['Calendars.Read.Shared']); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${otherUserId}')/calendarGroups/${calendarGroupId}`) { + return calendarGroupResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, userId: otherUserId }) }); + assert(loggerLogSpy.calledOnceWith(calendarGroupResponse)); + }); + + it('retrieves calendar group for a user specified by name using delegated permissions with shared scope', async () => { + sinonUtil.restore(accessToken.getScopesFromAccessToken); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns(['Calendars.Read.Shared']); + + const expectedFilterUrl = `https://graph.microsoft.com/v1.0/users('${otherUserId}')/calendarGroups?$select=id,name&$filter=name eq 'Personal%20Events'`; + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === expectedFilterUrl) { + return calendarGroupsResponseForFilter; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/users('${otherUserId}')/calendarGroups/${resolvedCalendarGroupId}`) { + return calendarGroupResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userId: otherUserId }) }); + assert(loggerLogSpy.calledOnceWith(calendarGroupResponse)); + }); + + it('retrieves calendar group for the signed-in user with verbose output', async () => { + const logToStderrSpy = sinon.spy(logger, 'logToStderr'); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}`) { + return calendarGroupResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarGroupId, verbose: true }) }); + + assert(loggerLogSpy.calledOnceWith(calendarGroupResponse)); + assert(logToStderrSpy.calledOnce); + }); + + it('throws an error when calendar group name does not match any results', async () => { + const expectedFilterUrl = `https://graph.microsoft.com/v1.0/me/calendarGroups?$select=id,name&$filter=name eq 'Personal%20Events'`; + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === expectedFilterUrl) { + return { value: [] }; + } + + throw 'Invalid request'; + }); + + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName }) }), + new CommandError(`The specified calendar group '${calendarGroupName}' does not exist.`) + ); + }); + + it('retrieves calendar group for a user specified by userName using delegated permissions with shared scope (ReadWrite.Shared)', async () => { + sinonUtil.restore(accessToken.getScopesFromAccessToken); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns(['Calendars.ReadWrite.Shared']); + + const expectedFilterUrl = `https://graph.microsoft.com/v1.0/users('${userName}')/calendarGroups?$select=id,name&$filter=name eq 'Personal%20Events'`; + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === expectedFilterUrl) { + return calendarGroupsResponseForFilter; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendarGroups/${resolvedCalendarGroupId}`) { + return calendarGroupResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ name: calendarGroupName, userName }) }); + assert(loggerLogSpy.calledOnceWith(calendarGroupResponse)); + }); +}); + diff --git a/src/m365/outlook/commands/calendargroup/calendargroup-get.ts b/src/m365/outlook/commands/calendargroup/calendargroup-get.ts new file mode 100644 index 00000000000..c9ade2b53a6 --- /dev/null +++ b/src/m365/outlook/commands/calendargroup/calendargroup-get.ts @@ -0,0 +1,146 @@ +import { CalendarGroup } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; +import { Logger } from '../../../../cli/Logger.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { validation } from '../../../../utils/validation.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { odata } from '../../../../utils/odata.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import auth from '../../../../Auth.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() +}); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class OutlookCalendarGroupGetCommand extends GraphCommand { + public get name(): string { + return commands.CALENDARGROUP_GET; + } + + public get description(): string { + return 'Retrieve information about a calendar group for a user'; + } + + public get schema(): z.ZodType | undefined { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(o => !(o.id && o.name), { + error: 'Specify either id or name, but not both.' + }) + .refine(o => Boolean(o.id || o.name), { + error: 'Specify either id or name.' + }) + .refine(o => !(o.userId && o.userName), { + error: 'Specify either userId or userName, but not both.' + }); + } + + public defaultProperties(): string[] | undefined { + return ['id', 'name']; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + const token = auth.connection.accessTokens[auth.defaultResource].accessToken; + const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(token); + + // Determine user identifier and whether the user explicitly requested "other user". + let userIdentifier: string | undefined = undefined; + if (args.options.userId || args.options.userName) { + userIdentifier = args.options.userId ?? args.options.userName; + } + + if (isAppOnlyAccessToken) { + if (!args.options.userId && !args.options.userName) { + throw 'When running with application permissions either userId or userName is required.'; + } + } + 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.Read.Shared' || s === 'Calendars.ReadWrite.Shared'); + if (!hasSharedScope) { + throw `To retrieve calendar groups of other users, the Entra ID application used for authentication must have either the Calendars.Read.Shared or Calendars.ReadWrite.Shared delegated permission assigned.`; + } + } + } + } + + const getCalendarGroupId = async (calendarGroupName: string): Promise => { + const userPath = userIdentifier ? `users('${userIdentifier}')` : 'me'; + const calendarGroups = await odata.getAllItems( + `${this.resource}/v1.0/${userPath}/calendarGroups?$select=id,name&$filter=name eq '${formatting.encodeQueryParameter(calendarGroupName)}'` + ); + + if (calendarGroups.length === 0) { + throw `The specified calendar group '${calendarGroupName}' does not exist.`; + } + + // Graph guarantees unique calendarGroupId; for duplicate names, return the first match. + return calendarGroups[0].id!; + }; + + // Schema guarantees exactly one of `id` or `name` is present, + // so avoid ternaries/undefined paths to keep coverage deterministic. + let calendarGroupId: string; + if (args.options.id) { + calendarGroupId = args.options.id; + } + else { + calendarGroupId = await getCalendarGroupId(args.options.name!); + } + + // For delegated access without userId/userName: use /me. + const userPath = userIdentifier ? `users('${userIdentifier}')` : 'me'; + const requestUrl = `${this.resource}/v1.0/${userPath}/calendarGroups/${calendarGroupId}`; + + if (this.verbose) { + await logger.logToStderr(`Retrieving calendar group '${calendarGroupId}'...`); + } + + const requestOptions: CliRequestOptions = { + url: requestUrl, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + const result = await request.get(requestOptions); + await logger.log(result); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new OutlookCalendarGroupGetCommand(); + From bc70128c9e0213e50629fbc908a2d264451205f3 Mon Sep 17 00:00:00 2001 From: Alejandro Gispert Date: Fri, 20 Mar 2026 17:45:10 +0100 Subject: [PATCH 2/2] Refactor OutlookCalendarGroupGetCommand to use encoded user identifiers for API requests - Updated the command to encode user identifiers before constructing API URLs. - Adjusted test cases to reflect changes in user identifier handling. --- .../calendargroup/calendargroup-get.spec.ts | 6 ++++-- .../commands/calendargroup/calendargroup-get.ts | 15 +++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/m365/outlook/commands/calendargroup/calendargroup-get.spec.ts b/src/m365/outlook/commands/calendargroup/calendargroup-get.spec.ts index 30486435dff..4ea18f2d0e8 100644 --- a/src/m365/outlook/commands/calendargroup/calendargroup-get.spec.ts +++ b/src/m365/outlook/commands/calendargroup/calendargroup-get.spec.ts @@ -11,6 +11,7 @@ import { accessToken } from '../../../../utils/accessToken.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { formatting } from '../../../../utils/formatting.js'; import commands from '../../commands.js'; import command, { options } from './calendargroup-get.js'; @@ -293,13 +294,14 @@ describe(commands.CALENDARGROUP_GET, () => { sinonUtil.restore(accessToken.getScopesFromAccessToken); sinon.stub(accessToken, 'getScopesFromAccessToken').returns(['Calendars.ReadWrite.Shared']); - const expectedFilterUrl = `https://graph.microsoft.com/v1.0/users('${userName}')/calendarGroups?$select=id,name&$filter=name eq 'Personal%20Events'`; + const encodedUserName = formatting.encodeQueryParameter(userName); + const expectedFilterUrl = `https://graph.microsoft.com/v1.0/users('${encodedUserName}')/calendarGroups?$select=id,name&$filter=name eq 'Personal%20Events'`; sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === expectedFilterUrl) { return calendarGroupsResponseForFilter; } - if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendarGroups/${resolvedCalendarGroupId}`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${encodedUserName}')/calendarGroups/${resolvedCalendarGroupId}`) { return calendarGroupResponse; } diff --git a/src/m365/outlook/commands/calendargroup/calendargroup-get.ts b/src/m365/outlook/commands/calendargroup/calendargroup-get.ts index c9ade2b53a6..cb61860069a 100644 --- a/src/m365/outlook/commands/calendargroup/calendargroup-get.ts +++ b/src/m365/outlook/commands/calendargroup/calendargroup-get.ts @@ -64,12 +64,15 @@ class OutlookCalendarGroupGetCommand extends GraphCommand { const token = auth.connection.accessTokens[auth.defaultResource].accessToken; const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(token); - // Determine user identifier and whether the user explicitly requested "other user". let userIdentifier: string | undefined = undefined; if (args.options.userId || args.options.userName) { userIdentifier = args.options.userId ?? args.options.userName; } + const encodedUserIdentifier: string | undefined = userIdentifier + ? formatting.encodeQueryParameter(userIdentifier) + : undefined; + if (isAppOnlyAccessToken) { if (!args.options.userId && !args.options.userName) { throw 'When running with application permissions either userId or userName is required.'; @@ -94,7 +97,7 @@ class OutlookCalendarGroupGetCommand extends GraphCommand { } const getCalendarGroupId = async (calendarGroupName: string): Promise => { - const userPath = userIdentifier ? `users('${userIdentifier}')` : 'me'; + const userPath = encodedUserIdentifier ? `users('${encodedUserIdentifier}')` : 'me'; const calendarGroups = await odata.getAllItems( `${this.resource}/v1.0/${userPath}/calendarGroups?$select=id,name&$filter=name eq '${formatting.encodeQueryParameter(calendarGroupName)}'` ); @@ -103,12 +106,8 @@ class OutlookCalendarGroupGetCommand extends GraphCommand { throw `The specified calendar group '${calendarGroupName}' does not exist.`; } - // Graph guarantees unique calendarGroupId; for duplicate names, return the first match. return calendarGroups[0].id!; }; - - // Schema guarantees exactly one of `id` or `name` is present, - // so avoid ternaries/undefined paths to keep coverage deterministic. let calendarGroupId: string; if (args.options.id) { calendarGroupId = args.options.id; @@ -117,8 +116,8 @@ class OutlookCalendarGroupGetCommand extends GraphCommand { calendarGroupId = await getCalendarGroupId(args.options.name!); } - // For delegated access without userId/userName: use /me. - const userPath = userIdentifier ? `users('${userIdentifier}')` : 'me'; + + const userPath = encodedUserIdentifier ? `users('${encodedUserIdentifier}')` : 'me'; const requestUrl = `${this.resource}/v1.0/${userPath}/calendarGroups/${calendarGroupId}`; if (this.verbose) {