Skip to content

Commit 3f16da4

Browse files
MathijsVerbeeckmartinlingstuyl
authored andcommitted
Adds command 'engage community user add'. Closes #6293
1 parent 094447f commit 3f16da4

File tree

5 files changed

+517
-0
lines changed

5 files changed

+517
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Global from '/docs/cmd/_global.mdx';
2+
import Tabs from '@theme/Tabs';
3+
import TabItem from '@theme/TabItem';
4+
5+
# viva engage community user add
6+
7+
Adds a user to a specific Microsoft 365 Viva Engage community
8+
9+
## Usage
10+
11+
```sh
12+
m365 viva engage community user add [options]
13+
```
14+
15+
## Options
16+
17+
```md definition-list
18+
`-i, --communityId [communityId]`
19+
: The ID of the Viva Engage community. Specify `communityId`, `communityDisplayName` or `entraGroupId`.
20+
21+
`-n, --communityDisplayName [communityDisplayName]`
22+
: The display name of the Viva Engage community. Specify `communityId`, `communityDisplayName` or `entraGroupId`.
23+
24+
`--entraGroupId [entraGroupId]`
25+
: The ID of the Microsoft 365 group. Specify `communityId`, `communityDisplayName` or `entraGroupId`.
26+
27+
`--ids [ids]`
28+
: Microsoft Entra IDs of users. You can pass a comma-separated list of multiple IDs. Specify either `ids` or `userNames` but not both.
29+
30+
`--userNames [userNames]`
31+
: The user principal names of users. You can pass a comma-separated list of multiple UPNs. Specify either `ids` or `userNames` but not both.
32+
33+
`-r, --role <role>`
34+
: The role to be assigned to the new users. Valid values: `Admin`, `Member`.
35+
```
36+
37+
<Global />
38+
39+
## Examples
40+
41+
Add a single user specified by ID as a member to a community specified by display name.
42+
43+
```sh
44+
m365 viva engage community user add --communityDisplayName "All company" --ids 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member
45+
```
46+
47+
Add multiple users specified by ID as members to a community specified by ID.
48+
49+
```sh
50+
m365 viva engage community user add --communityId eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIzNjAyMDAxMTAwOSJ9 --ids "098b9f52-f48c-4401-819f-29c33794c3f5,f1e06e31-3abf-4746-83c2-1513d71f38b8" --role Member
51+
```
52+
53+
Add a single user specified by UPN as an admin to a community specified by display name.
54+
55+
```sh
56+
m365 viva engage community user add --communityDisplayName "All company" --userNames john.doe@contoso.com --role Admin
57+
```
58+
59+
Adds multiple users specified by UPN as admins to a community specified by its group ID.
60+
61+
```sh
62+
m365 viva engage community user add --entraGroupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --userNames "john.doe@contoso.com,adele.vance@contoso.com" --role Admin
63+
```
64+
65+
## Response
66+
67+
The command won't return a response on success.

