Skip to content

Migrates some 'm365group' commands to zod. Closes #6849 #6845

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 102 additions & 90 deletions src/m365/entra/commands/m365group/m365group-add.spec.ts

Large diffs are not rendered by default.

175 changes: 60 additions & 115 deletions src/m365/entra/commands/m365group/m365group-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,46 @@ import { Group, User } from '@microsoft/microsoft-graph-types';
import { setTimeout } from 'timers/promises';
import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { Logger } from '../../../../cli/Logger.js';
import GlobalOptions from '../../../../GlobalOptions.js';
import { globalOptionsZod } from '../../../../Command.js';
import request, { CliRequestOptions } from '../../../../request.js';
import { formatting } from '../../../../utils/formatting.js';
import { zod } from '../../../../utils/zod.js';
import GraphCommand from '../../../base/GraphCommand.js';
import commands from '../../commands.js';

interface CommandArgs {
options: Options;
enum GroupVisibility {
Private = 'Private',
Public = 'Public',
HiddenMembership = 'HiddenMembership'
}

interface Options extends GlobalOptions {
displayName: string;
mailNickname: string;
description?: string;
owners?: string;
members?: string;
visibility?: string;
logoPath?: string;
allowMembersToPost?: boolean;
hideGroupInOutlook?: boolean;
subscribeNewGroupMembers?: boolean;
welcomeEmailDisabled?: boolean;
const options = globalOptionsZod
.extend({
displayName: zod.alias('n', z.string()),
mailNickname: zod.alias('m', z.string()),
description: zod.alias('d', z.string().optional()),
owners: z.string().optional(),
members: z.string().optional(),
visibility: zod.coercedEnum(GroupVisibility).optional(),
logoPath: zod.alias('l', z.string().optional()),
allowMembersToPost: z.boolean().optional(),
hideGroupInOutlook: z.boolean().optional(),
subscribeNewGroupMembers: z.boolean().optional(),
welcomeEmailDisabled: z.boolean().optional()
})
.strict();

declare type Options = z.infer<typeof options>;

interface CommandArgs {
options: Options;
}

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

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

constructor() {
super();

this.#initTelemetry();
this.#initOptions();
this.#initTypes();
this.#initValidators();
}

#initTelemetry(): void {
this.telemetry.push((args: CommandArgs) => {
Object.assign(this.telemetryProperties, {
description: typeof args.options.description !== 'undefined',
owners: typeof args.options.owners !== 'undefined',
members: typeof args.options.members !== 'undefined',
logoPath: typeof args.options.logoPath !== 'undefined',
visibility: typeof args.options.visibility !== 'undefined',
allowMembersToPost: !!args.options.allowMembersToPost,
hideGroupInOutlook: !!args.options.hideGroupInOutlook,
subscribeNewGroupMembers: !!args.options.subscribeNewGroupMembers,
welcomeEmailDisabled: !!args.options.welcomeEmailDisabled
});
});
}

#initOptions(): void {
this.options.unshift(
{
option: '-n, --displayName <displayName>'
},
{
option: '-m, --mailNickname <mailNickname>'
},
{
option: '-d, --description [description]'
},
{
option: '--owners [owners]'
},
{
option: '--members [members]'
},
{
option: '--visibility [visibility]',
autocomplete: this.allowedVisibilities
},
{
option: '--allowMembersToPost [allowMembersToPost]',
autocomplete: ['true', 'false']
},
{
option: '--hideGroupInOutlook [hideGroupInOutlook]',
autocomplete: ['true', 'false']
},
{
option: '--subscribeNewGroupMembers [subscribeNewGroupMembers]',
autocomplete: ['true', 'false']
},
{
option: '--welcomeEmailDisabled [welcomeEmailDisabled]',
autocomplete: ['true', 'false']
},
{
option: '-l, --logoPath [logoPath]'
}
);
}

#initTypes(): void {
this.types.string.push('displayName', 'mailNickname', 'description', 'owners', 'members', 'visibility', 'logoPath');
this.types.boolean.push('allowMembersToPost', 'hideGroupInOutlook', 'subscribeNewGroupMembers', 'welcomeEmailDisabled');
public get schema(): z.ZodTypeAny | undefined {
return options;
}

