Skip to content

Commit

Permalink
f
Browse files Browse the repository at this point in the history
  • Loading branch information
milanholemans committed Jan 3, 2025
1 parent 9cf808b commit 498e01e
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 51 deletions.
32 changes: 22 additions & 10 deletions docs/docs/cmd/entra/group/group-member-remove.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,22 @@ m365 entra group member remove [options]

```md definition-list
`-i, --groupId [groupId]`
: The ID of the Entra ID group. Specify `groupId` or `groupDisplayName` but not both.
: The ID of the Entra ID group. Specify `groupId` or `groupName` but not both.

`-n, --groupDisplayName [groupDisplayName]`
: The display name of the Entra ID group. Specify `groupId` or `groupDisplayName` but not both.
`-n, --groupName [groupName]`
: The display name of the Entra ID group. Specify `groupId` or `groupName` but not both.

`--ids [ids]`
: Entra ID IDs of users. You can also pass a comma-separated list of IDs. Specify either `ids` or `userNames` but not both.
`--userIds [userIds]`
: Microsoft Entra user IDs. You can also pass a comma-separated list of IDs. Specify either `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 `userIds`, `userNames`, `subgroupIds` or `subgroupNames` but not multiple.

`--subgroupIds [subgroupIds]`
: Microsoft Entra group IDs. You can also pass a comma-separated list of IDs. Specify either `userIds`, `userNames`, `subgroupIds` or `subgroupNames` but not multiple.

`--subgroupNames [subgroupNames]`
: The display names of Microsoft Entra groups. You can also pass a comma-separated list of group display names. Specify either `userIds`, `userNames`, `subgroupIds` or `subgroupNames` but not multiple.

`-r, --role [role]`
: The role to be removed from the users. Valid values: `Owner`, `Member`. Defaults to both.
Expand Down Expand Up @@ -52,19 +58,19 @@ Without using this option, you would need to manually verify the user's role in
Remove a single user specified by ID as member from a group specified by display name

```sh
m365 entra group member remove --groupDisplayName Developers --ids 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member
m365 entra group member remove --groupName Developers --userIds 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member
```

Remove multiple users specified by ID from a group specified by ID

```sh
m365 entra group member remove --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --ids "098b9f52-f48c-4401-819f-29c33794c3f5,f1e06e31-3abf-4746-83c2-1513d71f38b8"
m365 entra group member remove --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --userIds "098b9f52-f48c-4401-819f-29c33794c3f5,f1e06e31-3abf-4746-83c2-1513d71f38b8"
```

Remove a single user specified by UPN as an owner from a group specified by display name

```sh
m365 entra group member remove --groupDisplayName Developers --userNames john.doe@contoso.com --role Owner
m365 entra group member remove --groupName Developers --userNames john.doe@contoso.com --role Owner
```

Remove multiple users specified by UPN from a group specified by ID
Expand All @@ -76,7 +82,13 @@ m365 entra group member remove --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --
Remove a single user specified by ID as owner and member of the group and suppress errors when the user was not found as owner or member

```sh
m365 entra group member remove --groupDisplayName Developers --ids 098b9f52-f48c-4401-819f-29c33794c3f5 --suppressNotFound
m365 entra group member remove --groupName Developers --userIds 098b9f52-f48c-4401-819f-29c33794c3f5 --suppressNotFound
```

Remove 2 nested groups referenced by id from a security group

```sh
m365 entra group member remove --groupName Developers --subgroupIds "b51b6157-839f-4d92-8dab-ac61b53c6c40,1e793f86-8dc6-4df6-8037-649ef9a22330" --role Member
```

## Response
Expand Down
103 changes: 85 additions & 18 deletions src/m365/entra/commands/group/group-member-remove.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
const groupId = '630dfae3-6904-4154-acc2-812e11205351';
const upns = ['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'];
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'];
const groupNames = ['HR', 'Marketing', 'IT'];
const groupIds = ['f64dc7f7-1a3e-4ba6-b4ee-491b282a3f84', '2e8641bb-9e9d-4da1-be52-d2a8394d3a85', '187a95ce-3e88-4051-87b9-ce19093975bf'];

let log: string[];
let logger: Logger;
Expand Down Expand Up @@ -71,12 +73,12 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
});

it('fails validation if groupId is not a valid GUID', async () => {
const actual = await command.validate({ options: { groupId: 'foo', ids: userIds[0] } }, commandInfo);
const actual = await command.validate({ options: { groupId: 'foo', userIds: userIds[0] } }, commandInfo);
assert.notStrictEqual(actual, true);
});

it('fails validation if ids contains an invalid GUID', async () => {
const actual = await command.validate({ options: { groupId: groupId, ids: `${userIds[0]},foo` } }, commandInfo);
it('fails validation if userIds contains an invalid GUID', async () => {
const actual = await command.validate({ options: { groupId: groupId, userIds: `${userIds[0]},foo` } }, commandInfo);
assert.notStrictEqual(actual, true);
});

Expand All @@ -85,35 +87,50 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
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 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 subgroupIds is specified without role option', async () => {
const actual = await command.validate({ options: { groupId: groupId, subgroupIds: groupIds[0] } }, commandInfo);
assert.notStrictEqual(actual, true);
});

it('fails validation if subgroupNames is specified with owner role', async () => {
const actual = await command.validate({ options: { groupId: groupId, subgroupIds: groupIds[0], role: 'Owner' } }, commandInfo);
assert.notStrictEqual(actual, true);
});

it('passes validation when all required parameters are valid with ids', async () => {
const actual = await command.validate({ options: { groupId: groupId, ids: userIds.join(',') } }, commandInfo);
const actual = await command.validate({ options: { groupId: groupId, userIds: userIds.join(',') } }, commandInfo);
assert.strictEqual(actual, true);
});

