Skip to content

Commit 914a578

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

34 files changed

+1127
-1785
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-conversation-list.spec.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import assert from 'assert';
22
import sinon from 'sinon';
3+
import { z } from 'zod';
34
import auth from '../../../../Auth.js';
5+
import { cli } from '../../../../cli/cli.js';
46
import { CommandInfo } from '../../../../cli/CommandInfo.js';
57
import { Logger } from '../../../../cli/Logger.js';
68
import { CommandError } from '../../../../Command.js';
@@ -12,13 +14,13 @@ import { sinonUtil } from '../../../../utils/sinonUtil.js';
1214
import commands from '../../commands.js';
1315
import command from './m365group-conversation-list.js';
1416
import { entraGroup } from '../../../../utils/entraGroup.js';
15-
import { cli } from '../../../../cli/cli.js';
1617

1718
describe(commands.M365GROUP_CONVERSATION_LIST, () => {
1819
let log: string[];
1920
let logger: Logger;
2021
let loggerLogSpy: sinon.SinonSpy;
2122
let commandInfo: CommandInfo;
23+
let commandOptionsSchema: z.ZodTypeAny;
2224

2325
const jsonOutput = {
2426
"value": [
@@ -53,6 +55,7 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => {
5355
sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves('00000000-0000-0000-0000-000000000000');
5456
auth.connection.active = true;
5557
commandInfo = cli.getCommandInfo(command);
58+
commandOptionsSchema = commandInfo.command.getSchemaToParse()!;
5659
});
5760

5861
beforeEach(() => {
@@ -94,13 +97,15 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => {
9497
it('defines correct properties for the default output', () => {
9598
assert.deepStrictEqual(command.defaultProperties(), ['topic', 'lastDeliveredDateTime', 'id']);
9699
});
100+
97101
it('fails validation if the groupId is not a valid GUID', async () => {
98-
const actual = await command.validate({ options: { groupId: 'not-c49b-4fd4-8223-28f0ac3a6402' } }, commandInfo);
99-
assert.notStrictEqual(actual, true);
102+
const actual = commandOptionsSchema.safeParse({ groupId: 'not-c49b-4fd4-8223-28f0ac3a6402' });
103+
assert.strictEqual(actual.success, false);
100104
});
105+
101106
it('passes validation if the groupId is a valid GUID', async () => {
102-
const actual = await command.validate({ options: { groupId: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' } }, commandInfo);
103-
assert.strictEqual(actual, true);
107+
const actual = commandOptionsSchema.safeParse({ groupId: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' });
108+
assert.strictEqual(actual.success, true);
104109
});
105110

106111
it('Retrieve conversations for the group specified by groupId in the tenant (verbose)', async () => {
@@ -112,9 +117,9 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => {
112117
});
113118

114119
await command.action(logger, {
115-
options: {
120+
options: commandOptionsSchema.parse({
116121
verbose: true, groupId: "00000000-0000-0000-0000-000000000000"
117-
}
122+
})
118123
});
119124
assert(loggerLogSpy.calledWith(
120125
jsonOutput.value
@@ -130,9 +135,9 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => {
130135
});
131136

132137
await command.action(logger, {
133-
options: {
138+
options: commandOptionsSchema.parse({
134139
verbose: true, groupName: "Finance"
135-
}
140+
})
136141
});
137142
assert(loggerLogSpy.calledWith(
138143
jsonOutput.value
@@ -142,7 +147,7 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => {
142147
it('correctly handles error when listing conversations', async () => {
143148
sinon.stub(request, 'get').rejects(new Error('An error has occurred'));
144149

145-
await assert.rejects(command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000" } } as any),
150+
await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: "00000000-0000-0000-0000-000000000000" }) } as any),
146151
new CommandError('An error has occurred'));
147152
});
148153

@@ -152,7 +157,7 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => {
152157
sinonUtil.restore(entraGroup.isUnifiedGroup);
153158
sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false);
154159

155-
await assert.rejects(command.action(logger, { options: { groupId: groupId } } as any),
160+
await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupId }) } as any),
156161
new CommandError(`Specified group with id '${groupId}' is not a Microsoft 365 group.`));
157162
});
158163

src/m365/entra/commands/m365group/m365group-conversation-list.ts

Lines changed: 19 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
import { Conversation } from '@microsoft/microsoft-graph-types';
2+
import { z } from 'zod';
23
import { Logger } from '../../../../cli/Logger.js';
3-
import GlobalOptions from '../../../../GlobalOptions.js';
4+
import { globalOptionsZod } from '../../../../Command.js';
45
import { odata } from '../../../../utils/odata.js';
5-
import { validation } from '../../../../utils/validation.js';
6+
import { zod } from '../../../../utils/zod.js';
67
import GraphCommand from '../../../base/GraphCommand.js';
78
import commands from '../../commands.js';
89
import { entraGroup } from '../../../../utils/entraGroup.js';
910

11+
const options = globalOptionsZod
12+
.extend({
13+
groupId: zod.alias('i', z.string().uuid().optional()),
14+
groupName: zod.alias('n', z.string().optional())
15+
})
16+
.strict();
17+
18+
declare type Options = z.infer<typeof options>;
19+
1020
interface CommandArgs {
1121
options: Options;
1222
}
1323

14-
interface Options extends GlobalOptions {
15-
groupId?: string;
16-
groupName?: string;
17-
}
18-
1924
class EntraM365GroupConversationListCommand extends GraphCommand {
2025
public get name(): string {
2126
return commands.M365GROUP_CONVERSATION_LIST;
@@ -29,44 +34,15 @@ class EntraM365GroupConversationListCommand extends GraphCommand {
2934
return ['topic', 'lastDeliveredDateTime', 'id'];
3035
}
3136

32-
constructor() {
33-
super();
34-
35-
this.#initOptions();
36-
this.#initValidators();
37-
this.#initOptionSets();
38-
this.#initTypes();
39-
}
40-
41-
#initOptions(): void {
42-
this.options.unshift(
43-
{
44-
option: '-i, --groupId [groupId]'
45-
},
46-
{
47-
option: '-n, --groupName [groupName]'
48-
}
49-
);
50-
}
51-
52-
#initValidators(): void {
53-
this.validators.push(
54-
async (args: CommandArgs) => {
55-
if (args.options.groupId && !validation.isValidGuid(args.options.groupId as string)) {
56-
return `${args.options.groupId} is not a valid GUID`;
57-
}
58-
59-
return true;
60-
}
61-
);
62-
}
63-
64-
#initOptionSets(): void {
65-
this.optionSets.push({ options: ['groupId', 'groupName'] });
37+
public get schema(): z.ZodTypeAny | undefined {
38+
return options;
6639
}
6740

68-
#initTypes(): void {
69-
this.types.string.push('groupId', 'groupName');
41+
public getRefinedSchema(schema: typeof options): z.ZodEffects<any> | undefined {
42+
return schema
43+
.refine(options => [options.groupId, options.groupName].filter(Boolean).length === 1, {
44+
message: 'Specify either groupId or groupName'
45+
});
7046
}
7147

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

0 commit comments

Comments
 (0)