diff --git a/docs/docs/cmd/entra/group/group-member-add.mdx b/docs/docs/cmd/entra/group/group-member-add.mdx index 8b7cdcbc8e5..1bda4a25025 100644 --- a/docs/docs/cmd/entra/group/group-member-add.mdx +++ b/docs/docs/cmd/entra/group/group-member-add.mdx @@ -23,10 +23,19 @@ m365 entra group member add [options] : The display name of the Microsoft Entra group. Specify `groupId`, `groupDisplayName` or `groupName` but not multiple. `--ids [ids]` -: Microsoft Entra IDs of users. You can also pass a comma-separated list of IDs. Specify either `ids` or `userNames` but not both. +: (deprecated. Use option `userIds` instead) Microsoft Entra IDs of users. You can also pass a comma-separated list of IDs. Specify either `ids`, `userIds`,`userNames`, `subgroupIds`, or `subgroupNames` but not multiple. + +`--userIds [userIds]` +: Microsoft Entra IDs of users. You can also pass a comma-separated list of IDs. Specify either `ids`, `userIds`,`userNames`, `subgroupIds`, or `subgroupNames` but not multiple. `--userNames [userNames]` -: The user principal names of users. You can also pass a comma-separated list of UPNs. Specify either `ids` or `userNames` but not both. +: The user principal names of users. You can also pass a comma-separated list of UPNs. Specify either `ids`, `userIds`,`userNames`, `subgroupIds`, or `subgroupNames` but not multiple. + +`--subgroupIds [subgroupIds]` +: Comma-separated list of Microsoft Entra group IDs to add. Specify either `ids`, `userIds`,`userNames`, `subgroupIds`, or `subgroupNames` but not multiple. + +`--subgroupNames [subgroupNames]` +: Comma-separated list of Microsoft Entra group titles to add. Specify either `ids`, `userIds`,`userNames`, `subgroupIds`, or `subgroupNames` but not multiple. `-r, --role ` : The role to be assigned to the new users. Valid values: `Owner`, `Member`. @@ -39,19 +48,19 @@ m365 entra group member add [options] Add a single member specified by ID as a member to a group specified by display name. ```sh -m365 entra group member add --groupDisplayName Developers --ids 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member +m365 entra group member add --groupDisplayName Developers --userIds 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member ``` Add a single member specified by ID as a member to a group specified by group name. ```sh -m365 entra group member add --groupName Developers --ids 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member +m365 entra group member add --groupName Developers --userIds 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member ``` Add multiple members specified by ID as members to a group specified by ID. ```sh -m365 entra group member add --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --ids "098b9f52-f48c-4401-819f-29c33794c3f5,f1e06e31-3abf-4746-83c2-1513d71f38b8" --role Member +m365 entra group member add --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --userIds "098b9f52-f48c-4401-819f-29c33794c3f5,f1e06e31-3abf-4746-83c2-1513d71f38b8" --role Member ``` Add a single member specified by UPN as an owner to a group specified by display name. @@ -72,6 +81,18 @@ Adds multiple members specified by UPN as owners to a group specified by ID. m365 entra group member add --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --userNames "john.doe@contoso.com,adele.vance@contoso.com" --role Owner ``` +Add multiple members (subgroups) specified by ID as members to a group specified by ID. + +```sh +m365 entra group member add --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --subgroupIds "098b9f52-f48c-4401-819f-29c33794c3f5,f1e06e31-3abf-4746-83c2-1513d71f38b8" --role Member +``` + +Adds multiple members (subgroups) specified by name as members to a group specified by ID. + +```sh +m365 entra group member add --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --subgroupNames "john.doe@contoso.com,adele.vance@contoso.com" --role Member +``` + ## Response The command won't return a response on success. diff --git a/docs/docs/cmd/entra/group/group-member-set.mdx b/docs/docs/cmd/entra/group/group-member-set.mdx index 91e26dd8666..efca5ebbbf8 100644 --- a/docs/docs/cmd/entra/group/group-member-set.mdx +++ b/docs/docs/cmd/entra/group/group-member-set.mdx @@ -23,7 +23,10 @@ m365 entra group member set [options] : The display name of the Microsoft Entra group. Specify `groupId`, `groupDisplayName` or `groupName` but not multiple. `--ids [ids]` -: Comma-separated list of user IDs. Specify either `ids` or `userNames` but not both. +: (deprecated. Use option `userIds` instead) Comma-separated list of user IDs. Specify either `ids`, `userIds` or `userNames` but not multiple. + +`--userIds [userIds]` +: Comma-separated list of user IDs. Specify either `ids`, `userIds` or `userNames` but not multiple. `--userNames [userNames]` : The user principal names of users. You can also pass a comma-separated list of UPNs. Specify either `ids` or `userNames` but not both. @@ -39,19 +42,19 @@ m365 entra group member set [options] Update a single member specified by ID to a member of a group specified by display name ```sh -m365 entra group member set --groupDisplayName Developers --ids 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member +m365 entra group member set --groupDisplayName Developers --userIds 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member ``` Update a single member specified by ID to a member of a group specified by group name ```sh -m365 entra group member set --groupName Developers --ids 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member +m365 entra group member set --groupName Developers --userIds 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member ``` Update multiple members specified by ID to members of a group specified by ID ```sh -m365 entra group member set --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --ids "098b9f52-f48c-4401-819f-29c33794c3f5,f1e06e31-3abf-4746-83c2-1513d71f38b8" --role Member +m365 entra group member set --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --userIds "098b9f52-f48c-4401-819f-29c33794c3f5,f1e06e31-3abf-4746-83c2-1513d71f38b8" --role Member ``` Update a single member specified by UPN to an owner of a group specified by display name diff --git a/src/m365/entra/commands/group/group-member-add.spec.ts b/src/m365/entra/commands/group/group-member-add.spec.ts index 7476637bc8d..58240ec1a76 100644 --- a/src/m365/entra/commands/group/group-member-add.spec.ts +++ b/src/m365/entra/commands/group/group-member-add.spec.ts @@ -19,6 +19,8 @@ describe(commands.GROUP_MEMBER_ADD, () => { const groupId = '630dfae3-6904-4154-acc2-812e11205351'; const userUpns = ['user1@contoso.com', 'user2@contoso.com', 'user3@contoso.com', 'user4@contoso.com', 'user5@contoso.com', 'user6@contoso.com', 'user7@contoso.com', 'user8@contoso.com', 'user9@contoso.com', 'user10@contoso.com', 'user11@contoso.com', 'user12@contoso.com', 'user13@contoso.com', 'user14@contoso.com', 'user15@contoso.com', 'user16@contoso.com', 'user17@contoso.com', 'user18@contoso.com', 'user19@contoso.com', 'user20@contoso.com', 'user21@contoso.com', 'user22@contoso.com', 'user23@contoso.com', 'user24@contoso.com', 'user25@contoso.com']; const userIds = ['3f2504e0-4f89-11d3-9a0c-0305e82c3301', '6dcd4ce0-4f89-11d3-9a0c-0305e82c3302', '9b76f130-4f89-11d3-9a0c-0305e82c3303', 'c835f5e0-4f89-11d3-9a0c-0305e82c3304', 'f4f3fa90-4f89-11d3-9a0c-0305e82c3305', '2230f6a0-4f8a-11d3-9a0c-0305e82c3306', '4f6df5b0-4f8a-11d3-9a0c-0305e82c3307', '7caaf4c0-4f8a-11d3-9a0c-0305e82c3308', 'a9e8f3d0-4f8a-11d3-9a0c-0305e82c3309', 'd726f2e0-4f8a-11d3-9a0c-0305e82c330a', '0484f1f0-4f8b-11d3-9a0c-0305e82c330b', '31e2f100-4f8b-11d3-9a0c-0305e82c330c', '5f40f010-4f8b-11d3-9a0c-0305e82c330d', '8c9eef20-4f8b-11d3-9a0c-0305e82c330e', 'b9fce030-4f8b-11d3-9a0c-0305e82c330f', 'e73cdf40-4f8b-11d3-9a0c-0305e82c3310', '1470ce50-4f8c-11d3-9a0c-0305e82c3311', '41a3cd60-4f8c-11d3-9a0c-0305e82c3312', '6ed6cc70-4f8c-11d3-9a0c-0305e82c3313', '9c09cb80-4f8c-11d3-9a0c-0305e82c3314', 'c93cca90-4f8c-11d3-9a0c-0305e82c3315', 'f66cc9a0-4f8c-11d3-9a0c-0305e82c3316', '2368c8b0-4f8d-11d3-9a0c-0305e82c3317', '5064c7c0-4f8d-11d3-9a0c-0305e82c3318', '7d60c6d0-4f8d-11d3-9a0c-0305e82c3319']; + const groupNames = ['group1', 'group2', 'group3', 'group4', 'group5', 'group6', 'group7', 'group8', 'group9', 'group10', 'group11', 'group12', 'group13', 'group14', 'group15', 'group16', 'group17', 'group18', 'group19', 'group20', 'group21', 'group22', 'group23', 'group24', 'group25']; + const groupIds = ['5acd04e0-1234-abcd-0a9c-000000000001', '5acd04e0-1234-abcd-0a9c-000000000002', '5acd04e0-1234-abcd-0a9c-000000000003', '5acd04e0-1234-abcd-0a9c-000000000004', '5acd04e0-1234-abcd-0a9c-000000000005', '5acd04e0-1234-abcd-0a9c-000000000006', '5acd04e0-1234-abcd-0a9c-000000000007', '5acd04e0-1234-abcd-0a9c-000000000008', '5acd04e0-1234-abcd-0a9c-000000000009', '5acd04e0-1234-abcd-0a9c-000000000010', '5acd04e0-1234-abcd-0a9c-000000000011', '5acd04e0-1234-abcd-0a9c-000000000012', '5acd04e0-1234-abcd-0a9c-000000000013', '5acd04e0-1234-abcd-0a9c-000000000014', '5acd04e0-1234-abcd-0a9c-000000000015', '5acd04e0-1234-abcd-0a9c-000000000016', '5acd04e0-1234-abcd-0a9c-000000000017', '5acd04e0-1234-abcd-0a9c-000000000018', '5acd04e0-1234-abcd-0a9c-000000000019', '5acd04e0-1234-abcd-0a9c-000000000020', '5acd04e0-1234-abcd-0a9c-000000000021', '5acd04e0-1234-abcd-0a9c-000000000022', '5acd04e0-1234-abcd-0a9c-000000000023', '5acd04e0-1234-abcd-0a9c-000000000024', '5acd04e0-1234-abcd-0a9c-000000000025']; let log: string[]; let logger: Logger; @@ -52,6 +54,7 @@ describe(commands.GROUP_MEMBER_ADD, () => { sinonUtil.restore([ request.post, entraGroup.getGroupIdByDisplayName, + entraGroup.getGroupIdsByDisplayNames, entraUser.getUserIdsByUpns ]); }); @@ -70,7 +73,7 @@ describe(commands.GROUP_MEMBER_ADD, () => { }); it('fails validation if groupId is not a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: 'foo', ids: userIds[0], role: 'Member' } }, commandInfo); + const actual = await command.validate({ options: { groupId: 'foo', userIds: userIds[0], role: 'Member' } }, commandInfo); assert.notStrictEqual(actual, true); }); @@ -79,13 +82,33 @@ describe(commands.GROUP_MEMBER_ADD, () => { assert.notStrictEqual(actual, true); }); + it('fails validation if userIds contains an invalid GUID', async () => { + const actual = await command.validate({ options: { groupId: groupId, userIds: `${userIds[0]},foo`, role: 'Member' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if subgroupIds contains an invalid GUID', async () => { + const actual = await command.validate({ options: { groupId: groupId, subgroupIds: `${groupIds[0]},foo`, role: 'Member' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + it('fails validation if userNames contains an invalid UPN', async () => { const actual = await command.validate({ options: { groupId: groupId, userNames: `${userUpns[0]},foo`, role: 'Member' } }, commandInfo); assert.notStrictEqual(actual, true); }); it('fails validation if role is not a valid role', async () => { - const actual = await command.validate({ options: { groupId: groupId, ids: userIds.join(','), role: 'foo' } }, commandInfo); + const actual = await command.validate({ options: { groupId: groupId, userIds: userIds.join(','), role: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if subgroups specified by ids are added as owners', async () => { + const actual = await command.validate({ options: { groupId: groupId, subgroupIds: groupIds.join(','), role: 'Owner' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if subgroups specified by names are added as owners', async () => { + const actual = await command.validate({ options: { groupId: groupId, subgroupNames: groupNames.join(','), role: 'Owner' } }, commandInfo); assert.notStrictEqual(actual, true); }); @@ -99,13 +122,33 @@ describe(commands.GROUP_MEMBER_ADD, () => { assert.strictEqual(actual, true); }); + it('passes validation when all required parameters are valid with userIds', async () => { + const actual = await command.validate({ options: { groupId: groupId, userIds: userIds.join(','), role: 'Member' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with userIds with leading spaces', async () => { + const actual = await command.validate({ options: { groupId: groupId, userIds: userIds.map(i => ' ' + i).join(','), role: 'Member' } }, commandInfo); + assert.strictEqual(actual, true); + }); + it('passes validation when all required parameters are valid with names', async () => { const actual = await command.validate({ options: { groupDisplayName: 'IT department', userNames: userUpns.join(','), role: 'Owner' } }, commandInfo); assert.strictEqual(actual, true); }); + it('passes validation when all required parameters are valid with group names', async () => { + const actual = await command.validate({ options: { groupDisplayName: 'IT department', subgroupNames: groupNames.join(','), role: 'Member' } }, commandInfo); + assert.strictEqual(actual, true); + }); + it('passes validation when all required parameters are valid with names with trailing spaces', async () => { - const actual = await command.validate({ options: { groupDisplayName: 'IT department', userNames: userUpns.map(u => u + ' ').join(','), role: 'Owner' } }, commandInfo); + const actual = await command.validate({ options: { groupDisplayName: 'IT department', userNames: userUpns.map(u => u + ' ').join(','), role: 'Member' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with group names with trailing spaces', async () => { + const actual = await command.validate({ options: { groupDisplayName: 'IT department', subgroupNames: groupNames.map(u => u + ' ').join(','), role: 'Member' } }, commandInfo); assert.strictEqual(actual, true); }); @@ -129,12 +172,38 @@ describe(commands.GROUP_MEMBER_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupDisplayName: 'IT department', ids: userIds.join(','), role: 'Member', verbose: true } }); + await command.action(logger, { options: { groupDisplayName: 'IT department', userIds: userIds.join(','), role: 'Member', verbose: true } }); assert(loggerErrSpy.calledWith(chalk.yellow(`Option 'groupDisplayName' is deprecated and will be removed in the next major release.`))); sinonUtil.restore(loggerErrSpy); }); + it(`correctly shows deprecation warning for option 'ids'`, async () => { + const chalk = (await import('chalk')).default; + const loggerErrSpy = sinon.spy(logger, 'logToStderr'); + + sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); + sinon.stub(entraUser, 'getUserIdsByUpns').resolves(userIds); + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupDisplayName: 'IT department', ids: userIds.join(','), role: 'Member', verbose: true } }); + assert(loggerErrSpy.calledWith(chalk.yellow(`Option 'ids' is deprecated and will be removed in the next major release. Please use 'userIds' instead.`))); + + sinonUtil.restore(loggerErrSpy); + }); + it('successfully adds users to the group with ids', async () => { const postStub = sinon.stub(request, 'post').callsFake(async opts => { if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { @@ -172,6 +241,43 @@ describe(commands.GROUP_MEMBER_ADD, () => { ]); }); + it('successfully adds users to the group with userIds', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupId: groupId, userIds: userIds.join(','), role: 'Member', verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [ + { + id: 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + }, + { + id: 21, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + } + ]); + }); + it('successfully adds users to the group with ids with trailing spaces', async () => { const postStub = sinon.stub(request, 'post').callsFake(async opts => { if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { @@ -210,6 +316,44 @@ describe(commands.GROUP_MEMBER_ADD, () => { ]); }); + it('successfully adds users to the group with userIds with trailing spaces', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + const ids = userIds.map(id => id + ' ').join(','); + await command.action(logger, { options: { groupId: groupId, userIds: ids, role: 'Member', verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [ + { + id: 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + }, + { + id: 21, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + } + ]); + }); + it('successfully adds users to the group with names', async () => { sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); sinon.stub(entraUser, 'getUserIdsByUpns').resolves(userIds); @@ -250,6 +394,43 @@ describe(commands.GROUP_MEMBER_ADD, () => { ]); }); + it('successfully adds users to the group with subgroupIds', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupId: groupId, subgroupIds: groupIds.join(','), role: 'Member', verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [ + { + id: 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': groupIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + }, + { + id: 21, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': groupIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + } + ]); + }); + it('successfully adds users to the group using groupName and userNames', async () => { sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); sinon.stub(entraUser, 'getUserIdsByUpns').resolves(userIds); @@ -331,6 +512,46 @@ describe(commands.GROUP_MEMBER_ADD, () => { ]); }); + it('successfully adds users to the group using groupName and subgroupNames', async () => { + sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); + sinon.stub(entraGroup, 'getGroupIdsByDisplayNames').resolves(groupIds); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupName: 'Contoso', subgroupNames: groupNames.join(','), role: 'Owner', verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [ + { + id: 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'owners@odata.bind': groupIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + }, + { + id: 21, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'owners@odata.bind': groupIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + } + ]); + }); + it('handles API error when adding users to a group', async () => { sinon.stub(request, 'post').callsFake(async opts => { if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { @@ -357,7 +578,7 @@ describe(commands.GROUP_MEMBER_ADD, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), role: 'Member' } }), + await assert.rejects(command.action(logger, { options: { groupId: groupId, userIds: userIds.join(','), role: 'Member' } }), new CommandError(`One or more added object references already exist for the following modified properties: 'members'.`)); }); }); diff --git a/src/m365/entra/commands/group/group-member-add.ts b/src/m365/entra/commands/group/group-member-add.ts index 4c735130dfe..14d2460bc75 100644 --- a/src/m365/entra/commands/group/group-member-add.ts +++ b/src/m365/entra/commands/group/group-member-add.ts @@ -16,7 +16,10 @@ interface Options extends GlobalOptions { groupDisplayName?: string; groupName?: string; ids?: string; + userIds?: string; userNames?: string; + subgroupIds?: string; + subgroupNames?: string; role: string; } @@ -48,7 +51,10 @@ class EntraGroupMemberAddCommand extends GraphCommand { groupDisplayName: typeof args.options.groupDisplayName !== 'undefined', groupName: typeof args.options.groupName !== 'undefined', ids: typeof args.options.ids !== 'undefined', - userNames: typeof args.options.userNames !== 'undefined' + userIds: typeof args.options.userIds !== 'undefined', + userNames: typeof args.options.userNames !== 'undefined', + subgroupIds: typeof args.options.subgroupIds !== 'undefined', + subgroupNames: typeof args.options.subgroupNames !== 'undefined' }); }); } @@ -67,9 +73,18 @@ class EntraGroupMemberAddCommand extends GraphCommand { { option: '--ids [ids]' }, + { + option: '--userIds [userIds]' + }, { option: '--userNames [userNames]' }, + { + option: '--subgroupIds [subgroupIds]' + }, + { + option: '--subgroupNames [subgroupNames]' + }, { option: '-r, --role ', autocomplete: this.roleValues @@ -91,6 +106,13 @@ class EntraGroupMemberAddCommand extends GraphCommand { } } + if (args.options.userIds) { + const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.userIds); + if (isValidGUIDArrayResult !== true) { + return `The following GUIDs are invalid for the option 'userIds': ${isValidGUIDArrayResult}.`; + } + } + if (args.options.userNames) { const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.userNames); if (isValidUPNArrayResult !== true) { @@ -98,6 +120,17 @@ class EntraGroupMemberAddCommand extends GraphCommand { } } + if (args.options.subgroupIds) { + const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.subgroupIds); + if (isValidGUIDArrayResult !== true) { + return `The following GUIDs are invalid for the option 'subgroupIds': ${isValidGUIDArrayResult}.`; + } + } + + if ((args.options.subgroupIds || args.options.subgroupNames) && args.options.role === 'Owner') { + return `Subgroups cannot be set as owners.`; + } + if (this.roleValues.indexOf(args.options.role) === -1) { return `Option 'role' must be one of the following values: ${this.roleValues.join(', ')}.`; } @@ -110,12 +143,12 @@ class EntraGroupMemberAddCommand extends GraphCommand { #initOptionSets(): void { this.optionSets.push( { options: ['groupId', 'groupDisplayName', 'groupName'] }, - { options: ['ids', 'userNames'] } + { options: ['ids', 'userIds', 'userNames', 'subgroupIds', 'subgroupNames'] } ); } #initTypes(): void { - this.types.string.push('groupId', 'groupDisplayName', 'groupName', 'ids', 'userNames', 'role'); + this.types.string.push('groupId', 'groupDisplayName', 'groupName', 'ids', 'userIds', 'userNames', 'subgroupIds', 'subgroupNames', 'role'); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -124,15 +157,19 @@ class EntraGroupMemberAddCommand extends GraphCommand { await this.warn(logger, `Option 'groupDisplayName' is deprecated and will be removed in the next major release.`); } + if (args.options.ids) { + await this.warn(logger, `Option 'ids' is deprecated and will be removed in the next major release. Please use 'userIds' instead.`); + } + if (this.verbose) { - await logger.logToStderr(`Adding member(s) ${args.options.ids || args.options.userNames} to group ${args.options.groupId || args.options.groupDisplayName || args.options.groupName}...`); + await logger.logToStderr(`Adding member(s) ${args.options.ids || args.options.userIds || args.options.userNames || args.options.subgroupIds || args.options.subgroupNames} to group ${ args.options.groupId || args.options.groupDisplayName || args.options.groupName }...`); } const groupId = await this.getGroupId(logger, args.options); - const userIds = await this.getUserIds(logger, args.options); + const objectIds = await this.getObjectIds(logger, args.options); - for (let i = 0; i < userIds.length; i += 400) { - const userIdsBatch = userIds.slice(i, i + 400); + for (let i = 0; i < objectIds.length; i += 400) { + const objectIdsBatch = objectIds.slice(i, i + 400); const requestOptions: CliRequestOptions = { url: `${this.resource}/v1.0/$batch`, headers: { @@ -144,8 +181,8 @@ class EntraGroupMemberAddCommand extends GraphCommand { } }; - for (let j = 0; j < userIdsBatch.length; j += 20) { - const userIdsChunk = userIdsBatch.slice(j, j + 20); + for (let j = 0; j < objectIdsBatch.length; j += 20) { + const objectIdsChunk = objectIdsBatch.slice(j, j + 20); requestOptions.data.requests.push({ id: j + 1, method: 'PATCH', @@ -154,7 +191,7 @@ class EntraGroupMemberAddCommand extends GraphCommand { 'content-type': 'application/json;odata.metadata=none' }, body: { - [`${args.options.role === 'Member' ? 'members' : 'owners'}@odata.bind`]: userIdsChunk.map(u => `${this.resource}/v1.0/directoryObjects/${u}`) + [`${args.options.role === 'Member' ? 'members' : 'owners'}@odata.bind`]: objectIdsChunk.map(u => `${this.resource}/v1.0/directoryObjects/${u}`) } }); } @@ -184,17 +221,41 @@ class EntraGroupMemberAddCommand extends GraphCommand { return entraGroup.getGroupIdByDisplayName(options.groupDisplayName! || options.groupName!); } + private async getObjectIds(logger: Logger, options: Options): Promise { + if (options.ids || options.userIds || options.userNames) { + return this.getUserIds(logger, options); + } + + return this.getGroupIds(logger, options); + } + private async getUserIds(logger: Logger, options: Options): Promise { if (options.ids) { return options.ids.split(',').map(i => i.trim()); } + if (options.userIds) { + return options.userIds.split(',').map(i => i.trim()); + } + if (this.verbose) { await logger.logToStderr('Retrieving ID(s) of user(s)...'); } return entraUser.getUserIdsByUpns(options.userNames!.split(',').map(u => u.trim())); } + + private async getGroupIds(logger: Logger, options: Options): Promise { + if (options.subgroupIds) { + return options.subgroupIds.split(',').map(i => i.trim()); + } + + if (this.verbose) { + await logger.logToStderr('Retrieving ID(s) of group(s)...'); + } + + return entraGroup.getGroupIdsByDisplayNames(options.subgroupNames!.split(',').map(u => u.trim())); + } } export default new EntraGroupMemberAddCommand(); \ No newline at end of file diff --git a/src/m365/entra/commands/group/group-member-set.spec.ts b/src/m365/entra/commands/group/group-member-set.spec.ts index 4eb7bcba05f..adc8274fcd0 100644 --- a/src/m365/entra/commands/group/group-member-set.spec.ts +++ b/src/m365/entra/commands/group/group-member-set.spec.ts @@ -79,6 +79,11 @@ describe(commands.GROUP_MEMBER_SET, () => { assert.notStrictEqual(actual, true); }); + it('fails validation if userIds contains an invalid GUID', async () => { + const actual = await command.validate({ options: { groupId: groupId, userIds: `${userIds[0]},foo`, role: 'Member' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + it('fails validation if userNames contains an invalid UPN', async () => { const actual = await command.validate({ options: { groupId: groupId, userNames: `${userUpns[0]},foo`, role: 'Member' } }, commandInfo); assert.notStrictEqual(actual, true); @@ -99,6 +104,16 @@ describe(commands.GROUP_MEMBER_SET, () => { assert.strictEqual(actual, true); }); + it('passes validation when all required parameters are valid with userIds', async () => { + const actual = await command.validate({ options: { groupId: groupId, userIds: userIds.join(','), role: 'Member' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with userIds with leading spaces', async () => { + const actual = await command.validate({ options: { groupId: groupId, userIds: userIds.map(i => ' ' + i).join(','), role: 'Member' } }, commandInfo); + assert.strictEqual(actual, true); + }); + it('passes validation when all required parameters are valid with names', async () => { const actual = await command.validate({ options: { groupDisplayName: 'IT department', userNames: userUpns.join(','), role: 'Owner' } }, commandInfo); assert.strictEqual(actual, true); @@ -158,12 +173,67 @@ describe(commands.GROUP_MEMBER_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupDisplayName: 'Contoso', ids: userIds.join(','), role: 'Member', verbose: true } }); + await command.action(logger, { options: { groupDisplayName: 'Contoso', userIds: userIds.join(','), role: 'Member', verbose: true } }); assert(loggerErrSpy.calledWith(chalk.yellow(`Option 'groupDisplayName' is deprecated and will be removed in the next major release.`))); sinonUtil.restore(loggerErrSpy); }); + it(`correctly shows deprecation warning for option 'ids'`, async () => { + const chalk = (await import('chalk')).default; + const loggerErrSpy = sinon.spy(logger, 'logToStderr'); + + sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); + sinon.stub(entraUser, 'getUserIdsByUpns').resolves(userIds); + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'GET') { + return { + responses: [ + { + id: userIds[0], + status: 200, + body: 1 + }, + { + id: userIds[2], + status: 200, + body: 1 + } + ] + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupDisplayName: 'Contoso', ids: userIds.join(','), role: 'Member', verbose: true } }); + assert(loggerErrSpy.calledWith(chalk.yellow(`Option 'ids' is deprecated and will be removed in the next major release. Please use 'userIds' instead.`))); + + sinonUtil.restore(loggerErrSpy); + }); + it('successfully updates roles for users with ids in the group', async () => { const postStub = sinon.stub(request, 'post').callsFake(async opts => { if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && @@ -297,6 +367,139 @@ describe(commands.GROUP_MEMBER_SET, () => { ]); }); + it('successfully updates roles for users with userIds in the group', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'GET') { + return { + responses: [ + { + id: userIds[0], + status: 200, + body: 1 + }, + { + id: userIds[2], + status: 200, + body: 1 + } + ] + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupId: groupId, userIds: userIds.join(','), role: 'Member', verbose: true } }); + assert.deepStrictEqual(postStub.firstCall.args[0].data.requests, [ + { + id: 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + }, + { + id: 21, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + } + ]); + }); + + it('successfully updates roles for users with userIds with trailing spaces in the group', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'GET') { + return { + responses: [ + { + id: userIds[0], + status: 200, + body: 1 + }, + { + id: userIds[2], + status: 200, + body: 1 + } + ] + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + const ids = userIds.map(id => id + ' ').join(','); + await command.action(logger, { options: { groupId: groupId, userIds: ids, role: 'Member', verbose: true } }); + assert.deepStrictEqual(postStub.firstCall.args[0].data.requests, [ + { + id: 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + }, + { + id: 21, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + } + ]); + }); + it('successfully updates roles for users with names in the group', async () => { sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); sinon.stub(entraUser, 'getUserIdsByUpns').resolves(userIds); diff --git a/src/m365/entra/commands/group/group-member-set.ts b/src/m365/entra/commands/group/group-member-set.ts index 35f2d97fddd..aef6519a570 100644 --- a/src/m365/entra/commands/group/group-member-set.ts +++ b/src/m365/entra/commands/group/group-member-set.ts @@ -16,6 +16,7 @@ interface Options extends GlobalOptions { groupDisplayName?: string; groupName?: string; ids?: string; + userIds?: string; userNames?: string; role: string; } @@ -48,6 +49,7 @@ class EntraGroupMemberSetCommand extends GraphCommand { groupDisplayName: typeof args.options.groupDisplayName !== 'undefined', groupName: typeof args.options.groupName !== 'undefined', ids: typeof args.options.ids !== 'undefined', + userIds: typeof args.options.userIds !== 'undefined', userNames: typeof args.options.userNames !== 'undefined' }); }); @@ -67,6 +69,9 @@ class EntraGroupMemberSetCommand extends GraphCommand { { option: '--ids [ids]' }, + { + option: '--userIds [userIds]' + }, { option: '--userNames [userNames]' }, @@ -91,6 +96,13 @@ class EntraGroupMemberSetCommand extends GraphCommand { } } + if (args.options.userIds) { + const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.userIds); + if (isValidGUIDArrayResult !== true) { + return `The following GUIDs are invalid for the option 'userIds': ${isValidGUIDArrayResult}.`; + } + } + if (args.options.userNames) { const isValidUserPrincipalNameArray = validation.isValidUserPrincipalNameArray(args.options.userNames); if (isValidUserPrincipalNameArray !== true) { @@ -110,12 +122,12 @@ class EntraGroupMemberSetCommand extends GraphCommand { #initOptionSets(): void { this.optionSets.push( { options: ['groupId', 'groupDisplayName', 'groupName'] }, - { options: ['ids', 'userNames'] } + { options: ['ids', 'userIds', 'userNames'] } ); } #initTypes(): void { - this.types.string.push('groupId', 'groupDisplayName', 'groupName', 'ids', 'userNames', 'role'); + this.types.string.push('groupId', 'groupDisplayName', 'groupName', 'ids', 'userIds', 'userNames', 'role'); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -124,8 +136,12 @@ class EntraGroupMemberSetCommand extends GraphCommand { await this.warn(logger, `Option 'groupDisplayName' is deprecated and will be removed in the next major release.`); } + if (args.options.ids) { + await this.warn(logger, `Option 'ids' is deprecated and will be removed in the next major release. Please use 'userIds' instead.`); + } + if (this.verbose) { - await logger.logToStderr(`Adding member(s) ${args.options.ids || args.options.userNames} to role ${args.options.role} of group ${args.options.groupId || args.options.groupDisplayName || args.options.groupName}...`); + await logger.logToStderr(`Adding member(s) ${args.options.ids || args.options.userIds || args.options.userNames} to role ${args.options.role} of group ${args.options.groupId || args.options.groupDisplayName || args.options.groupName}...`); } const groupId = await this.getGroupId(logger, args.options); @@ -160,6 +176,10 @@ class EntraGroupMemberSetCommand extends GraphCommand { return options.ids.split(',').map(i => i.trim()); } + if (options.userIds) { + return options.userIds.split(',').map(i => i.trim()); + } + if (this.verbose) { await logger.logToStderr('Retrieving ID(s) of user(s)...'); } diff --git a/src/utils/entraGroup.spec.ts b/src/utils/entraGroup.spec.ts index e8742c01f1d..8005b93396c 100644 --- a/src/utils/entraGroup.spec.ts +++ b/src/utils/entraGroup.spec.ts @@ -40,6 +40,7 @@ describe('utils/entraGroup', () => { sinonUtil.restore([ request.get, request.patch, + request.post, cli.getSettingWithDefaultValue, cli.handleMultipleResultsFound ]); @@ -351,4 +352,118 @@ describe('utils/entraGroup', () => { const actual = await entraGroup.isUnifiedGroup(validGroupId); assert.deepStrictEqual(actual, false); }); + + it('correctly gets group ids by display names', async () => { + const groupNames = ['group1', 'group2', 'group3', 'group4', 'group5', 'group6', 'group7', 'group8', 'group9', 'group10', 'group11', 'group12', 'group13', 'group14', 'group15', 'group16', 'group17', 'group18', 'group19', 'group20', 'group21', 'group22', 'group23', 'group24', 'group25']; + const groupIds = ['5acd04e0-1234-abcd-0a9c-000000000001', '5acd04e0-1234-abcd-0a9c-000000000002', '5acd04e0-1234-abcd-0a9c-000000000003', '5acd04e0-1234-abcd-0a9c-000000000004', '5acd04e0-1234-abcd-0a9c-000000000005', '5acd04e0-1234-abcd-0a9c-000000000006', '5acd04e0-1234-abcd-0a9c-000000000007', '5acd04e0-1234-abcd-0a9c-000000000008', '5acd04e0-1234-abcd-0a9c-000000000009', '5acd04e0-1234-abcd-0a9c-000000000010', '5acd04e0-1234-abcd-0a9c-000000000011', '5acd04e0-1234-abcd-0a9c-000000000012', '5acd04e0-1234-abcd-0a9c-000000000013', '5acd04e0-1234-abcd-0a9c-000000000014', '5acd04e0-1234-abcd-0a9c-000000000015', '5acd04e0-1234-abcd-0a9c-000000000016', '5acd04e0-1234-abcd-0a9c-000000000017', '5acd04e0-1234-abcd-0a9c-000000000018', '5acd04e0-1234-abcd-0a9c-000000000019', '5acd04e0-1234-abcd-0a9c-000000000020', '5acd04e0-1234-abcd-0a9c-000000000021', '5acd04e0-1234-abcd-0a9c-000000000022', '5acd04e0-1234-abcd-0a9c-000000000023', '5acd04e0-1234-abcd-0a9c-000000000024', '5acd04e0-1234-abcd-0a9c-000000000025']; + + let batch = -1; + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/$batch`) { + return { + responses: groupIds.slice(++batch * 20, batch * 20 + 20).map(groupId => ({ + status: 200, + body: { + value: [{ + id: groupId + }] + } + })) + }; + } + + throw 'Invalid request'; + }); + + const actual = await entraGroup.getGroupIdsByDisplayNames(groupNames); + assert.deepStrictEqual(postStub.firstCall.args[0].data.requests, groupNames.slice(0, 20).map((name, i) => ({ id: i + 1, method: 'GET', url: `/groups?$filter=displayName eq '${formatting.encodeQueryParameter(name)}'&$select=id`, headers: { accept: 'application/json;odata.metadata=none' } }))); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, groupNames.slice(20, 40).map((name, i) => ({ id: i + 1, method: 'GET', url: `/groups?$filter=displayName eq '${formatting.encodeQueryParameter(name)}'&$select=id`, headers: { accept: 'application/json;odata.metadata=none' } }))); + assert.deepStrictEqual(actual, groupIds); + }); + + it('correctly throws error when no group was found with a specific display name', async () => { + const groupNames = ['group1', 'group2', 'group3', 'group4', 'group5', 'group6', 'group7', 'group8', 'group9', 'group10', 'group11', 'group12', 'group13', 'group14', 'group15', 'group16', 'group17', 'group18', 'group19', 'group20', 'group21', 'group22', 'group23', 'group24', 'group25']; + const groupIds = ['5acd04e0-1234-abcd-0a9c-000000000001', '5acd04e0-1234-abcd-0a9c-000000000002', '5acd04e0-1234-abcd-0a9c-000000000003', '5acd04e0-1234-abcd-0a9c-000000000004', '5acd04e0-1234-abcd-0a9c-000000000005', '5acd04e0-1234-abcd-0a9c-000000000006', '5acd04e0-1234-abcd-0a9c-000000000007', '5acd04e0-1234-abcd-0a9c-000000000008', '5acd04e0-1234-abcd-0a9c-000000000009', '5acd04e0-1234-abcd-0a9c-000000000010', '5acd04e0-1234-abcd-0a9c-000000000011', '5acd04e0-1234-abcd-0a9c-000000000012', '5acd04e0-1234-abcd-0a9c-000000000013', '5acd04e0-1234-abcd-0a9c-000000000014', '5acd04e0-1234-abcd-0a9c-000000000015', '5acd04e0-1234-abcd-0a9c-000000000016', '5acd04e0-1234-abcd-0a9c-000000000017', '5acd04e0-1234-abcd-0a9c-000000000018', '5acd04e0-1234-abcd-0a9c-000000000019', '5acd04e0-1234-abcd-0a9c-000000000020', '5acd04e0-1234-abcd-0a9c-000000000021', '5acd04e0-1234-abcd-0a9c-000000000022', '5acd04e0-1234-abcd-0a9c-000000000023', '5acd04e0-1234-abcd-0a9c-000000000024', '5acd04e0-1234-abcd-0a9c-000000000025']; + + let counter = 0; + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/$batch`) { + return { + responses: groupIds.slice(counter, counter + 20).map(groupId => { + if (counter++ < groupNames.length - 1) { + return { + status: 200, + body: { + value: [{ + id: groupId + }] + } + }; + } + else { + return { + id: counter % 20, + status: 200, + body: { + value: [] + } + }; + } + }) + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(entraGroup.getGroupIdsByDisplayNames(groupNames), Error(`The specified group with name '${groupNames[groupNames.length - 1]}' does not exist.`)); + }); + + it('handles selecting single result when multiple groups were found with a specific display name and cli is set to prompt with specified properties', async () => { + const groupNames = ['group1', 'group2', 'group3', 'group4', 'group5', 'group6', 'group7', 'group8', 'group9', 'group10', 'group11', 'group12', 'group13', 'group14', 'group15', 'group16', 'group17', 'group18', 'group19', 'group20', 'group21', 'group22', 'group23', 'group24', 'group25']; + const groupIds = ['5acd04e0-1234-abcd-0a9c-000000000001', '5acd04e0-1234-abcd-0a9c-000000000002', '5acd04e0-1234-abcd-0a9c-000000000003', '5acd04e0-1234-abcd-0a9c-000000000004', '5acd04e0-1234-abcd-0a9c-000000000005', '5acd04e0-1234-abcd-0a9c-000000000006', '5acd04e0-1234-abcd-0a9c-000000000007', '5acd04e0-1234-abcd-0a9c-000000000008', '5acd04e0-1234-abcd-0a9c-000000000009', '5acd04e0-1234-abcd-0a9c-000000000010', '5acd04e0-1234-abcd-0a9c-000000000011', '5acd04e0-1234-abcd-0a9c-000000000012', '5acd04e0-1234-abcd-0a9c-000000000013', '5acd04e0-1234-abcd-0a9c-000000000014', '5acd04e0-1234-abcd-0a9c-000000000015', '5acd04e0-1234-abcd-0a9c-000000000016', '5acd04e0-1234-abcd-0a9c-000000000017', '5acd04e0-1234-abcd-0a9c-000000000018', '5acd04e0-1234-abcd-0a9c-000000000019', '5acd04e0-1234-abcd-0a9c-000000000020', '5acd04e0-1234-abcd-0a9c-000000000021', '5acd04e0-1234-abcd-0a9c-000000000022', '5acd04e0-1234-abcd-0a9c-000000000023', '5acd04e0-1234-abcd-0a9c-000000000024', '5acd04e0-1234-abcd-0a9c-000000000025']; + + let counter = 0; + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/$batch`) { + return { + responses: groupIds.slice(counter, counter + 20).map(groupId => { + if (counter++ < groupNames.length - 1) { + return { + status: 200, + body: { + value: [{ + id: groupId + }] + } + }; + } + else { + return { + id: counter % 20, + status: 200, + body: { + value: [{ + id: groupId + }, + { + id: '5acd04e0-1234-abcd-0a9c-000000000026' + }] + } + }; + } + }) + }; + } + + throw 'Invalid request'; + }); + + sinon.stub(cli, 'handleMultipleResultsFound').resolves({ id: '5acd04e0-1234-abcd-0a9c-000000000025' }); + + const actual = await entraGroup.getGroupIdsByDisplayNames(groupNames); + assert.deepStrictEqual(postStub.firstCall.args[0].data.requests, groupNames.slice(0, 20).map((name, i) => ({ id: i + 1, method: 'GET', url: `/groups?$filter=displayName eq '${formatting.encodeQueryParameter(name)}'&$select=id`, headers: { accept: 'application/json;odata.metadata=none' } }))); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, groupNames.slice(20, 40).map((name, i) => ({ id: i + 1, method: 'GET', url: `/groups?$filter=displayName eq '${formatting.encodeQueryParameter(name)}'&$select=id`, headers: { accept: 'application/json;odata.metadata=none' } }))); + assert.deepStrictEqual(actual, groupIds); + }); }); \ No newline at end of file diff --git a/src/utils/entraGroup.ts b/src/utils/entraGroup.ts index 006a8f75027..f6582b59c29 100644 --- a/src/utils/entraGroup.ts +++ b/src/utils/entraGroup.ts @@ -176,5 +176,53 @@ export const entraGroup = { const group = await request.get<{ groupTypes: string[] }>(requestOptions); return group.groupTypes!.some(type => type === 'Unified'); + }, + + /** + * Retrieve the IDs of groups by their display names. There is no guarantee that the order of the returned IDs will match the order of the specified names. + * @param names Array of group names. + * @returns Array of group IDs. + */ + async getGroupIdsByDisplayNames(names: string[]): Promise { + const groupIds: string[] = []; + + for (let i = 0; i < names.length; i += 20) { + const namesChunk = names.slice(i, i + 20); + const requestOptions: CliRequestOptions = { + url: `${graphResource}/v1.0/$batch`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: { + requests: namesChunk.map((name, index) => ({ + id: index + 1, + method: 'GET', + url: `/groups?$filter=displayName eq '${formatting.encodeQueryParameter(name)}'&$select=id`, + headers: { + accept: 'application/json;odata.metadata=none' + } + })) + } + }; + const res = await request.post<{ responses: { id: number; status: number; body: { value: [{ id: string }] } }[] }>(requestOptions); + + for (const response of res.responses) { + if (response.body.value.length === 1) { + groupIds.push(response.body.value[0].id); + } + else if (response.body.value.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('id', response.body.value); + const result = await cli.handleMultipleResultsFound(`Multiple groups with the name '${namesChunk[response.id - 1]}' found.`, resultAsKeyValuePair); + groupIds.push(result.id!); + //throw Error(`Multiple groups with the name '${namesChunk[response.id - 1]}' found.`); + } + else { + throw Error(`The specified group with name '${namesChunk[response.id - 1]}' does not exist.`); + } + } + } + + return groupIds; } }; \ No newline at end of file