#initValidators(): void {
this.validators.push(
async (args: CommandArgs) => {
if (args.options.owners) {
const owners: string[] = args.options.owners.split(',').map(o => o.trim());
public getRefinedSchema(schema: typeof options): z.ZodEffects<any> | undefined {
return schema
.refine(options => {
if (options.owners) {
const owners: string[] = options.owners.split(',').map(o => o.trim());
for (let i = 0; i < owners.length; i++) {
if (owners[i].indexOf('@') < 0) {
return `${owners[i]} is not a valid userPrincipalName`;
return false;
}
}
}

if (args.options.members) {
const members: string[] = args.options.members.split(',').map(m => m.trim());
return true;
}, {
message: 'Invalid userPrincipalName for owners'
})
.refine(options => {
if (options.members) {
const members: string[] = options.members.split(',').map(m => m.trim());
for (let i = 0; i < members.length; i++) {
if (members[i].indexOf('@') < 0) {
return `${members[i]} is not a valid userPrincipalName`;
return false;
}
}
}

if (args.options.mailNickname.indexOf(' ') !== -1) {
return 'The option mailNickname cannot contain spaces.';
}

if (args.options.logoPath) {
const fullPath: string = path.resolve(args.options.logoPath);

return true;
}, {
message: 'Invalid userPrincipalName for members'
})
.refine(options => options.mailNickname.indexOf(' ') === -1, {
message: 'The option mailNickname cannot contain spaces.'
})
.refine(options => {
if (options.logoPath) {
const fullPath: string = path.resolve(options.logoPath);

if (!fs.existsSync(fullPath)) {
return `File '${fullPath}' not found`;
return false;
}

if (fs.lstatSync(fullPath).isDirectory()) {
return `Path '${fullPath}' points to a directory`;
return false;
}
}

if (args.options.visibility && this.allowedVisibilities.map(x => x.toLowerCase()).indexOf(args.options.visibility.toLowerCase()) === -1) {
return `${args.options.visibility} is not a valid visibility. Allowed values are ${this.allowedVisibilities.join(', ')}`;
}

return true;
}
);
}, {
message: 'Invalid logoPath'
});
}

public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
Expand Down
60 changes: 37 additions & 23 deletions src/m365/entra/commands/m365group/m365group-get.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'assert';
import sinon from 'sinon';
import { z } from 'zod';
import auth from '../../../../Auth.js';
import { CommandError } from '../../../../Command.js';
import { cli } from '../../../../cli/cli.js';
Expand All @@ -19,6 +20,7 @@ describe(commands.M365GROUP_GET, () => {
let logger: Logger;
let loggerLogSpy: sinon.SinonSpy;
let commandInfo: CommandInfo;
let commandOptionsSchema: z.ZodTypeAny;

before(() => {
sinon.stub(auth, 'restoreAuth').resolves();
Expand All @@ -28,6 +30,7 @@ describe(commands.M365GROUP_GET, () => {
sinon.stub(entraGroup, 'isUnifiedGroup').resolves(true);
auth.connection.active = true;
commandInfo = cli.getCommandInfo(command);
commandOptionsSchema = commandInfo.command.getSchemaToParse()!;
});

beforeEach(() => {
Expand Down Expand Up @@ -109,7 +112,7 @@ describe(commands.M365GROUP_GET, () => {
throw 'Invalid request';
});

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

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

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

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

sinonUtil.restore(loggerErrSpy);
Expand Down Expand Up @@ -397,7 +400,7 @@ describe(commands.M365GROUP_GET, () => {
throw 'Invalid request';
});

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

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

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

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

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

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

it('supports specifying id', () => {
const options = command.options;
let containsOption = false;
options.forEach(o => {
if (o.option.indexOf('--id') > -1) {
containsOption = true;
}
it('fails validation if the id is not a valid GUID', () => {
const actual = commandOptionsSchema.safeParse({ id: '123' });
assert.strictEqual(actual.success, false);
});

it('passes validation if the id is a valid GUID', () => {
const actual = commandOptionsSchema.safeParse({
id: '1caf7dcd-7e83-4c3a-94f7-932a1299c844'
});
assert.strictEqual(actual.success, true);
});

it('passes validation when displayName is specified', () => {
const actual = commandOptionsSchema.safeParse({
displayName: 'Finance'
});
assert(containsOption);
assert.strictEqual(actual.success, true);
});

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

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