Skip to content

Commit f74716f

Browse files
Migrates some 'm365group' commands to zod
1 parent 071c745 commit f74716f

File tree

6 files changed

+268
-363
lines changed

6 files changed

+268
-363
lines changed

src/m365/entra/commands/m365group/m365group-add.spec.ts

Lines changed: 102 additions & 90 deletions
Large diffs are not rendered by default.

src/m365/entra/commands/m365group/m365group-add.ts

Lines changed: 60 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,46 @@ import { Group, User } from '@microsoft/microsoft-graph-types';
22
import { setTimeout } from 'timers/promises';
33
import fs from 'fs';
44
import path from 'path';
5+
import { z } from 'zod';
56
import { Logger } from '../../../../cli/Logger.js';
6-
import GlobalOptions from '../../../../GlobalOptions.js';
7+
import { globalOptionsZod } from '../../../../Command.js';
78
import request, { CliRequestOptions } from '../../../../request.js';
89
import { formatting } from '../../../../utils/formatting.js';
10+
import { zod } from '../../../../utils/zod.js';
911
import GraphCommand from '../../../base/GraphCommand.js';
1012
import commands from '../../commands.js';
1113

12-
interface CommandArgs {
13-
options: Options;
14+
enum GroupVisibility {
15+
Private = 'Private',
16+
Public = 'Public',
17+
HiddenMembership = 'HiddenMembership'
1418
}
1519

16-
interface Options extends GlobalOptions {
17-
displayName: string;
18-
mailNickname: string;
19-
description?: string;
20-
owners?: string;
21-
members?: string;
22-
visibility?: string;
23-
logoPath?: string;
24-
allowMembersToPost?: boolean;
25-
hideGroupInOutlook?: boolean;
26-
subscribeNewGroupMembers?: boolean;
27-
welcomeEmailDisabled?: boolean;
20+
const options = globalOptionsZod
21+
.extend({
22+
displayName: zod.alias('n', z.string()),
23+
mailNickname: zod.alias('m', z.string()),
24+
description: zod.alias('d', z.string().optional()),
25+
owners: z.string().optional(),
26+
members: z.string().optional(),
27+
visibility: zod.coercedEnum(GroupVisibility).optional(),
28+
logoPath: zod.alias('l', z.string().optional()),
29+
allowMembersToPost: z.boolean().optional(),
30+
hideGroupInOutlook: z.boolean().optional(),
31+
subscribeNewGroupMembers: z.boolean().optional(),
32+
welcomeEmailDisabled: z.boolean().optional()
33+
})
34+
.strict();
35+
36+
declare type Options = z.infer<typeof options>;
37+
38+
interface CommandArgs {
39+
options: Options;
2840
}
2941

3042
class EntraM365GroupAddCommand extends GraphCommand {
3143
private static numRepeat: number = 15;
3244
private pollingInterval: number = 500;
33-
private allowedVisibilities: string[] = ['Private', 'Public', 'HiddenMembership'];
3445

3546
public get name(): string {
3647
return commands.M365GROUP_ADD;
@@ -40,123 +51,57 @@ class EntraM365GroupAddCommand extends GraphCommand {
4051
return 'Creates a Microsoft 365 Group';
4152
}
4253

43-
constructor() {
44-
super();
45-
46-
this.#initTelemetry();
47-
this.#initOptions();
48-
this.#initTypes();
49-
this.#initValidators();
50-
}
51-
52-
#initTelemetry(): void {
53-
this.telemetry.push((args: CommandArgs) => {
54-
Object.assign(this.telemetryProperties, {
55-
description: typeof args.options.description !== 'undefined',
56-
owners: typeof args.options.owners !== 'undefined',
57-
members: typeof args.options.members !== 'undefined',
58-
logoPath: typeof args.options.logoPath !== 'undefined',
59-
visibility: typeof args.options.visibility !== 'undefined',
60-
allowMembersToPost: !!args.options.allowMembersToPost,
61-
hideGroupInOutlook: !!args.options.hideGroupInOutlook,
62-
subscribeNewGroupMembers: !!args.options.subscribeNewGroupMembers,
63-
welcomeEmailDisabled: !!args.options.welcomeEmailDisabled
64-
});
65-
});
66-
}
67-
68-
#initOptions(): void {
69-
this.options.unshift(
70-
{
71-
option: '-n, --displayName <displayName>'
72-
},
73-
{
74-
option: '-m, --mailNickname <mailNickname>'
75-
},
76-
{
77-
option: '-d, --description [description]'
78-
},
79-
{
80-
option: '--owners [owners]'
81-
},
82-
{
83-
option: '--members [members]'
84-
},
85-
{
86-
option: '--visibility [visibility]',
87-
autocomplete: this.allowedVisibilities
88-
},
89-
{
90-
option: '--allowMembersToPost [allowMembersToPost]',
91-
autocomplete: ['true', 'false']
92-
},
93-
{
94-
option: '--hideGroupInOutlook [hideGroupInOutlook]',
95-
autocomplete: ['true', 'false']
96-
},
97-
{
98-
option: '--subscribeNewGroupMembers [subscribeNewGroupMembers]',
99-
autocomplete: ['true', 'false']
100-
},
101-
{
102-
option: '--welcomeEmailDisabled [welcomeEmailDisabled]',
103-
autocomplete: ['true', 'false']
104-
},
105-
{
106-
option: '-l, --logoPath [logoPath]'
107-
}
108-
);
109-
}
110-
111-
#initTypes(): void {
112-
this.types.string.push('displayName', 'mailNickname', 'description', 'owners', 'members', 'visibility', 'logoPath');
113-
this.types.boolean.push('allowMembersToPost', 'hideGroupInOutlook', 'subscribeNewGroupMembers', 'welcomeEmailDisabled');
54+
public get schema(): z.ZodTypeAny | undefined {
55+
return options;
11456
}
11557

116-
#initValidators(): void {
117-
this.validators.push(
118-
async (args: CommandArgs) => {
119-
if (args.options.owners) {
120-
const owners: string[] = args.options.owners.split(',').map(o => o.trim());
58+
public getRefinedSchema(schema: typeof options): z.ZodEffects<any> | undefined {
59+
return schema
60+
.refine(options => {
61+
if (options.owners) {
62+
const owners: string[] = options.owners.split(',').map(o => o.trim());
12163
for (let i = 0; i < owners.length; i++) {
12264
if (owners[i].indexOf('@') < 0) {
123-
return `${owners[i]} is not a valid userPrincipalName`;
65+
return false;
12466
}
12567
}
12668
}
127-
128-
if (args.options.members) {
129-
const members: string[] = args.options.members.split(',').map(m => m.trim());
69+
return true;
70+
}, {
71+
message: 'Invalid userPrincipalName for owners'
72+
})
73+
.refine(options => {
74+
if (options.members) {
75+
const members: string[] = options.members.split(',').map(m => m.trim());
13076
for (let i = 0; i < members.length; i++) {
13177
if (members[i].indexOf('@') < 0) {
132-
return `${members[i]} is not a valid userPrincipalName`;
78+
return false;
13379
}
13480
}
13581
}
136-
137-
if (args.options.mailNickname.indexOf(' ') !== -1) {
138-
return 'The option mailNickname cannot contain spaces.';
139-
}
140-
141-
if (args.options.logoPath) {
142-
const fullPath: string = path.resolve(args.options.logoPath);
143-
82+
return true;
83+
}, {
84+
message: 'Invalid userPrincipalName for members'
85+
})
86+
.refine(options => options.mailNickname.indexOf(' ') === -1, {
87+
message: 'The option mailNickname cannot contain spaces.'
88+
})
89+
.refine(options => {
90+
if (options.logoPath) {
91+
const fullPath: string = path.resolve(options.logoPath);
92+
14493
if (!fs.existsSync(fullPath)) {
145-
return `File '${fullPath}' not found`;
94+
return false;
14695
}
147-
96+
14897
if (fs.lstatSync(fullPath).isDirectory()) {
149-
return `Path '${fullPath}' points to a directory`;
98+
return false;
15099
}
151100
}
152-
153-
if (args.options.visibility && this.allowedVisibilities.map(x => x.toLowerCase()).indexOf(args.options.visibility.toLowerCase()) === -1) {
154-
return `${args.options.visibility} is not a valid visibility. Allowed values are ${this.allowedVisibilities.join(', ')}`;
155-
}
156-
157101
return true;
158-
}
159-
);
102+
}, {
103+
message: 'Invalid logoPath'
104+
});
160105
}
161106

162107
public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {

src/m365/entra/commands/m365group/m365group-get.spec.ts

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from 'assert';
22
import sinon from 'sinon';
3+
import { z } from 'zod';
34
import auth from '../../../../Auth.js';
45
import { CommandError } from '../../../../Command.js';
56
import { cli } from '../../../../cli/cli.js';
@@ -19,6 +20,7 @@ describe(commands.M365GROUP_GET, () => {
1920
let logger: Logger;
2021
let loggerLogSpy: sinon.SinonSpy;
2122
let commandInfo: CommandInfo;
23+
let commandOptionsSchema: z.ZodTypeAny;
2224

2325
before(() => {
2426
sinon.stub(auth, 'restoreAuth').resolves();
@@ -28,6 +30,7 @@ describe(commands.M365GROUP_GET, () => {
2830
sinon.stub(entraGroup, 'isUnifiedGroup').resolves(true);
2931
auth.connection.active = true;
3032
commandInfo = cli.getCommandInfo(command);
33+
commandOptionsSchema = commandInfo.command.getSchemaToParse()!;
3134
});
3235

3336
beforeEach(() => {
@@ -109,7 +112,7 @@ describe(commands.M365GROUP_GET, () => {
109112
throw 'Invalid request';
110113
});
111114

112-
await command.action(logger, { options: { id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' } });
115+
await command.action(logger, { options: commandOptionsSchema.parse({ id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' }) });
113116
assert(loggerLogSpy.calledWith({
114117
"id": "1caf7dcd-7e83-4c3a-94f7-932a1299c844",
115118
"deletedDateTime": null,
@@ -189,7 +192,7 @@ describe(commands.M365GROUP_GET, () => {
189192
throw 'Invalid request';
190193
});
191194

192-
await command.action(logger, { options: { displayName: 'Finance' } });
195+
await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'Finance' }) });
193196
assert(loggerLogSpy.calledWith({
194197
"id": "1caf7dcd-7e83-4c3a-94f7-932a1299c844",
195198
"deletedDateTime": null,
@@ -265,7 +268,7 @@ describe(commands.M365GROUP_GET, () => {
265268
throw 'Invalid request';
266269
});
267270

268-
await command.action(logger, { options: { debug: true, id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' } });
271+
await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' }) });
269272
assert(loggerLogSpy.calledWith({
270273
"id": "1caf7dcd-7e83-4c3a-94f7-932a1299c844",
271274
"deletedDateTime": null,
@@ -346,7 +349,7 @@ describe(commands.M365GROUP_GET, () => {
346349
throw 'Invalid request';
347350
});
348351

349-
await command.action(logger, { options: { id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', includeSiteUrl: true } });
352+
await command.action(logger, { options: commandOptionsSchema.parse({ id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', includeSiteUrl: true }) });
350353
assert(loggerErrSpy.calledWith(chalk.yellow(`Parameter 'includeSiteUrl' is deprecated. Please use 'withSiteUrl' instead`)));
351354

352355
sinonUtil.restore(loggerErrSpy);
@@ -397,7 +400,7 @@ describe(commands.M365GROUP_GET, () => {
397400
throw 'Invalid request';
398401
});
399402

400-
await command.action(logger, { options: { id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', withSiteUrl: true } });
403+
await command.action(logger, { options: commandOptionsSchema.parse({ id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', withSiteUrl: true }) });
401404
assert(loggerLogSpy.calledWith({
402405
"id": "1caf7dcd-7e83-4c3a-94f7-932a1299c844",
403406
"deletedDateTime": null,
@@ -478,7 +481,7 @@ describe(commands.M365GROUP_GET, () => {
478481
throw 'Invalid request';
479482
});
480483

481-
await command.action(logger, { options: { debug: true, id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', withSiteUrl: true } });
484+
await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', withSiteUrl: true }) });
482485
assert(loggerLogSpy.calledWith({
483486
"id": "1caf7dcd-7e83-4c3a-94f7-932a1299c844",
484487
"deletedDateTime": null,
@@ -559,7 +562,7 @@ describe(commands.M365GROUP_GET, () => {
559562
throw 'Invalid request';
560563
});
561564

562-
await command.action(logger, { options: { id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', withSiteUrl: true } });
565+
await command.action(logger, { options: commandOptionsSchema.parse({ id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', withSiteUrl: true }) });
563566
assert(loggerLogSpy.calledWith({
564567
"id": "1caf7dcd-7e83-4c3a-94f7-932a1299c844",
565568
"deletedDateTime": null,
@@ -597,28 +600,39 @@ describe(commands.M365GROUP_GET, () => {
597600
const errorMessage = 'Something went wrong';
598601
sinon.stub(request, 'get').rejects(new Error(errorMessage));
599602

600-
await assert.rejects(command.action(logger, { options: { id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' } }), new CommandError(errorMessage));
603+
await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' }) }), new CommandError(errorMessage));
601604
});
602605

603-
it('fails validation if the id is not a valid GUID', async () => {
604-
const actual = await command.validate({ options: { id: '123' } }, commandInfo);
605-
assert.notStrictEqual(actual, true);
606+
it('fails validation when id and displayName are not specified', () => {
607+
const actual = commandOptionsSchema.safeParse({});
608+
assert.strictEqual(actual.success, false);
606609
});
607610

608-
it('passes validation if the id is a valid GUID', async () => {
609-
const actual = await command.validate({ options: { id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' } }, commandInfo);
610-
assert.strictEqual(actual, true);
611+
it('fails validation when both id and displayName are specified', () => {
612+
const actual = commandOptionsSchema.safeParse({
613+
id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844',
614+
displayName: 'Finance'
615+
});
616+
assert.strictEqual(actual.success, false);
611617
});
612618

613-
it('supports specifying id', () => {
614-
const options = command.options;
615-
let containsOption = false;
616-
options.forEach(o => {
617-
if (o.option.indexOf('--id') > -1) {
618-
containsOption = true;
619-
}
619+
it('fails validation if the id is not a valid GUID', () => {
620+
const actual = commandOptionsSchema.safeParse({ id: '123' });
621+
assert.strictEqual(actual.success, false);
622+
});
623+
624+
it('passes validation if the id is a valid GUID', () => {
625+
const actual = commandOptionsSchema.safeParse({
626+
id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844'
627+
});
628+
assert.strictEqual(actual.success, true);
629+
});
630+
631+
it('passes validation when displayName is specified', () => {
632+
const actual = commandOptionsSchema.safeParse({
633+
displayName: 'Finance'
620634
});
621-
assert(containsOption);
635+
assert.strictEqual(actual.success, true);
622636
});
623637

624638
it('shows error when the group is not a unified group', async () => {
@@ -650,7 +664,7 @@ describe(commands.M365GROUP_GET, () => {
650664
sinonUtil.restore(entraGroup.isUnifiedGroup);
651665
sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false);
652666

653-
await assert.rejects(command.action(logger, { options: { id: groupId } }),
667+
await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId }) }),
654668
new CommandError(`Specified group with id '${groupId}' is not a Microsoft 365 group.`));
655669
});
656670
});

0 commit comments

Comments
 (0)