Skip to content

Commit

Permalink
Adds command 'entra group user add'. Closes #5471
Browse files Browse the repository at this point in the history
  • Loading branch information
milanholemans authored and waldekmastykarz committed Feb 10, 2024
1 parent a16331e commit a241ef8
Show file tree
Hide file tree
Showing 9 changed files with 689 additions and 1 deletion.
62 changes: 62 additions & 0 deletions docs/docs/cmd/entra/group/group-user-add.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Global from '/docs/cmd/_global.mdx';

# entra group user add

Adds a user to a Microsoft Entra ID group

## Usage

```sh
m365 entra group user add [options]
```

## Options

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

`-n, --groupDisplayName [groupDisplayName]`
: The display name of the Entra ID group. Specify `groupId` or `groupDisplayName` 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.

`--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.

`-r, --role <role>`
: The role to be assigned to the new users. Valid values: `Owner`, `Member`.
```

<Global />

## Examples

Add a single user specified by ID as a member to a group specified by display name

```sh
m365 entra group user add --groupDisplayName Developers --ids 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member
```

Add multiple users specified by ID as members to a group specified by ID

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

Add a single user specified by UPN as an owner to a group specified by display name

```sh
m365 entra group user add --groupDisplayName Developers --userNames john.doe@contoso.com --role Owner
```

Adds multiple users specified by UPN as owners to a group specified by ID

```sh
m365 entra group user add --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --userNames "john.doe@contoso.com,adele.vance@contoso.com" --role Owner
```

## Response

The command won't return a response on success.
5 changes: 5 additions & 0 deletions docs/src/config/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,11 @@ const sidebars: SidebarsConfig = {
label: 'group remove',
id: 'cmd/entra/group/group-remove'
},
{
type: 'doc',
label: 'group user add',
id: 'cmd/entra/group/group-user-add'
},
{
type: 'doc',
label: 'group user list',
Expand Down
1 change: 1 addition & 0 deletions src/m365/entra/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default {
GROUP_GET: `${prefix} group get`,
GROUP_LIST: `${prefix} group list`,
GROUP_REMOVE: `${prefix} group remove`,
GROUP_USER_ADD: `${prefix} group user add`,
GROUP_USER_LIST: `${prefix} group user list`,
GROUPSETTING_ADD: `${prefix} groupsetting add`,
GROUPSETTING_GET: `${prefix} groupsetting get`,
Expand Down
297 changes: 297 additions & 0 deletions src/m365/entra/commands/group/group-user-add.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import assert from 'assert';
import sinon from 'sinon';
import auth from '../../../../Auth.js';
import commands from '../../commands.js';
import { telemetry } from '../../../../telemetry.js';
import { Logger } from '../../../../cli/Logger.js';
import { pid } from '../../../../utils/pid.js';
import { session } from '../../../../utils/session.js';
import { sinonUtil } from '../../../../utils/sinonUtil.js';
import { cli } from '../../../../cli/cli.js';
import { CommandInfo } from '../../../../cli/CommandInfo.js';
import command from './group-user-add.js';
import request from '../../../../request.js';
import { entraGroup } from '../../../../utils/entraGroup.js';
import { entraUser } from '../../../../utils/entraUser.js';
import { CommandError } from '../../../../Command.js';

describe(commands.GROUP_USER_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'];

let log: string[];
let logger: Logger;
let commandInfo: CommandInfo;

before(() => {
sinon.stub(auth, 'restoreAuth').resolves();
sinon.stub(telemetry, 'trackEvent').returns();
sinon.stub(pid, 'getProcessName').returns('');
sinon.stub(session, 'getId').returns('');
auth.service.connected = true;
commandInfo = cli.getCommandInfo(command);
});

beforeEach(() => {
log = [];
logger = {
log: async (msg: string) => {
log.push(msg);
},
logRaw: async (msg: string) => {
log.push(msg);
},
logToStderr: async (msg: string) => {
log.push(msg);
}
};
});

afterEach(() => {
sinonUtil.restore([
request.post,
entraGroup.getGroupIdByDisplayName,
entraUser.getUserIdsByUpns
]);
});

after(() => {
sinon.restore();
auth.service.connected = false;
});

it('has correct name', () => {
assert.strictEqual(command.name, commands.GROUP_USER_ADD);
});

it('has a description', () => {
assert.notStrictEqual(command.description, null);
});

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);
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`, 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);
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(','), role: 'Member' } }, 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);
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 names with trailing spaces', async () => {
const actual = await command.validate({ options: { groupDisplayName: 'IT department', userNames: userUpns.map(u => u + ' ').join(','), role: 'Owner' } }, commandInfo);
assert.strictEqual(actual, true);
});

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') {
return {
responses: Array(2).fill({
status: 204,
body: {}
})
};
}

throw 'Invalid request';
});

await command.action(logger, { options: { groupId: groupId, ids: 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') {
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, ids: 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);

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: { groupDisplayName: 'Contoso', userNames: userUpns.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': 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: {
'owners@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 and leading spaces', async () => {
sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId);
sinon.stub(entraUser, 'getUserIdsByUpns').resolves(userIds);

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 userNames = userUpns.map(u => ' ' + u).join(',');
await command.action(logger, { options: { groupDisplayName: 'Contoso', userNames: userNames, 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': 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: {
'owners@odata.bind': userIds.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') {
return {
responses: [
{
id: 1,
status: 204,
body: {}
},
{
id: 2,
status: 400,
body: {
error: {
message: `One or more added object references already exist for the following modified properties: 'members'.`
}
}
}
]
};
}

throw 'Invalid request';
});

await assert.rejects(command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), role: 'Member' } }),
new CommandError(`One or more added object references already exist for the following modified properties: 'members'.`));
});
});
Loading

0 comments on commit a241ef8

Please sign in to comment.