docs/src/config/sidebars.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4705,6 +4705,11 @@ const sidebars: SidebarsConfig = {
47054705
label: 'engage community set',
47064706
id: 'cmd/viva/engage/engage-community-set'
47074707
},
4708+
{
4709+
type: 'doc',
4710+
label: 'engage community user add',
4711+
id: 'cmd/viva/engage/engage-community-user-add'
4712+
},
47084713
{
47094714
type: 'doc',
47104715
label: 'engage community user list',

src/m365/viva/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default {
66
ENGAGE_COMMUNITY_GET: `${prefix} engage community get`,
77
ENGAGE_COMMUNITY_LIST: `${prefix} engage community list`,
88
ENGAGE_COMMUNITY_SET: `${prefix} engage community set`,
9+
ENGAGE_COMMUNITY_USER_ADD: `${prefix} engage community user add`,
910
ENGAGE_COMMUNITY_USER_LIST: `${prefix} engage community user list`,
1011
ENGAGE_GROUP_LIST: `${prefix} engage group list`,
1112
ENGAGE_GROUP_USER_ADD: `${prefix} engage group user add`,
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
2+
import assert from 'assert';
3+
import sinon from 'sinon';
4+
import auth from '../../../../Auth.js';
5+
import { Logger } from '../../../../cli/Logger.js';
6+
import { CommandError } from '../../../../Command.js';
7+
import request from '../../../../request.js';
8+
import { telemetry } from '../../../../telemetry.js';
9+
import { pid } from '../../../../utils/pid.js';
10+
import { session } from '../../../../utils/session.js';
11+
import { sinonUtil } from '../../../../utils/sinonUtil.js';
12+
import commands from '../../commands.js';
13+
import command from './engage-community-user-add.js';
14+
import { CommandInfo } from '../../../../cli/CommandInfo.js';
15+
import { z } from 'zod';
16+
import { cli } from '../../../../cli/cli.js';
17+
import { vivaEngage } from '../../../../utils/vivaEngage.js';
18+
import { entraUser } from '../../../../utils/entraUser.js';
19+
20+
describe(commands.ENGAGE_COMMUNITY_USER_ADD, () => {
21+
const communityId = 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIzNjAyMDAxMTAwOSJ9';
22+
const communityDisplayName = 'All company';
23+
const entraGroupId = 'b6c35b51-ebca-445c-885a-63a67d24cb53';
24+
const userNames = ['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'];
25+
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'];
26+
27+
let log: string[];
28+
let logger: Logger;
29+
let loggerLogSpy: sinon.SinonSpy;
30+
let commandInfo: CommandInfo;
31+
let commandOptionsSchema: z.ZodTypeAny;
32+
33+
before(() => {
34+
sinon.stub(auth, 'restoreAuth').resolves();
35+
sinon.stub(telemetry, 'trackEvent').resolves();
36+
sinon.stub(pid, 'getProcessName').returns('');
37+
sinon.stub(session, 'getId').returns('');
38+
auth.connection.active = true;
39+
commandInfo = cli.getCommandInfo(command);
40+
commandOptionsSchema = commandInfo.command.getSchemaToParse()!;
41+
sinon.stub(entraUser, 'getUserIdsByUpns').resolves(userIds);
42+
sinon.stub(vivaEngage, 'getCommunityByDisplayName').resolves({ groupId: entraGroupId });
43+
sinon.stub(vivaEngage, 'getCommunityById').resolves({ groupId: entraGroupId });
44+
});
45+
46+
beforeEach(() => {
47+
log = [];
48+
logger = {
49+
log: async (msg: string) => {
50+
log.push(msg);
51+
},
52+
logRaw: async (msg: string) => {
53+
log.push(msg);
54+
},
55+
logToStderr: async (msg: string) => {
56+
log.push(msg);
57+
}
58+
};
59+
loggerLogSpy = sinon.spy(logger, 'log');
60+
});
61+
62+
afterEach(() => {
63+
sinonUtil.restore([
64+
request.post
65+
]);
66+
});
67+
68+
after(() => {
69+
sinon.restore();
70+
auth.connection.active = false;
71+
});
72+
73+
it('has correct name', () => {
74+
assert.strictEqual(command.name, commands.ENGAGE_COMMUNITY_USER_ADD);
75+
});
76+
77+
it('has a description', () => {
78+
assert.notStrictEqual(command.description, null);
79+
});
80+
81+
it('fails validation if entraGroupId is not a valid GUID', () => {
82+
const actual = commandOptionsSchema.safeParse({
83+
entraGroupId: 'invalid',
84+
role: 'Member',
85+
userNames: userNames.join(',')
86+
});
87+
assert.notStrictEqual(actual.success, true);
88+
});
89+
90+
it('fails validation if ids contains invalid guids', () => {
91+
const actual = commandOptionsSchema.safeParse({
92+
entraGroupId: entraGroupId,
93+
ids: userIds.join(',') + ',invalid',
94+
role: 'Member'
95+
});
96+
assert.notStrictEqual(actual.success, true);
97+
});
98+
99+
it('fails validation if userNames contains invalid user principal names', () => {
100+
const actual = commandOptionsSchema.safeParse({
101+
entraGroupId: entraGroupId,
102+
userNames: userNames.join(',') + ',invalid',
103+
role: 'Member'
104+
});
105+
assert.notStrictEqual(actual.success, true);
106+
});
107+
108+
it('fails validation if communityId, communityDisplayName or entraGroupId are not specified', () => {
109+
const actual = commandOptionsSchema.safeParse({});
110+
assert.notStrictEqual(actual.success, true);
111+
});
112+
113+
it('fails validation if communityId, communityDisplayName and entraGroupId are specified', () => {
114+
const actual = commandOptionsSchema.safeParse({
115+
communityId: communityId,
116+
communityDisplayName: communityDisplayName,
117+
entraGroupId: entraGroupId,
118+
ids: userIds.join(','),
119+
role: 'Member'
120+
});
121+
assert.notStrictEqual(actual.success, true);
122+
});
123+
124+
it('fails validation if incorrect role value is specified', () => {
125+
const actual = commandOptionsSchema.safeParse({
126+
communityId: communityId,
127+
userNames: userNames.join(','),
128+
role: 'invalid'
129+
});
130+
assert.notStrictEqual(actual.success, true);
131+
});
132+
133+
it('passes validation if communityId is specified', () => {
134+
const actual = commandOptionsSchema.safeParse({
135+
communityId: communityId,
136+
userNames: userNames.join(','),
137+
role: 'Admin'
138+
});
139+
assert.strictEqual(actual.success, true);
140+
});
141+
142+
it('passes validation if entraGroupId is specified with a proper GUID', () => {
143+
const actual = commandOptionsSchema.safeParse({
144+
entraGroupId: entraGroupId,
145+
userNames: userNames.join(','),
146+
role: 'Admin'
147+
});
148+
assert.strictEqual(actual.success, true);
149+
});
150+
151+
it('passes validation if communityDisplayName is specified', () => {
152+
const actual = commandOptionsSchema.safeParse({
153+
communityDisplayName: communityDisplayName,
154+
userNames: userNames.join(','),
155+
role: 'Admin'
156+
});
157+
assert.strictEqual(actual.success, true);
158+
});
159+
160+
it('passes validation if role is specified with a proper value', () => {
161+
const actual = commandOptionsSchema.safeParse({
162+
communityId: communityId,
163+
userNames: userNames.join(','),
164+
role: 'Admin'
165+
});
166+
assert.strictEqual(actual.success, true);
167+
assert(loggerLogSpy.notCalled);
168+
});
169+
170+
it('correctly adds users specified by id as owner', async () => {
171+
const postStub = sinon.stub(request, 'post').callsFake(async (opts) => {
172+
if (opts.url === `https://graph.microsoft.com/v1.0/$batch`) {
173+
return {
174+
responses: Array(2).fill({
175+
status: 204,
176+
body: {}
177+
})
178+
};
179+
}
180+
181+
throw 'Invalid request';
182+
});
183+
184+
await command.action(logger, { options: { communityDisplayName: communityDisplayName, verbose: true, ids: userIds.join(','), role: 'Owner' } });
185+
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [
186+
{
187+
id: 1,
188+
method: 'PATCH',
189+
url: `/groups/${entraGroupId}`,
190+
headers: { 'content-type': 'application/json;odata.metadata=none' },
191+
body: {
192+
'owners@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`)
193+
}
194+
},
195+
{
196+
id: 21,
197+
method: 'PATCH',
198+
url: `/groups/${entraGroupId}`,
199+
headers: { 'content-type': 'application/json;odata.metadata=none' },
200+
body: {
201+
'owners@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`)
202+
}
203+
}
204+
]);
205+
});
206+
207+
it('correctly adds users specified by ids as member', async () => {
208+
const postStub = sinon.stub(request, 'post').callsFake(async (opts) => {
209+
if (opts.url === `https://graph.microsoft.com/v1.0/$batch`) {
210+
return {
211+
responses: Array(2).fill({
212+
status: 204,
213+
body: {}
214+
})
215+
};
216+
}
217+
218+
throw 'Invalid request';
219+
});
220+
221+
await command.action(logger, { options: { communityId: communityId, verbose: true, ids: userIds.join(','), role: 'Member' } });
222+
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [
223+
{
224+
id: 1,
225+
method: 'PATCH',
226+
url: `/groups/${entraGroupId}`,
227+
headers: { 'content-type': 'application/json;odata.metadata=none' },
228+
body: {
229+
'members@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`)
230+
}
231+
},
232+
{
233+
id: 21,
234+
method: 'PATCH',
235+
url: `/groups/${entraGroupId}`,
236+
headers: { 'content-type': 'application/json;odata.metadata=none' },
237+
body: {
238+
'members@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`)
239+
}
240+
}
241+
]);
242+
});
243+
244+
it('correctly adds users specified by userNames as member', async () => {
245+
const postStub = sinon.stub(request, 'post').callsFake(async (opts) => {
246+
if (opts.url === `https://graph.microsoft.com/v1.0/$batch`) {
247+
return {
248+
responses: Array(2).fill({
249+
status: 204,
250+
body: {}
251+
})
252+
};;
253+
}
254+
255+
throw 'Invalid request';
256+
});
257+
258+
await command.action(logger, { options: { entraGroupId: entraGroupId, verbose: true, userNames: userNames.join(','), role: 'Member' } });
259+
assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [
260+
{
261+
id: 1,
262+
method: 'PATCH',
263+
url: `/groups/${entraGroupId}`,
264+
headers: { 'content-type': 'application/json;odata.metadata=none' },
265+
body: {
266+
'members@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`)
267+
}
268+
},
269+
{
270+
id: 21,
271+
method: 'PATCH',
272+
url: `/groups/${entraGroupId}`,
273+
headers: { 'content-type': 'application/json;odata.metadata=none' },
274+
body: {
275+
'members@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`)
276+
}
277+
}
278+
]);
279+
});
280+
281+
it('handles API error when adding users to a community', async () => {
282+
sinon.stub(request, 'post').callsFake(async opts => {
283+
if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') {
284+
return {
285+
responses: [
286+
{
287+
id: 1,
288+
status: 204,
289+
body: {}
290+
},
291+
{
292+
id: 2,
293+
status: 400,
294+
body: {
295+
error: {
296+
message: `One or more added object references already exist for the following modified properties: 'members'.`
297+
}
298+
}
299+
}
300+
]
301+
};
302+
}
303+
304+
throw 'Invalid request';
305+
});
306+
307+
await assert.rejects(command.action(logger, { options: { entraGroupId: entraGroupId, ids: userIds.join(','), role: 'Member' } }),
308+
new CommandError(`One or more added object references already exist for the following modified properties: 'members'.`));
309+
});
310+
});

0 commit comments

Comments
 (0)