it('passes validation when all required parameters are valid with ids with leading spaces', async () => {
const actual = await command.validate({ options: { groupId: groupId, ids: userIds.map(i => ' ' + i).join(','), role: 'Member' } }, commandInfo);
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: upns.join(',') } }, commandInfo);
const actual = await command.validate({ options: { groupName: 'IT department', userNames: upns.join(',') } }, 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: upns.map(u => u + ' ').join(','), role: 'Owner' } }, commandInfo);
const actual = await command.validate({ options: { groupName: 'IT department', userNames: upns.map(u => u + ' ').join(','), role: 'Owner' } }, commandInfo);
assert.strictEqual(actual, true);
});

it('prompts before removing the specified users when confirm option not passed', async () => {
const confirmationStub = sinon.stub(cli, 'promptForConfirmation').resolves(false);

await command.action(logger, { options: { groupDisplayName: 'IT department', ids: userIds.join(',') } });
await command.action(logger, { options: { groupName: 'IT department', subgroupNames: groupNames.join(','), role: 'Member' } });

assert(confirmationStub.calledOnce);
});
Expand All @@ -123,7 +140,7 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {

const postSpy = sinon.stub(request, 'post').resolves();

await command.action(logger, { options: { groupId: groupId, ids: userIds.join(',') } });
await command.action(logger, { options: { groupId: groupId, userIds: userIds.join(',') } });
assert(postSpy.notCalled);
});

Expand All @@ -143,7 +160,7 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), verbose: true } });
await command.action(logger, { options: { groupId: groupId, userIds: userIds.join(','), verbose: true } });
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 20 }, (_, index) => ({
id: index + 1,
method: 'DELETE',
Expand All @@ -166,7 +183,7 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { groupId: groupId, ids: userIds.map(i => i + ' ').join(','), force: true, verbose: true } });
await command.action(logger, { options: { groupId: groupId, userIds: userIds.map(i => i + ' ').join(','), force: true, verbose: true } });
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 20 }, (_, index) => ({
id: index + 1,
method: 'DELETE',
Expand Down Expand Up @@ -194,7 +211,7 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { groupDisplayName: 'Contoso', userNames: upns.join(','), verbose: true } });
await command.action(logger, { options: { groupName: 'Contoso', userNames: upns.join(','), verbose: true } });
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 20 }, (_, index) => ({
id: index + 1,
method: 'DELETE',
Expand All @@ -220,7 +237,7 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { groupDisplayName: 'Contoso', userNames: upns.map(u => + ' ' + u).join(','), force: true, verbose: true } });
await command.action(logger, { options: { groupName: 'Contoso', userNames: upns.map(u => + ' ' + u).join(','), force: true, verbose: true } });
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 20 }, (_, index) => ({
id: index + 1,
method: 'DELETE',
Expand All @@ -243,7 +260,7 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), role: 'Owner', force: true, verbose: true } });
await command.action(logger, { options: { groupId: groupId, userIds: userIds.join(','), role: 'Owner', force: true, verbose: true } });
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
method: 'DELETE',
Expand All @@ -269,7 +286,7 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { groupDisplayName: 'Contoso', userNames: upns.join(','), role: 'Member', force: true, verbose: true } });
await command.action(logger, { options: { groupName: 'Contoso', userNames: upns.join(','), role: 'Member', force: true, verbose: true } });
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
method: 'DELETE',
Expand All @@ -278,6 +295,56 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
})));
});

it('successfully removes subgroups from the group by using IDs', async () => {
const postStub = sinon.stub(request, 'post').callsFake(async opts => {
if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') {
return {
responses: Array(3).fill({
status: 204,
body: {}
})
};
}

throw 'Invalid request';
});

await command.action(logger, { options: { groupId: groupId, subgroupIds: groupIds.join(','), role: 'Member', force: true, verbose: true } });
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 3 }, (_, index) => ({
id: index + 1,
method: 'DELETE',
url: `/groups/${groupId}/members/${groupIds[index]}/$ref`,
headers: { 'content-type': 'application/json;odata.metadata=none' }
})));
});

it('successfully removes subgroups from the group by using group names', async () => {
const entraGroupStub = sinon.stub(entraGroup, 'getGroupIdByDisplayName').callsFake(async () => {
return groupIds[entraGroupStub.callCount - 1];
});

const postStub = sinon.stub(request, 'post').callsFake(async opts => {
if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') {
return {
responses: Array(3).fill({
status: 204,
body: {}
})
};
}

throw 'Invalid request';
});

await command.action(logger, { options: { groupId: groupId, subgroupNames: groupNames.join(','), role: 'Member', force: true, verbose: true } });
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 3 }, (_, index) => ({
id: index + 1,
method: 'DELETE',
url: `/groups/${groupId}/members/${groupIds[index]}/$ref`,
headers: { 'content-type': 'application/json;odata.metadata=none' }
})));
});

it('handles API errors correctly', async () => {
const errorMessage = `Resource '${groupId}' does not exist or one of its queried reference-property objects are not present.`;

Expand Down Expand Up @@ -307,7 +374,7 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
throw 'Invalid request';
});

await assert.rejects(command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), force: true, verbose: true } }),
await assert.rejects(command.action(logger, { options: { groupId: groupId, userIds: userIds.join(','), force: true, verbose: true } }),
new CommandError(errorMessage));
});

Expand Down Expand Up @@ -340,7 +407,7 @@ describe(commands.GROUP_MEMBER_REMOVE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), suppressNotFound: true, force: true, verbose: true } });
await command.action(logger, { options: { groupId: groupId, userIds: userIds.join(','), suppressNotFound: true, force: true, verbose: true } });
assert(postStub.calledOnce);
});
});
Loading

0 comments on commit 498e01e

Please sign in to comment.