From 880aec068bfac5d6e29590d1dc2d88b3921665f2 Mon Sep 17 00:00:00 2001 From: Martin Lingstuyl Date: Wed, 10 Sep 2025 12:08:41 +0200 Subject: [PATCH] Migrates some 'm365group' commands to zod --- .../commands/m365group/m365group-add.spec.ts | 192 ++++++------ .../entra/commands/m365group/m365group-add.ts | 175 ++++------- .../m365group-conversation-list.spec.ts | 27 +- .../m365group/m365group-conversation-list.ts | 62 ++-- .../m365group-conversation-post-list.spec.ts | 33 ++- .../m365group-conversation-post-list.ts | 70 ++--- .../commands/m365group/m365group-get.spec.ts | 60 ++-- .../entra/commands/m365group/m365group-get.ts | 80 ++--- .../commands/m365group/m365group-list.spec.ts | 61 ++-- .../commands/m365group/m365group-list.ts | 63 ++-- .../m365group-recyclebinitem-clear.spec.ts | 35 +-- .../m365group-recyclebinitem-clear.ts | 39 +-- .../m365group-recyclebinitem-list.spec.ts | 41 +-- .../m365group-recyclebinitem-list.ts | 45 +-- .../m365group-recyclebinitem-remove.spec.ts | 63 ++-- .../m365group-recyclebinitem-remove.ts | 77 ++--- .../m365group-recyclebinitem-restore.spec.ts | 57 ++-- .../m365group-recyclebinitem-restore.ts | 71 ++--- .../m365group/m365group-remove.spec.ts | 31 +- .../commands/m365group/m365group-remove.ts | 80 ++--- .../m365group/m365group-renew.spec.ts | 32 +- .../commands/m365group/m365group-renew.ts | 62 ++-- .../commands/m365group/m365group-set.spec.ts | 139 +++++---- .../entra/commands/m365group/m365group-set.ts | 274 +++++++----------- .../m365group/m365group-teamify.spec.ts | 58 ++-- .../commands/m365group/m365group-teamify.ts | 76 ++--- .../m365group/m365group-user-add.spec.ts | 138 ++++----- .../commands/m365group/m365group-user-add.ts | 148 ++++------ .../m365group/m365group-user-list.spec.ts | 79 +++-- .../commands/m365group/m365group-user-list.ts | 100 ++----- .../m365group/m365group-user-remove.spec.ts | 107 ++++--- .../m365group/m365group-user-remove.ts | 143 +++------ .../m365group/m365group-user-set.spec.ts | 49 ++-- .../commands/m365group/m365group-user-set.ts | 145 ++++----- 34 files changed, 1127 insertions(+), 1785 deletions(-) diff --git a/src/m365/entra/commands/m365group/m365group-add.spec.ts b/src/m365/entra/commands/m365group/m365group-add.spec.ts index 984bc51e59d..bd207705145 100644 --- a/src/m365/entra/commands/m365group/m365group-add.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-add.spec.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -71,6 +72,7 @@ describe(commands.M365GROUP_ADD, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -79,6 +81,7 @@ describe(commands.M365GROUP_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -103,6 +106,8 @@ describe(commands.M365GROUP_ADD, () => { request.post, request.put, request.get, + fs.existsSync, + fs.lstatSync, fs.readFileSync ]); }); @@ -129,7 +134,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', verbose: true }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { description: 'My awesome group', displayName: 'My group', @@ -157,7 +162,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', visibility: 'Private' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', visibility: 'Private' }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { description: 'My awesome group', displayName: 'My group', @@ -185,7 +190,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', allowMembersToPost: true, hideGroupInOutlook: true, subscribeNewGroupMembers: true, welcomeEmailDisabled: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', allowMembersToPost: true, hideGroupInOutlook: true, subscribeNewGroupMembers: true, welcomeEmailDisabled: true }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { description: 'My awesome group', displayName: 'My group', @@ -202,6 +207,8 @@ describe(commands.M365GROUP_ADD, () => { }); it('creates Microsoft 365 Group with a png logo', async () => { + sinon.stub(fs, 'existsSync').callsFake(() => true); + sinon.stub(fs, 'lstatSync').callsFake(() => fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { @@ -218,7 +225,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'logo.png' } } as any); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'logo.png' }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { description: 'My awesome group', displayName: 'My group', @@ -236,6 +243,8 @@ describe(commands.M365GROUP_ADD, () => { }); it('creates Microsoft 365 Group with a jpg logo', async () => { + sinon.stub(fs, 'existsSync').callsFake(() => true); + sinon.stub(fs, 'lstatSync').callsFake(() => fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { @@ -253,7 +262,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'logo.jpg' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'logo.jpg' }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { description: 'My awesome group', displayName: 'My group', @@ -271,6 +280,8 @@ describe(commands.M365GROUP_ADD, () => { }); it('creates Microsoft 365 Group with a gif logo', async () => { + sinon.stub(fs, 'existsSync').callsFake(() => true); + sinon.stub(fs, 'lstatSync').callsFake(() => fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { @@ -291,7 +302,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'logo.gif' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'logo.gif' }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { description: 'My awesome group', displayName: 'My group', @@ -310,6 +321,8 @@ describe(commands.M365GROUP_ADD, () => { it('handles failure when creating Microsoft 365 Group with a logo and succeeds on tenth call', async () => { let amountOfCalls = 1; + sinon.stub(fs, 'existsSync').callsFake(() => true); + sinon.stub(fs, 'lstatSync').callsFake(() => fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { @@ -329,7 +342,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'logo.png' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'logo.png' }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { description: 'My awesome group', displayName: 'My group', @@ -346,6 +359,8 @@ describe(commands.M365GROUP_ADD, () => { }); it('handles failure when creating Microsoft 365 Group with a logo (debug)', async () => { + sinon.stub(fs, 'existsSync').callsFake(() => true); + sinon.stub(fs, 'lstatSync').callsFake(() => fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { @@ -356,7 +371,7 @@ describe(commands.M365GROUP_ADD, () => { }); sinon.stub(request, 'put').rejects(new Error('Invalid request')); - await assert.rejects(command.action(logger, { options: { debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'logo.png' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'logo.png' }) }), new CommandError('Invalid request')); assert.deepStrictEqual(postStub.lastCall.args[0].data, { @@ -400,7 +415,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user@contoso.onmicrosoft.com' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user@contoso.onmicrosoft.com' }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data['@odata.id'], 'https://graph.microsoft.com/v1.0/users/949b16c1-a032-453e-a8ae-89a52bfc1d8a'); assert(loggerLogSpy.calledOnceWith(groupResponse)); }); @@ -447,7 +462,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' }) }); assert.deepStrictEqual(postStub.getCall(-2).args[0].data, { '@odata.id': 'https://graph.microsoft.com/v1.0/users/949b16c1-a032-453e-a8ae-89a52bfc1d8a' }); @@ -483,7 +498,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user@contoso.onmicrosoft.com' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user@contoso.onmicrosoft.com' }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data['@odata.id'], 'https://graph.microsoft.com/v1.0/users/949b16c1-a032-453e-a8ae-89a52bfc1d8a'); assert(loggerLogSpy.calledOnceWith(groupResponse)); }); @@ -526,7 +541,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' }) }); assert.deepStrictEqual(postStub.getCall(-2).args[0].data, { '@odata.id': 'https://graph.microsoft.com/v1.0/users/949b16c1-a032-453e-a8ae-89a52bfc1d8a' }); @@ -557,7 +572,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' }) }), new CommandError('Cannot proceed with group creation. The following users provided are invalid : user2@contoso.onmicrosoft.com')); }); @@ -583,7 +598,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' }) }), new CommandError('Cannot proceed with group creation. The following users provided are invalid : user2@contoso.onmicrosoft.com')); }); @@ -609,7 +624,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' }) }), new CommandError('Cannot proceed with group creation. The following users provided are invalid : user2@contoso.onmicrosoft.com')); }); @@ -635,7 +650,7 @@ describe(commands.M365GROUP_ADD, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' }) }), new CommandError('Cannot proceed with group creation. The following users provided are invalid : user2@contoso.onmicrosoft.com')); }); @@ -651,107 +666,104 @@ describe(commands.M365GROUP_ADD, () => { } }); - await assert.rejects(command.action(logger, { options: { clientId: '6a7b1395-d313-4682-8ed4-65a6265a6320', resourceId: '6a7b1395-d313-4682-8ed4-65a6265a6320', scope: 'user_impersonation' } } as any), - new CommandError('Invalid request')); }); - it('passes validation when the displayName, description and mailNickname are specified', async () => { - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when the displayName, description and mailNickname are specified', () => { + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group' }); + assert.strictEqual(actual.success, true); }); - it('fails validation when mailNickname contains spaces', async () => { - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my group' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when mailNickname contains spaces', () => { + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my group' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if one of the owners is invalid', async () => { - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if one of the owners is invalid', () => { + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the owner is valid', async () => { - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the owner is valid', () => { + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user@contoso.onmicrosoft.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation with multiple owners, comma-separated', async () => { - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation with multiple owners, comma-separated', () => { + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation with multiple owners, comma-separated with an additional space', async () => { - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user1@contoso.onmicrosoft.com, user2@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation with multiple owners, comma-separated with an additional space', () => { + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', owners: 'user1@contoso.onmicrosoft.com, user2@contoso.onmicrosoft.com' }); + assert.strictEqual(actual.success, true); }); - it('fails validation if one of the members is invalid', async () => { - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if one of the members is invalid', () => { + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the member is valid', async () => { - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the member is valid', () => { + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user@contoso.onmicrosoft.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation with multiple members, comma-separated', async () => { - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation with multiple members, comma-separated', () => { + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation with multiple members, comma-separated with an additional space', async () => { - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user1@contoso.onmicrosoft.com, user2@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation with multiple members, comma-separated with an additional space', () => { + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', members: 'user1@contoso.onmicrosoft.com, user2@contoso.onmicrosoft.com' }); + assert.strictEqual(actual.success, true); }); - it('fails validation if logoPath points to a non-existent file', async () => { + it('fails validation if logoPath points to a non-existent file', () => { sinon.stub(fs, 'existsSync').callsFake(() => false); - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'invalid' } }, commandInfo); - sinonUtil.restore(fs.existsSync); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if logoPath points to a folder', async () => { + it('fails validation if logoPath points to a folder', () => { const stats = { ...fsStats, isDirectory: () => true }; sinon.stub(fs, 'existsSync').callsFake(() => true); sinon.stub(fs, 'lstatSync').callsFake(() => stats); - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'folder' } }, commandInfo); - sinonUtil.restore([ - fs.existsSync, - fs.lstatSync - ]); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'folder' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if incorrect visibility is specified.', async () => { - const actual = await command.validate({ - options: { - displayName: 'My group', - description: 'My awesome group', - mailNickname: 'my_group', - visibility: "invalid" - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if incorrect visibility is specified.', () => { + const actual = commandOptionsSchema.safeParse({ + displayName: 'My group', + description: 'My awesome group', + mailNickname: 'my_group', + visibility: "invalid" + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation with valid visibility value', () => { + const actual = commandOptionsSchema.safeParse({ + displayName: 'My group', + description: 'My awesome group', + mailNickname: 'my_group', + visibility: "Private" + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if logoPath points to an existing file', async () => { + it('passes validation if logoPath points to an existing file', () => { sinon.stub(fs, 'existsSync').callsFake(() => true); sinon.stub(fs, 'lstatSync').callsFake(() => fsStats); - const actual = await command.validate({ options: { displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'folder' } }, commandInfo); - sinonUtil.restore([ - fs.existsSync, - fs.lstatSync - ]); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ displayName: 'My group', description: 'My awesome group', mailNickname: 'my_group', logoPath: 'folder' }); + assert.strictEqual(actual.success, true); }); it('supports specifying displayName', () => { - const options = command.options; + const options = commandInfo.options; let containsOption = false; options.forEach(o => { - if (o.option.indexOf('--displayName') > -1) { + if (o.long === 'displayName') { containsOption = true; } }); @@ -759,10 +771,10 @@ describe(commands.M365GROUP_ADD, () => { }); it('supports specifying description', () => { - const options = command.options; + const options = commandInfo.options; let containsOption = false; options.forEach(o => { - if (o.option.indexOf('--description') > -1) { + if (o.long === 'description') { containsOption = true; } }); @@ -770,10 +782,10 @@ describe(commands.M365GROUP_ADD, () => { }); it('supports specifying mailNickname', () => { - const options = command.options; + const options = commandInfo.options; let containsOption = false; options.forEach(o => { - if (o.option.indexOf('--mailNickname') > -1) { + if (o.long === 'mailNickname') { containsOption = true; } }); @@ -781,10 +793,10 @@ describe(commands.M365GROUP_ADD, () => { }); it('supports specifying owners', () => { - const options = command.options; + const options = commandInfo.options; let containsOption = false; options.forEach(o => { - if (o.option.indexOf('--owners') > -1) { + if (o.long === 'owners') { containsOption = true; } }); @@ -792,10 +804,10 @@ describe(commands.M365GROUP_ADD, () => { }); it('supports specifying members', () => { - const options = command.options; + const options = commandInfo.options; let containsOption = false; options.forEach(o => { - if (o.option.indexOf('--members') > -1) { + if (o.long === 'members') { containsOption = true; } }); @@ -803,10 +815,10 @@ describe(commands.M365GROUP_ADD, () => { }); it('supports specifying group type', () => { - const options = command.options; + const options = commandInfo.options; let containsOption = false; options.forEach(o => { - if (o.option.indexOf('--visibility') > -1) { + if (o.long === 'visibility') { containsOption = true; } }); @@ -814,10 +826,10 @@ describe(commands.M365GROUP_ADD, () => { }); it('supports specifying logo file path', () => { - const options = command.options; + const options = commandInfo.options; let containsOption = false; options.forEach(o => { - if (o.option.indexOf('--logoPath') > -1) { + if (o.long === 'logoPath') { containsOption = true; } }); diff --git a/src/m365/entra/commands/m365group/m365group-add.ts b/src/m365/entra/commands/m365group/m365group-add.ts index c8d25956e40..d1ae293545e 100644 --- a/src/m365/entra/commands/m365group/m365group-add.ts +++ b/src/m365/entra/commands/m365group/m365group-add.ts @@ -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; + +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; @@ -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 ' - }, - { - option: '-m, --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 | 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 { diff --git a/src/m365/entra/commands/m365group/m365group-conversation-list.spec.ts b/src/m365/entra/commands/m365group/m365group-conversation-list.spec.ts index 0a3596c5f08..c08406fe5f4 100644 --- a/src/m365/entra/commands/m365group/m365group-conversation-list.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-conversation-list.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; @@ -12,13 +14,13 @@ import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './m365group-conversation-list.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; -import { cli } from '../../../../cli/cli.js'; describe(commands.M365GROUP_CONVERSATION_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; const jsonOutput = { "value": [ @@ -53,6 +55,7 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => { sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves('00000000-0000-0000-0000-000000000000'); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -94,13 +97,15 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => { it('defines correct properties for the default output', () => { assert.deepStrictEqual(command.defaultProperties(), ['topic', 'lastDeliveredDateTime', 'id']); }); + it('fails validation if the groupId is not a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: 'not-c49b-4fd4-8223-28f0ac3a6402' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: 'not-c49b-4fd4-8223-28f0ac3a6402' }); + assert.strictEqual(actual.success, false); }); + it('passes validation if the groupId is a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: '1caf7dcd-7e83-4c3a-94f7-932a1299c844' }); + assert.strictEqual(actual.success, true); }); it('Retrieve conversations for the group specified by groupId in the tenant (verbose)', async () => { @@ -112,9 +117,9 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, groupId: "00000000-0000-0000-0000-000000000000" - } + }) }); assert(loggerLogSpy.calledWith( jsonOutput.value @@ -130,9 +135,9 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, groupName: "Finance" - } + }) }); assert(loggerLogSpy.calledWith( jsonOutput.value @@ -142,7 +147,7 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => { it('correctly handles error when listing conversations', async () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000" } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: "00000000-0000-0000-0000-000000000000" }) } as any), new CommandError('An error has occurred')); }); @@ -152,7 +157,7 @@ describe(commands.M365GROUP_CONVERSATION_LIST, () => { sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { groupId: groupId } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupId }) } as any), new CommandError(`Specified group with id '${groupId}' is not a Microsoft 365 group.`)); }); diff --git a/src/m365/entra/commands/m365group/m365group-conversation-list.ts b/src/m365/entra/commands/m365group/m365group-conversation-list.ts index 2db25b06e01..4633d468b92 100644 --- a/src/m365/entra/commands/m365group/m365group-conversation-list.ts +++ b/src/m365/entra/commands/m365group/m365group-conversation-list.ts @@ -1,21 +1,26 @@ import { Conversation } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { odata } from '../../../../utils/odata.js'; -import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; +const options = globalOptionsZod + .extend({ + groupId: zod.alias('i', z.string().uuid().optional()), + groupName: zod.alias('n', z.string().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - groupId?: string; - groupName?: string; -} - class EntraM365GroupConversationListCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_CONVERSATION_LIST; @@ -29,44 +34,15 @@ class EntraM365GroupConversationListCommand extends GraphCommand { return ['topic', 'lastDeliveredDateTime', 'id']; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --groupId [groupId]' - }, - { - option: '-n, --groupName [groupName]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.groupId && !validation.isValidGuid(args.options.groupId as string)) { - return `${args.options.groupId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['groupId', 'groupName'] }); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTypes(): void { - this.types.string.push('groupId', 'groupName'); + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.groupId, options.groupName].filter(Boolean).length === 1, { + message: 'Specify either groupId or groupName' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-conversation-post-list.spec.ts b/src/m365/entra/commands/m365group/m365group-conversation-post-list.spec.ts index 8fbeafa25c4..59a0597a819 100644 --- a/src/m365/entra/commands/m365group/m365group-conversation-post-list.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-conversation-post-list.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -20,6 +21,7 @@ describe(commands.M365GROUP_CONVERSATION_POST_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; const jsonOutput = { "value": [ @@ -83,6 +85,7 @@ describe(commands.M365GROUP_CONVERSATION_POST_LIST, () => { sinon.stub(entraGroup, 'isUnifiedGroup').resolves(true); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -134,8 +137,8 @@ describe(commands.M365GROUP_CONVERSATION_POST_LIST, () => { return defaultValue; }); - const actual = await command.validate({ options: { groupId: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', groupName: 'MyGroup', threadId: '123' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', groupName: 'MyGroup', threadId: '123' }); + assert.strictEqual(actual.success, false); }); it('fails validation if neither groupId nor groupName specified', async () => { @@ -147,18 +150,18 @@ describe(commands.M365GROUP_CONVERSATION_POST_LIST, () => { return defaultValue; }); - const actual = await command.validate({ options: { threadId: '123' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ threadId: '123' }); + assert.strictEqual(actual.success, false); }); it('fails validation if the groupId is not a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: 'not-c49b-4fd4-8223-28f0ac3a6402', threadId: '123' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: 'not-c49b-4fd4-8223-28f0ac3a6402', threadId: '123' }); + assert.strictEqual(actual.success, false); }); it('passes validation if the groupId is a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', threadId: '123' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: '1caf7dcd-7e83-4c3a-94f7-932a1299c844', threadId: '123' }); + assert.strictEqual(actual.success, true); }); it('Retrieve posts for the specified conversation threadId of m365 group groupId in the tenant (verbose)', async () => { @@ -170,11 +173,11 @@ describe(commands.M365GROUP_CONVERSATION_POST_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, groupId: "00000000-0000-0000-0000-000000000000", threadId: "AAQkADkwN2Q2NDg1LWQ3ZGYtNDViZi1iNGRiLTVhYjJmN2Q5NDkxZQAQAOnRAfDf71lIvrdK85FAn5E=" - } + }) }); assert(loggerLogSpy.calledWith( jsonOutput.value @@ -200,11 +203,11 @@ describe(commands.M365GROUP_CONVERSATION_POST_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, groupName: "MyGroup", threadId: "AAQkADkwN2Q2NDg1LWQ3ZGYtNDViZi1iNGRiLTVhYjJmN2Q5NDkxZQAQAOnRAfDf71lIvrdK85FAn5E=" - } + }) }); assert(loggerLogSpy.calledWith( jsonOutput.value @@ -215,10 +218,10 @@ describe(commands.M365GROUP_CONVERSATION_POST_LIST, () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred')); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ groupId: "00000000-0000-0000-0000-000000000000", threadId: "AAQkADkwN2Q2NDg1LWQ3ZGYtNDViZi1iNGRiLTVhYjJmN2Q5NDkxZQAQAOnRAfDf71lIvrdK85FAn5E=" - } + }) } as any), new CommandError('An error has occurred')); }); @@ -228,7 +231,7 @@ describe(commands.M365GROUP_CONVERSATION_POST_LIST, () => { sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { groupId: groupId, threadId: 'AAQkADkwN2Q2NDg1LWQ3ZGYtNDViZi1iNGRiLTVhYjJmN2Q5NDkxZQAQAOnRAfDf71lIvrdK85FAn5E=' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupId, threadId: 'AAQkADkwN2Q2NDg1LWQ3ZGYtNDViZi1iNGRiLTVhYjJmN2Q5NDkxZQAQAOnRAfDf71lIvrdK85FAn5E=' }) } as any), new CommandError(`Specified group with id '${groupId}' is not a Microsoft 365 group.`)); }); }); \ No newline at end of file diff --git a/src/m365/entra/commands/m365group/m365group-conversation-post-list.ts b/src/m365/entra/commands/m365group/m365group-conversation-post-list.ts index 3e2d7840eac..aba90540e57 100644 --- a/src/m365/entra/commands/m365group/m365group-conversation-post-list.ts +++ b/src/m365/entra/commands/m365group/m365group-conversation-post-list.ts @@ -1,23 +1,28 @@ import { Post } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { formatting } from '../../../../utils/formatting.js'; import { odata } from '../../../../utils/odata.js'; -import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +const options = globalOptionsZod + .extend({ + groupId: zod.alias('i', z.string().uuid().optional()), + groupName: zod.alias('d', z.string().optional()), + threadId: zod.alias('t', z.string()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - groupId?: string; - groupName?: string; - threadId: string; -} - class EntraM365GroupConversationPostListCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_CONVERSATION_POST_LIST; @@ -27,52 +32,15 @@ class EntraM365GroupConversationPostListCommand extends GraphCommand { return 'Lists conversation posts of a Microsoft 365 group'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - groupId: typeof args.options.groupId !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.groupId, options.groupName].filter(Boolean).length === 1, { + message: 'Specify either groupId or groupName' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --groupId [groupId]' - }, - { - option: '-d, --groupName [groupName]' - }, - { - option: '-t, --threadId ' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.groupId && !validation.isValidGuid(args.options.groupId as string)) { - return `${args.options.groupId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['groupId', 'groupName'] }); } public defaultProperties(): string[] | undefined { diff --git a/src/m365/entra/commands/m365group/m365group-get.spec.ts b/src/m365/entra/commands/m365group/m365group-get.spec.ts index d33e3d26277..058c9dcfa1c 100644 --- a/src/m365/entra/commands/m365group/m365group-get.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-get.spec.ts @@ -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'; @@ -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(); @@ -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(() => { @@ -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, @@ -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, @@ -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, @@ -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); @@ -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, @@ -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, @@ -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, @@ -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 () => { @@ -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.`)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-get.ts b/src/m365/entra/commands/m365group/m365group-get.ts index a39ad6723ad..a8a4cde8ed6 100644 --- a/src/m365/entra/commands/m365group/m365group-get.ts +++ b/src/m365/entra/commands/m365group/m365group-get.ts @@ -1,23 +1,28 @@ +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 { entraGroup } from '../../../../utils/entraGroup.js'; -import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { GroupExtended } from './GroupExtended.js'; +const options = globalOptionsZod + .extend({ + id: zod.alias('i', z.string().uuid().optional()), + displayName: zod.alias('n', z.string().optional()), + includeSiteUrl: z.boolean().optional(), + withSiteUrl: z.boolean().optional() + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - displayName?: string; - includeSiteUrl?: boolean; - withSiteUrl?: boolean; -} - class EntraM365GroupGetCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_GET; @@ -27,60 +32,15 @@ class EntraM365GroupGetCommand extends GraphCommand { return 'Gets information about the specified Microsoft 365 Group or Microsoft Teams team'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - includeSiteUrl: !!args.options.includeSiteUrl, - withSiteUrl: !!args.options.withSiteUrl + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.id, options.displayName].filter(Boolean).length === 1, { + message: 'Specify either id or displayName' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id [id]' - }, - { - option: '-n, --displayName [displayName]' - }, - { - option: '--includeSiteUrl' - }, - { - option: '--withSiteUrl' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'displayName'] }); - } - - #initTypes(): void { - this.types.string.push('id', 'displayName'); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-list.spec.ts b/src/m365/entra/commands/m365group/m365group-list.spec.ts index 6c867d5ca52..21838579103 100644 --- a/src/m365/entra/commands/m365group/m365group-list.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-list.spec.ts @@ -1,6 +1,7 @@ import { Group } from '@microsoft/microsoft-graph-types'; import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -20,6 +21,7 @@ describe(commands.M365GROUP_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -28,6 +30,7 @@ describe(commands.M365GROUP_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -132,7 +135,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -249,7 +252,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -377,7 +380,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { orphaned: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ orphaned: true }) }); assert([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -465,7 +468,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, orphaned: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, orphaned: true }) }); assert([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -542,7 +545,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'Team' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'Team' }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -659,7 +662,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { mailNickname: 'team' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ mailNickname: 'team' }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -776,7 +779,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'Team', mailNickname: 'team' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'Team', mailNickname: 'team' }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -894,7 +897,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: displayName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: displayName }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -959,7 +962,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { mailNickname: mailNickName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ mailNickname: mailNickName }) }); assert(loggerLogSpy.calledWith([])); }); @@ -1083,7 +1086,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -1256,7 +1259,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: {} } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError('An error has occurred')); }); @@ -1322,7 +1325,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { output: 'json' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ output: 'json' }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -1450,7 +1453,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { includeSiteUrl: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ includeSiteUrl: true }) }); assert(loggerErrSpy.calledWith(chalk.yellow(`Parameter 'includeSiteUrl' is deprecated. Please use 'withSiteUrl' instead`))); sinonUtil.restore(loggerErrSpy); @@ -1526,7 +1529,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { withSiteUrl: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ withSiteUrl: true }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -1655,7 +1658,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, withSiteUrl: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, withSiteUrl: true }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -1782,7 +1785,7 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { withSiteUrl: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ withSiteUrl: true }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -1909,26 +1912,26 @@ describe(commands.M365GROUP_LIST, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { withSiteUrl: true } } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ withSiteUrl: true }) }), new CommandError('An error has occurred')); }); - it('passes validation if only includeSiteUrl option set', async () => { - const actual = await command.validate({ options: { includeSiteUrl: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if only includeSiteUrl option set', () => { + const actual = commandOptionsSchema.safeParse({ includeSiteUrl: true }); + assert.strictEqual(actual.success, true); }); - it('passes validation if only withSiteUrl option set', async () => { - const actual = await command.validate({ options: { withSiteUrl: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if only withSiteUrl option set', () => { + const actual = commandOptionsSchema.safeParse({ withSiteUrl: true }); + assert.strictEqual(actual.success, true); }); - it('passes validation if only orphaned option set', async () => { - const actual = await command.validate({ options: { orphaned: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if only orphaned option set', () => { + const actual = commandOptionsSchema.safeParse({ orphaned: true }); + assert.strictEqual(actual.success, true); }); - it('passes validation if no options set', async () => { - const actual = await command.validate({ options: {} }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if no options set', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-list.ts b/src/m365/entra/commands/m365group/m365group-list.ts index 980eb517c8a..30bee81c288 100644 --- a/src/m365/entra/commands/m365group/m365group-list.ts +++ b/src/m365/entra/commands/m365group/m365group-list.ts @@ -1,24 +1,30 @@ +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 { odata } from '../../../../utils/odata.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { GroupExtended } from './GroupExtended.js'; +const options = globalOptionsZod + .extend({ + displayName: zod.alias('d', z.string().optional()), + mailNickname: zod.alias('m', z.string().optional()), + includeSiteUrl: z.boolean().optional(), + withSiteUrl: z.boolean().optional(), + orphaned: z.boolean().optional() + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - displayName?: string; - mailNickname?: string; - includeSiteUrl?: boolean; - withSiteUrl?: boolean; - orphaned?: boolean; -} - class EntraM365GroupListCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_LIST; @@ -28,43 +34,8 @@ class EntraM365GroupListCommand extends GraphCommand { return 'Lists Microsoft 365 Groups in the current tenant'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - displayName: typeof args.options.displayName !== 'undefined', - mailNickname: typeof args.options.mailNickname !== 'undefined', - includeSiteUrl: args.options.includeSiteUrl, - withSiteUrl: !!args.options.withSiteUrl, - orphaned: !!args.options.orphaned - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-d, --displayName [displayName]' - }, - { - option: '-m, --mailNickname [displayName]' - }, - { - option: '--includeSiteUrl' - }, - { - option: '--withSiteUrl' - }, - { - option: '--orphaned' - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public defaultProperties(): string[] | undefined { diff --git a/src/m365/entra/commands/m365group/m365group-recyclebinitem-clear.spec.ts b/src/m365/entra/commands/m365group/m365group-recyclebinitem-clear.spec.ts index 9822d5e55ab..9aefeff1cd6 100644 --- a/src/m365/entra/commands/m365group/m365group-recyclebinitem-clear.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-recyclebinitem-clear.spec.ts @@ -1,8 +1,10 @@ import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -17,6 +19,8 @@ describe(commands.M365GROUP_RECYCLEBINITEM_CLEAR, () => { let log: string[]; let logger: Logger; let promptIssued: boolean = false; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -25,6 +29,8 @@ describe(commands.M365GROUP_RECYCLEBINITEM_CLEAR, () => { sinon.stub(session, 'getId').returns(''); sinon.stub(fs, 'readFileSync').returns('abc'); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -133,7 +139,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_CLEAR, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ force: true }) }); assert(deleteStub.calledTwice); }); @@ -235,7 +241,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_CLEAR, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ force: true }) }); assert(deleteStub.calledThrice); }); @@ -251,12 +257,12 @@ describe(commands.M365GROUP_RECYCLEBINITEM_CLEAR, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ force: true }) }); assert(deleteStub.notCalled); }); it('prompts before clearing the M365 Group recycle bin items when --force option is not passed', async () => { - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(promptIssued); }); @@ -265,7 +271,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_CLEAR, () => { const deleteSpy = sinon.spy(request, 'delete'); sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(deleteSpy.notCalled); }); @@ -273,7 +279,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_CLEAR, () => { const deleteSpy = sinon.spy(request, 'delete'); sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(deleteSpy.notCalled); }); @@ -343,7 +349,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_CLEAR, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(deleteStub.calledTwice); }); @@ -439,7 +445,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_CLEAR, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(deleteStub.calledThrice); }); @@ -447,17 +453,6 @@ describe(commands.M365GROUP_RECYCLEBINITEM_CLEAR, () => { const errorMessage = 'Something went wrong'; sinon.stub(request, 'get').rejects(new Error(errorMessage)); - await assert.rejects(command.action(logger, { options: { force: true } }), new CommandError(errorMessage)); - }); - - it('supports specifying confirmation flag', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--force') > -1) { - containsOption = true; - } - }); - assert(containsOption); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ force: true }) }), new CommandError(errorMessage)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-recyclebinitem-clear.ts b/src/m365/entra/commands/m365group/m365group-recyclebinitem-clear.ts index 6319d1e3139..03b9f43f9c2 100644 --- a/src/m365/entra/commands/m365group/m365group-recyclebinitem-clear.ts +++ b/src/m365/entra/commands/m365group/m365group-recyclebinitem-clear.ts @@ -1,20 +1,26 @@ import { DirectoryObject } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { odata } from '../../../../utils/odata.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +const options = globalOptionsZod + .extend({ + force: zod.alias('f', z.boolean().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - force?: boolean; -} - class EntraM365GroupRecycleBinItemClearCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_RECYCLEBINITEM_CLEAR; @@ -24,27 +30,8 @@ class EntraM365GroupRecycleBinItemClearCommand extends GraphCommand { return 'Clears all M365 Groups from recycle bin.'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: typeof args.options.force !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-f, --force' - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-recyclebinitem-list.spec.ts b/src/m365/entra/commands/m365group/m365group-recyclebinitem-list.spec.ts index 937ba4a83a2..d1c04d30410 100644 --- a/src/m365/entra/commands/m365group/m365group-recyclebinitem-list.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-recyclebinitem-list.spec.ts @@ -1,6 +1,9 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -15,6 +18,8 @@ describe(commands.M365GROUP_RECYCLEBINITEM_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -22,6 +27,8 @@ describe(commands.M365GROUP_RECYCLEBINITEM_LIST, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -126,7 +133,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -243,7 +250,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -360,7 +367,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupName: 'Deleted' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupName: 'Deleted' }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -477,7 +484,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupMailNickname: 'd_team' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupMailNickname: 'd_team' }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -594,7 +601,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupName: 'Deleted', groupMailNickname: 'd_team' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupName: 'Deleted', groupMailNickname: 'd_team' }) }); assert(loggerLogSpy.calledWith([ { "id": "010d2f0a-0c17-4ec8-b694-e85bbe607013", @@ -653,28 +660,6 @@ describe(commands.M365GROUP_RECYCLEBINITEM_LIST, () => { const errorMessage = 'Something went wrong'; sinon.stub(request, 'get').rejects(new Error(errorMessage)); - await assert.rejects(command.action(logger, { options: { mailNickname: 'd_team' } }), new CommandError(errorMessage)); - }); - - it('supports specifying groupName', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--groupName') > -1) { - containsOption = true; - } - }); - assert(containsOption); - }); - - it('supports specifying groupMailNickname', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--groupMailNickname') > -1) { - containsOption = true; - } - }); - assert(containsOption); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupMailNickname: 'd_team' }) }), new CommandError(errorMessage)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-recyclebinitem-list.ts b/src/m365/entra/commands/m365group/m365group-recyclebinitem-list.ts index dde8f5219db..12ab7b7fba9 100644 --- a/src/m365/entra/commands/m365group/m365group-recyclebinitem-list.ts +++ b/src/m365/entra/commands/m365group/m365group-recyclebinitem-list.ts @@ -1,20 +1,26 @@ import { DirectoryObject } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { formatting } from '../../../../utils/formatting.js'; import { odata } from '../../../../utils/odata.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +const options = globalOptionsZod + .extend({ + groupName: zod.alias('d', z.string().optional()), + groupMailNickname: zod.alias('m', z.string().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - groupName?: string; - groupMailNickname?: string; -} - class EntraM365GroupRecycleBinItemListCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_RECYCLEBINITEM_LIST; @@ -24,31 +30,8 @@ class EntraM365GroupRecycleBinItemListCommand extends GraphCommand { return 'Lists Microsoft 365 Groups deleted in the current tenant'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - groupName: typeof args.options.groupName !== 'undefined', - groupMailNickname: typeof args.options.groupMailNickname !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-d, --groupName [groupName]' - }, - { - option: '-m, --groupMailNickname [groupMailNickname]' - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public defaultProperties(): string[] | undefined { diff --git a/src/m365/entra/commands/m365group/m365group-recyclebinitem-remove.spec.ts b/src/m365/entra/commands/m365group/m365group-recyclebinitem-remove.spec.ts index 1c9302d8732..fa3a1d27aca 100644 --- a/src/m365/entra/commands/m365group/m365group-recyclebinitem-remove.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-recyclebinitem-remove.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -60,6 +61,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; let promptIssued: boolean = false; before(() => { @@ -69,6 +71,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { if (settingName === 'prompt') { return false; @@ -122,28 +125,24 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { }); it('fails validation when id is not a valid GUID', async () => { - const actual = await command.validate({ - options: { - id: 'invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: 'invalid' + }); + assert.strictEqual(actual.success, false); }); it('validates for a correct input with id', async () => { - const actual = await command.validate({ - options: { - id: validGroupId - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: validGroupId + }); + assert.strictEqual(actual.success, true); }); it('prompts before removing the specified group when force option not passed with id', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validGroupId - } + }) }); assert(promptIssued); @@ -152,9 +151,9 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { it('aborts removing the specified group when force option not passed and prompt not confirmed', async () => { const deleteSpy = sinon.spy(request, 'delete'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validGroupId - } + }) }); assert(deleteSpy.notCalled); }); @@ -169,10 +168,10 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ mailNickname: validGroupMailNickname, force: true - } + }) }), new CommandError(`The specified group '${validGroupMailNickname}' does not exist.`)); }); @@ -194,10 +193,10 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ mailNickname: validGroupMailNickname, force: true - } + }) }), new CommandError("Multiple groups with name 'Devteam' found. Found: 00000000-0000-0000-0000-000000000000.")); }); @@ -228,9 +227,9 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ displayName: validGroupDisplayName - } + }) }); assert(removeRequestIssued); }); @@ -245,10 +244,10 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validGroupId, force: true - } + }) }); }); @@ -264,9 +263,9 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validGroupId - } + }) }); }); @@ -289,9 +288,9 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ displayName: validGroupDisplayName - } + }) }); }); @@ -312,9 +311,9 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ mailNickname: validGroupMailNickname - } + }) }); }); @@ -327,10 +326,10 @@ describe(commands.M365GROUP_RECYCLEBINITEM_REMOVE, () => { sinon.stub(request, 'delete').rejects(error); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validGroupId, force: true - } + }) }), new CommandError("An error has occurred")); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-recyclebinitem-remove.ts b/src/m365/entra/commands/m365group/m365group-recyclebinitem-remove.ts index 64ac3680379..499b133347d 100644 --- a/src/m365/entra/commands/m365group/m365group-recyclebinitem-remove.ts +++ b/src/m365/entra/commands/m365group/m365group-recyclebinitem-remove.ts @@ -1,24 +1,29 @@ import { Group } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; 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 { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +const options = globalOptionsZod + .extend({ + id: zod.alias('i', z.string().uuid().optional()), + displayName: zod.alias('d', z.string().optional()), + mailNickname: zod.alias('m', z.string().optional()), + force: zod.alias('f', z.boolean().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - displayName?: string; - mailNickname?: string; - force: boolean; -} - class EntraM365GroupRecycleBinItemRemoveCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_RECYCLEBINITEM_REMOVE; @@ -28,57 +33,15 @@ class EntraM365GroupRecycleBinItemRemoveCommand extends GraphCommand { return 'Permanently deletes a Microsoft 365 Group from the recycle bin in the current tenant'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - displayName: typeof args.options.displayName !== 'undefined', - mailNickname: typeof args.options.mailNickname !== 'undefined', - force: !!args.options.force + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.id, options.displayName, options.mailNickname].filter(Boolean).length === 1, { + message: 'Specify either id, displayName, or mailNickname' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id [id]' - }, - { - option: '-d, --displayName [displayName]' - }, - { - option: '-m, --mailNickname [mailNickname]' - }, - { - option: '-f, --force' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'displayName', 'mailNickname'] }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-recyclebinitem-restore.spec.ts b/src/m365/entra/commands/m365group/m365group-recyclebinitem-restore.spec.ts index ca028cc592d..6c6da62fa64 100644 --- a/src/m365/entra/commands/m365group/m365group-recyclebinitem-restore.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-recyclebinitem-restore.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -60,6 +61,7 @@ describe(commands.M365GROUP_RECYCLEBINITEM_RESTORE, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -68,8 +70,9 @@ describe(commands.M365GROUP_RECYCLEBINITEM_RESTORE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { - if (settingName === 'prompt') { + if (settingName === settingsNames.prompt) { return false; } @@ -116,13 +119,13 @@ describe(commands.M365GROUP_RECYCLEBINITEM_RESTORE, () => { }); it('fails validation if the id is not a valid GUID', async () => { - const actual = await command.validate({ options: { id: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: 'invalid' }); + assert.strictEqual(actual.success, false); }); it('passes validation when the id is a valid GUID', async () => { - const actual = await command.validate({ options: { id: '2c1ba4c4-cd9b-4417-832f-92a34bc34b2a' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: validGroupId }); + assert.strictEqual(actual.success, true); }); it('restores the specified group by id', async () => { @@ -135,10 +138,10 @@ describe(commands.M365GROUP_RECYCLEBINITEM_RESTORE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, id: validGroupId - } + }) }); }); @@ -159,10 +162,10 @@ describe(commands.M365GROUP_RECYCLEBINITEM_RESTORE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, displayName: validGroupDisplayName - } + }) }); }); @@ -183,17 +186,17 @@ describe(commands.M365GROUP_RECYCLEBINITEM_RESTORE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, mailNickname: validGroupMailNickname - } + }) }); }); it('correctly handles error when group is not found', async () => { sinon.stub(request, 'post').rejects({ error: { 'odata.error': { message: { value: 'Group Not Found.' } } } }); - await assert.rejects(command.action(logger, { options: { id: '28beab62-7540-4db1-a23f-29a6018a3848' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: '28beab62-7540-4db1-a23f-29a6018a3848' }) } as any), new CommandError('Group Not Found.')); }); @@ -207,10 +210,9 @@ describe(commands.M365GROUP_RECYCLEBINITEM_RESTORE, () => { }); await assert.rejects(command.action(logger, { - options: { - mailNickname: validGroupMailNickname, - force: true - } + options: commandOptionsSchema.parse({ + mailNickname: validGroupMailNickname + }) }), new CommandError(`The specified group '${validGroupMailNickname}' does not exist.`)); }); @@ -222,7 +224,6 @@ describe(commands.M365GROUP_RECYCLEBINITEM_RESTORE, () => { return defaultValue; }); - sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/directory/deletedItems/Microsoft.Graph.Group?$filter=mailNickname eq '${formatting.encodeQueryParameter(validGroupMailNickname)}'`) { return multipleGroupsResponse; @@ -232,10 +233,9 @@ describe(commands.M365GROUP_RECYCLEBINITEM_RESTORE, () => { }); await assert.rejects(command.action(logger, { - options: { - mailNickname: validGroupMailNickname, - force: true - } + options: commandOptionsSchema.parse({ + mailNickname: validGroupMailNickname + }) }), new CommandError("Multiple groups with name 'Devteam' found. Found: 00000000-0000-0000-0000-000000000000.")); }); @@ -262,22 +262,11 @@ describe(commands.M365GROUP_RECYCLEBINITEM_RESTORE, () => { sinon.stub(cli, 'handleMultipleResultsFound').resolves(singleGroupsResponse.value[0]); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, displayName: validGroupDisplayName - } + }) }); assert(postRequestIssued); }); - - it('supports specifying id', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--id') > -1) { - containsOption = true; - } - }); - assert(containsOption); - }); }); diff --git a/src/m365/entra/commands/m365group/m365group-recyclebinitem-restore.ts b/src/m365/entra/commands/m365group/m365group-recyclebinitem-restore.ts index 02aec59d3b2..70e241031d1 100644 --- a/src/m365/entra/commands/m365group/m365group-recyclebinitem-restore.ts +++ b/src/m365/entra/commands/m365group/m365group-recyclebinitem-restore.ts @@ -1,23 +1,28 @@ import { Group } from '@microsoft/microsoft-graph-types'; +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 { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { cli } from '../../../../cli/cli.js'; +const options = globalOptionsZod + .extend({ + id: zod.alias('i', z.string().uuid().optional()), + displayName: zod.alias('d', z.string().optional()), + mailNickname: zod.alias('m', z.string().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - displayName?: string; - mailNickname?: string; -} - class EntraM365GroupRecycleBinItemRestoreCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_RECYCLEBINITEM_RESTORE; @@ -27,53 +32,15 @@ class EntraM365GroupRecycleBinItemRestoreCommand extends GraphCommand { return 'Restores a deleted Microsoft 365 Group'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - displayName: typeof args.options.displayName !== 'undefined', - mailNickname: typeof args.options.mailNickname !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.id, options.displayName, options.mailNickname].filter(Boolean).length === 1, { + message: 'Specify either id, displayName, or mailNickname' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id [id]' - }, - { - option: '-d, --displayName [displayName]' - }, - { - option: '-m, --mailNickname [mailNickname]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'displayName', 'mailNickname'] }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-remove.spec.ts b/src/m365/entra/commands/m365group/m365group-remove.spec.ts index eca0d3fee81..a60ea98cfae 100644 --- a/src/m365/entra/commands/m365group/m365group-remove.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-remove.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -22,6 +23,7 @@ describe(commands.M365GROUP_REMOVE, () => { let loggerLogToStderrSpy: sinon.SinonSpy; let commandInfo: CommandInfo; let promptIssued: boolean = false; + let commandOptionsSchema: z.ZodTypeAny; const groupId = '3e6e705d-6fb5-4ca7-84dc-3c8f5154fe2c'; @@ -106,6 +108,7 @@ describe(commands.M365GROUP_REMOVE, () => { auth.connection.active = true; auth.connection.spoUrl = 'https://contoso.sharepoint.com'; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -161,17 +164,17 @@ describe(commands.M365GROUP_REMOVE, () => { }); it('fails validation if the id is not a valid GUID', async () => { - const actual = await command.validate({ options: { id: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: 'abc' }); + assert.strictEqual(actual.success, false); }); it('passes validation when the id is a valid GUID', async () => { - const actual = await command.validate({ options: { id: '2c1ba4c4-cd9b-4417-832f-92a34bc34b2a' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '2c1ba4c4-cd9b-4417-832f-92a34bc34b2a' }); + assert.strictEqual(actual.success, true); }); it('prompts before removing the specified group when force option not passed', async () => { - await command.action(logger, { options: { id: '28beab62-7540-4db1-a23f-29a6018a3848' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: '28beab62-7540-4db1-a23f-29a6018a3848' }) }); assert(promptIssued); }); @@ -179,7 +182,7 @@ describe(commands.M365GROUP_REMOVE, () => { it('aborts removing the group when prompt not confirmed', async () => { const getGroupSpy: sinon.SinonStub = defaultGetStub(); - await command.action(logger, { options: { id: '28beab62-7540-4db1-a23f-29a6018a3848' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: '28beab62-7540-4db1-a23f-29a6018a3848' }) }); assert(getGroupSpy.notCalled); }); @@ -190,7 +193,7 @@ describe(commands.M365GROUP_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { id: groupId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, verbose: true }) }); assert(deletedGroupSpy.calledOnce); assert(loggerLogToStderrSpy.calledWith(`Deleting the group site: 'https://contoso.sharepoint.com/teams/sales'...`)); }); @@ -202,7 +205,7 @@ describe(commands.M365GROUP_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { displayName: 'Finance', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'Finance', verbose: true }) }); assert(deletedGroupSpy.calledOnce); assert(loggerLogToStderrSpy.calledWith(`Deleting the group site: 'https://contoso.sharepoint.com/teams/sales'...`)); }); @@ -212,7 +215,7 @@ describe(commands.M365GROUP_REMOVE, () => { defaultPostStub(); const deleteStub: sinon.SinonStub = defaultDeleteStub(); - await command.action(logger, { options: { id: groupId, verbose: true, skipRecycleBin: true, force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, verbose: true, skipRecycleBin: true, force: true }) }); assert(deleteStub.called); assert(loggerLogToStderrSpy.calledWith("Group has been deleted and is now available in the deleted groups list. Removing permanently...")); }); @@ -230,7 +233,7 @@ describe(commands.M365GROUP_REMOVE, () => { defaultPostStub(); const deleteStub: sinon.SinonStub = defaultDeleteStub(); - await command.action(logger, { options: { id: groupId, verbose: true, skipRecycleBin: true, force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, verbose: true, skipRecycleBin: true, force: true }) }); assert(deleteStub.called); }); @@ -274,7 +277,7 @@ describe(commands.M365GROUP_REMOVE, () => { }); defaultDeleteStub(); - await assert.rejects(command.action(logger, { options: { id: groupId, skipRecycleBin: true, force: true, debug: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, skipRecycleBin: true, force: true, debug: true }) }), new CommandError('An error has occurred.')); }); @@ -290,7 +293,7 @@ describe(commands.M365GROUP_REMOVE, () => { defaultPostStub(); defaultDeleteStub(); - await assert.rejects(command.action(logger, { options: { id: groupId, verbose: true, skipRecycleBin: true, force: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, verbose: true, skipRecycleBin: true, force: true }) }), new CommandError('Error')); }); @@ -306,7 +309,7 @@ describe(commands.M365GROUP_REMOVE, () => { defaultPostStub(); const deleteStub: sinon.SinonStub = defaultDeleteStub(); - await command.action(logger, { options: { id: groupId, verbose: true, skipRecycleBin: true, force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, verbose: true, skipRecycleBin: true, force: true }) }); assert(deleteStub.notCalled); }); @@ -316,7 +319,7 @@ describe(commands.M365GROUP_REMOVE, () => { sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { id: groupId, force: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, force: true }) }), new CommandError(`Specified group with id '${groupId}' is not a Microsoft 365 group.`)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-remove.ts b/src/m365/entra/commands/m365group/m365group-remove.ts index 6d0987617d8..6dcab090b46 100644 --- a/src/m365/entra/commands/m365group/m365group-remove.ts +++ b/src/m365/entra/commands/m365group/m365group-remove.ts @@ -1,9 +1,10 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; -import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import config from '../../../../config.js'; @@ -11,17 +12,21 @@ import { formatting } from '../../../../utils/formatting.js'; import { ClientSvcResponse, ClientSvcResponseContents, FormDigestInfo, spo } from '../../../../utils/spo.js'; import { setTimeout } from 'timers/promises'; +const options = globalOptionsZod + .extend({ + id: zod.alias('i', z.string().uuid().optional()), + displayName: zod.alias('n', z.string().optional()), + force: zod.alias('f', z.boolean().optional()), + skipRecycleBin: z.boolean().optional() + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - displayName?: string; - force?: boolean; - skipRecycleBin: boolean; -} - class EntraM365GroupRemoveCommand extends GraphCommand { private static maxRetries: number = 10; private intervalInMs: number = 5000; @@ -34,60 +39,15 @@ class EntraM365GroupRemoveCommand extends GraphCommand { return 'Removes a Microsoft 365 Group'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: (!(!args.options.force)).toString(), - skipRecycleBin: args.options.skipRecycleBin + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.id, options.displayName].filter(Boolean).length === 1, { + message: 'Specify either id or displayName' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id [id]' - }, - { - option: '-n, --displayName [displayName]' - }, - { - option: '-f, --force' - }, - { - option: '--skipRecycleBin' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'displayName'] }); - } - - #initTypes(): void { - this.types.string.push('id', 'displayName'); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-renew.spec.ts b/src/m365/entra/commands/m365group/m365group-renew.spec.ts index 74fe71e4fbe..f28cb6be1ac 100644 --- a/src/m365/entra/commands/m365group/m365group-renew.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-renew.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -20,6 +21,7 @@ describe(commands.M365GROUP_RENEW, () => { let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; let loggerLogToStderrSpy: sinon.SinonSpy; + let commandOptionsSchema: z.ZodTypeAny; const groupId = '28beab62-7540-4db1-a23f-29a6018a3848'; @@ -32,6 +34,7 @@ describe(commands.M365GROUP_RENEW, () => { sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -80,7 +83,7 @@ describe(commands.M365GROUP_RENEW, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: groupId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, verbose: true }) }); assert(loggerLogSpy.notCalled); }); @@ -93,7 +96,7 @@ describe(commands.M365GROUP_RENEW, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: 'Finance', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: 'Finance', verbose: true }) }); assert(loggerLogSpy.notCalled); }); @@ -106,36 +109,25 @@ describe(commands.M365GROUP_RENEW, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, id: groupId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, id: groupId }) }); assert(loggerLogToStderrSpy.called); }); it('correctly handles error when group is not found', async () => { sinon.stub(request, 'post').rejects({ error: { 'odata.error': { message: { value: 'The remote server returned an error: (404) Not Found.' } } } }); - await assert.rejects(command.action(logger, { options: { id: groupId } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId }) }), new CommandError('The remote server returned an error: (404) Not Found.')); }); - it('supports specifying id', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--id') > -1) { - containsOption = true; - } - }); - assert(containsOption); - }); - it('fails validation if the id is not a valid GUID', async () => { - const actual = await command.validate({ options: { id: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: 'abc' }); + assert.strictEqual(actual.success, false); }); it('passes validation when the id is a valid GUID', async () => { - const actual = await command.validate({ options: { id: '2c1ba4c4-cd9b-4417-832f-92a34bc34b2a' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '2c1ba4c4-cd9b-4417-832f-92a34bc34b2a' }); + assert.strictEqual(actual.success, true); }); it('throws error when the group is not a unified group', async () => { @@ -144,7 +136,7 @@ describe(commands.M365GROUP_RENEW, () => { 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.`)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-renew.ts b/src/m365/entra/commands/m365group/m365group-renew.ts index 36f47f6df0a..623c1bf2023 100644 --- a/src/m365/entra/commands/m365group/m365group-renew.ts +++ b/src/m365/entra/commands/m365group/m365group-renew.ts @@ -1,20 +1,25 @@ +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 { entraGroup } from '../../../../utils/entraGroup.js'; -import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +const options = globalOptionsZod + .extend({ + id: zod.alias('i', z.string().uuid().optional()), + displayName: zod.alias('n', z.string().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - displayName?: string; -} - class EntraM365GroupRenewCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_RENEW; @@ -24,44 +29,15 @@ class EntraM365GroupRenewCommand extends GraphCommand { return `Renews Microsoft 365 group's expiration`; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id [id]' - }, - { - option: '-n, --displayName [displayName]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'displayName'] }); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTypes(): void { - this.types.string.push('id', 'displayName'); + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.id, options.displayName].filter(Boolean).length === 1, { + message: 'Specify either id or displayName' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-set.spec.ts b/src/m365/entra/commands/m365group/m365group-set.spec.ts index 51b3e4345e8..962f6410671 100644 --- a/src/m365/entra/commands/m365group/m365group-set.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-set.spec.ts @@ -2,6 +2,7 @@ import { Group } from '@microsoft/microsoft-graph-types'; import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -53,6 +54,7 @@ describe(commands.M365GROUP_SET, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; let loggerLogToStderrSpy: sinon.SinonSpy; const groupId = 'f3db5c2b-068f-480d-985b-ec78b9fa0e76'; @@ -70,6 +72,7 @@ describe(commands.M365GROUP_SET, () => { }; } commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -129,7 +132,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: groupName, newDisplayName: 'My group', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: groupName, newDisplayName: 'My group', verbose: true }) }); assert(patchStub.calledOnce); }); @@ -146,7 +149,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, id: '28beab62-7540-4db1-a23f-29a6018a3848', description: 'My group' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, id: '28beab62-7540-4db1-a23f-29a6018a3848', description: 'My group' }) }); assert(loggerLogToStderrSpy.called); }); @@ -159,7 +162,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: groupId, description: '' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, description: '' }) }); assert.deepStrictEqual(JSON.parse(JSON.stringify(patchStub.firstCall.args[0].data)), { description: null }); }); @@ -176,7 +179,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: false } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: false }) }); assert(loggerLogSpy.notCalled); }); @@ -193,11 +196,13 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: true }) }); assert(loggerLogSpy.notCalled); }); it('updates Microsoft 365 Group logo with a png image', async () => { + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'lstatSync').returns(fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); sinon.stub(request, 'put').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups/28beab62-7540-4db1-a23f-29a6018a3848/photo/$value' && @@ -209,11 +214,13 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', logoPath: 'logo.png' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', logoPath: 'logo.png' }) }); assert(loggerLogSpy.notCalled); }); it('updates Microsoft 365 Group logo with a jpg image (debug)', async () => { + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'lstatSync').returns(fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); sinon.stub(request, 'put').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/photo/$value' && @@ -225,11 +232,13 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', logoPath: 'logo.jpg' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', logoPath: 'logo.jpg' }) }); assert(loggerLogToStderrSpy.called); }); it('updates Microsoft 365 Group logo with a gif image', async () => { + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'lstatSync').returns(fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); sinon.stub(request, 'put').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/photo/$value' && @@ -241,12 +250,14 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', logoPath: 'logo.gif' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', logoPath: 'logo.gif' }) }); assert(loggerLogSpy.notCalled); }); it('handles failure when updating Microsoft 365 Group logo and succeeds after 10 attempts', async () => { let amountOfCalls = 1; + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'lstatSync').returns(fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); const putStub = sinon.stub(request, 'put').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/photo/$value' && amountOfCalls < 10) { @@ -261,7 +272,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', logoPath: 'logo.png' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', logoPath: 'logo.png' }) }); assert.strictEqual(putStub.callCount, 10); }); @@ -271,6 +282,8 @@ describe(commands.M365GROUP_SET, () => { message: 'An error has occurred' } }; + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'lstatSync').returns(fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); sinon.stub(request, 'put').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/photo/$value') { @@ -280,7 +293,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', logoPath: 'logo.png' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', logoPath: 'logo.png' }) } as any), new CommandError('An error has occurred')); }); @@ -290,6 +303,8 @@ describe(commands.M365GROUP_SET, () => { message: 'An error has occurred' } }; + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'lstatSync').returns(fsStats); sinon.stub(fs, 'readFileSync').returns('abc'); sinon.stub(request, 'put').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/photo/$value') { @@ -299,7 +314,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { debug: true, id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', logoPath: 'logo.png' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', logoPath: 'logo.png' }) } as any), new CommandError('An error has occurred')); }); @@ -342,7 +357,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', memberIds: '949b16c1-a032-453e-a8ae-89a52bfc1d8a', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', memberIds: '949b16c1-a032-453e-a8ae-89a52bfc1d8a', verbose: true }) }); assert(loggerLogSpy.notCalled); }); @@ -404,7 +419,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', memberUserNames: 'user@contoso.onmicrosoft.com', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', memberUserNames: 'user@contoso.onmicrosoft.com', verbose: true }) }); assert(loggerLogSpy.notCalled); }); @@ -447,7 +462,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', ownerIds: '3527dada-9368-4cdd-a958-5460f5658e0e', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', ownerIds: '3527dada-9368-4cdd-a958-5460f5658e0e', verbose: true }) }); assert(loggerLogSpy.notCalled); }); @@ -509,7 +524,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', ownerUserNames: 'user@contoso.onmicrosoft.com', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', ownerUserNames: 'user@contoso.onmicrosoft.com', verbose: true }) }); assert(loggerLogSpy.notCalled); }); @@ -522,7 +537,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: groupId, allowExternalSenders: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, allowExternalSenders: true }) }); assert.deepStrictEqual(JSON.parse(JSON.stringify(patchStub.firstCall.args[0].data)), { allowExternalSenders: true }); }); @@ -535,7 +550,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: groupId, autoSubscribeNewMembers: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, autoSubscribeNewMembers: true }) }); assert.deepStrictEqual(JSON.parse(JSON.stringify(patchStub.firstCall.args[0].data)), { autoSubscribeNewMembers: true }); }); @@ -548,7 +563,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: groupId, autoSubscribeNewMembers: true, hideFromAddressLists: false } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, autoSubscribeNewMembers: true, hideFromAddressLists: false }) }); assert.deepStrictEqual(JSON.parse(JSON.stringify(patchStub.firstCall.args[0].data)), { autoSubscribeNewMembers: true, hideFromAddressLists: false }); }); @@ -561,7 +576,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: groupId, hideFromOutlookClients: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, hideFromOutlookClients: true }) }); assert.deepStrictEqual(JSON.parse(JSON.stringify(patchStub.firstCall.args[0].data)), { hideFromOutlookClients: true }); }); @@ -589,7 +604,7 @@ describe(commands.M365GROUP_SET, () => { }; }); - await assert.rejects(command.action(logger, { options: { id: groupId, ownerIds: userIds.join(',') } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, ownerIds: userIds.join(',') }) }), new CommandError(`One or more added object references already exist for the following modified properties: 'members'.`)); }); @@ -626,7 +641,7 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { id: groupId, ownerIds: userIds.join(',') } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, ownerIds: userIds.join(',') }) }), new CommandError('Service unavailable.')); }); @@ -642,7 +657,7 @@ describe(commands.M365GROUP_SET, () => { } }); - await assert.rejects(command.action(logger, { options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', newDisplayName: 'My group' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', newDisplayName: 'My group' }) } as any), new CommandError('An error has occurred')); }); @@ -650,7 +665,7 @@ describe(commands.M365GROUP_SET, () => { sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { id: groupId, newDisplayName: 'Updated title' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, newDisplayName: 'Updated title' }) }), new CommandError(`Specified group with id '${groupId}' is not a Microsoft 365 group.`)); }); @@ -658,7 +673,7 @@ describe(commands.M365GROUP_SET, () => { sinonUtil.restore(accessToken.isAppOnlyAccessToken); sinon.stub(accessToken, 'isAppOnlyAccessToken').resolves(true); - await assert.rejects(command.action(logger, { options: { id: groupId, allowExternalSenders: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, allowExternalSenders: true }) }), new CommandError(`Option 'allowExternalSenders' and 'autoSubscribeNewMembers' can only be used when using delegated permissions.`)); }); @@ -666,99 +681,109 @@ describe(commands.M365GROUP_SET, () => { sinonUtil.restore(accessToken.isAppOnlyAccessToken); sinon.stub(accessToken, 'isAppOnlyAccessToken').resolves(true); - await assert.rejects(command.action(logger, { options: { id: groupId, autoSubscribeNewMembers: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId, autoSubscribeNewMembers: true }) }), new CommandError(`Option 'allowExternalSenders' and 'autoSubscribeNewMembers' can only be used when using delegated permissions.`)); }); it('fails validation if the id is not a valid GUID', async () => { - const actual = await command.validate({ options: { id: 'invalid', description: 'My awesome group' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: 'invalid', description: 'My awesome group' }); + assert.strictEqual(actual.success, false); }); it('passes validation when the id is a valid GUID and displayName specified', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', newDisplayName: 'My group' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', newDisplayName: 'My group' }); + assert.strictEqual(actual.success, true); }); it('passes validation when the id is a valid GUID and description specified', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', description: 'My awesome group' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', description: 'My awesome group' }); + assert.strictEqual(actual.success, true); }); it('fails validation if no property to update is specified', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if ownerIds and ownerUserNames options are both specified', async () => { + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', ownerIds: '7167b488-1ffb-43f1-9547-35969469bada', ownerUserNames: 'john.doe@contoso.com' }); + assert.strictEqual(actual.success, false); }); it('fails validation if ownerIds contains invalid GUID', async () => { const ownerIds = ['7167b488-1ffb-43f1-9547-35969469bada', 'foo']; - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', ownerIds: ownerIds.join(',') } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', ownerIds: ownerIds.join(',') }); + assert.strictEqual(actual.success, false); }); it('fails validation if ownerUserNames contains invalid user principal name', async () => { const ownerUserNames = ['john.doe@contoso.com', 'foo']; - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', ownerUserNames: ownerUserNames.join(',') } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', ownerUserNames: ownerUserNames.join(',') }); + assert.strictEqual(actual.success, false); }); it('fails validation if memberIds contains invalid GUID', async () => { const memberIds = ['7167b488-1ffb-43f1-9547-35969469bada', 'foo']; - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', memberIds: memberIds.join(',') } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', memberIds: memberIds.join(',') }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if memberIds and memberUserNames are both specified', async () => { + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', memberIds: '7167b488-1ffb-43f1-9547-35969469bada', memberUserNames: 'john.doe@contoso.com' }); + assert.strictEqual(actual.success, false); }); it('fails validation if memberUserNames contains invalid user principal name', async () => { const memberUserNames = ['john.doe@contoso.com', 'foo']; - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', memberUserNames: memberUserNames.join(',') } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', memberUserNames: memberUserNames.join(',') }); + assert.strictEqual(actual.success, false); }); it('passes validation if isPrivate is true', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: true } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: true }); + assert.strictEqual(actual.success, true); }); it('passes validation if isPrivate is false', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: false } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: false }); + assert.strictEqual(actual.success, true); }); it('passes validation when all required parameters are valid with ids', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', ownerIds: userIds.join(',') } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', ownerIds: userIds.join(',') }); + assert.strictEqual(actual.success, true); }); it('passes validation when all required parameters are valid with user names', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', memberUserNames: userUpns.join(',') } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', memberUserNames: userUpns.join(',') }); + assert.strictEqual(actual.success, true); }); it('fails validation if logoPath points to a non-existent file', async () => { sinon.stub(fs, 'existsSync').returns(false); - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', logoPath: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', logoPath: 'invalid' }); + assert.strictEqual(actual.success, false); }); it('fails validation if logoPath points to a folder', async () => { const stats = { ...fsStats, isDirectory: () => true }; sinon.stub(fs, 'existsSync').returns(true); sinon.stub(fs, 'lstatSync').returns(stats); - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', logoPath: 'folder' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', logoPath: 'folder' }); + assert.strictEqual(actual.success, false); }); it('passes validation if logoPath points to an existing file', async () => { sinon.stub(fs, 'existsSync').returns(true); sinon.stub(fs, 'lstatSync').returns(fsStats); - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', logoPath: 'folder' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', logoPath: 'folder' }); + assert.strictEqual(actual.success, true); }); it('passes validation if all options are being set', async () => { sinon.stub(fs, 'existsSync').returns(true); sinon.stub(fs, 'lstatSync').returns(fsStats); - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', newDisplayName: 'Title', description: 'Description', logoPath: 'logo.png', ownerIds: userIds.join(','), memberIds: userIds.join(','), isPrivate: false, allowExternalSenders: false, autoSubscribeNewMembers: false, hideFromAddressLists: false, hideFromOutlookClients: false } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '28beab62-7540-4db1-a23f-29a6018a3848', newDisplayName: 'Title', description: 'Description', logoPath: 'logo.png', ownerIds: userIds.join(','), memberIds: userIds.join(','), isPrivate: false, allowExternalSenders: false, autoSubscribeNewMembers: false, hideFromAddressLists: false, hideFromOutlookClients: false }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-set.ts b/src/m365/entra/commands/m365group/m365group-set.ts index 4d0d876d257..35502c38114 100644 --- a/src/m365/entra/commands/m365group/m365group-set.ts +++ b/src/m365/entra/commands/m365group/m365group-set.ts @@ -2,10 +2,12 @@ import { Group } 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 { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; @@ -16,27 +18,31 @@ import { formatting } from '../../../../utils/formatting.js'; import { odata } from '../../../../utils/odata.js'; import { User } from '@microsoft/microsoft-graph-types'; +const options = globalOptionsZod + .extend({ + id: zod.alias('i', z.string().uuid().optional()), + displayName: zod.alias('n', z.string().optional()), + newDisplayName: zod.alias('newDisplayName', z.string().optional()), + description: zod.alias('d', z.string().optional()), + ownerIds: zod.alias('ownerIds', z.string().optional()), + ownerUserNames: zod.alias('ownerUserNames', z.string().optional()), + memberIds: zod.alias('memberIds', z.string().optional()), + memberUserNames: zod.alias('memberUserNames', z.string().optional()), + isPrivate: zod.alias('isPrivate', z.boolean().optional()), + logoPath: zod.alias('l', z.string().optional()), + allowExternalSenders: zod.alias('allowExternalSenders', z.boolean().optional()), + autoSubscribeNewMembers: zod.alias('autoSubscribeNewMembers', z.boolean().optional()), + hideFromAddressLists: zod.alias('hideFromAddressLists', z.boolean().optional()), + hideFromOutlookClients: zod.alias('hideFromOutlookClients', z.boolean().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { - id?: string; - displayName?: string; - newDisplayName?: string; - description?: string; - ownerIds?: string; - ownerUserNames?: string; - memberIds?: string; - memberUserNames?: string; - isPrivate?: boolean; - logoPath?: string; - allowExternalSenders?: boolean; - autoSubscribeNewMembers?: boolean; - hideFromAddressLists?: boolean; - hideFromOutlookClients?: boolean; -} - class EntraM365GroupSetCommand extends GraphCommand { private static numRepeat: number = 15; private pollingInterval: number = 500; @@ -49,175 +55,97 @@ class EntraM365GroupSetCommand extends GraphCommand { return 'Updates Microsoft 365 Group properties'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - displayName: typeof args.options.displayName !== 'undefined', - newDisplayName: typeof args.options.newDisplayName !== 'undefined', - description: typeof args.options.description !== 'undefined', - ownerIds: typeof args.options.ownerIds !== 'undefined', - ownerUserNames: typeof args.options.ownerUserNames !== 'undefined', - memberIds: typeof args.options.memberIds !== 'undefined', - memberUserNames: typeof args.options.memberUserNames !== 'undefined', - isPrivate: !!args.options.isPrivate, - logoPath: typeof args.options.logoPath !== 'undefined', - allowExternalSenders: !!args.options.allowExternalSenders, - autoSubscribeNewMembers: !!args.options.autoSubscribeNewMembers, - hideFromAddressLists: !!args.options.hideFromAddressLists, - hideFromOutlookClients: !!args.options.hideFromOutlookClients - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id [id]' - }, - { - option: '-n, --displayName [displayName]' - }, - { - option: '--newDisplayName [newDisplayName]' - }, - { - option: '-d, --description [description]' - }, - { - option: '--ownerIds [ownerIds]' - }, - { - option: '--ownerUserNames [ownerUserNames]' - }, - { - option: '--memberIds [memberIds]' - }, - { - option: '--memberUserNames [memberUserNames]' - }, - { - option: '--isPrivate [isPrivate]', - autocomplete: ['true', 'false'] - }, - { - option: '-l, --logoPath [logoPath]' - }, - { - option: '--allowExternalSenders [allowExternalSenders]', - autocomplete: ['true', 'false'] - }, - { - option: '--autoSubscribeNewMembers [autoSubscribeNewMembers]', - autocomplete: ['true', 'false'] - }, - { - option: '--hideFromAddressLists [hideFromAddressLists]', - autocomplete: ['true', 'false'] - }, - { - option: '--hideFromOutlookClients [hideFromOutlookClients]', - autocomplete: ['true', 'false'] - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'displayName'] }); - this.optionSets.push({ - options: ['ownerIds', 'ownerUserNames'], - runsWhen: (args) => { - return args.options.ownerIds !== undefined || args.options.ownerUserNames !== undefined; - } - }); - this.optionSets.push({ - options: ['memberIds', 'memberUserNames'], - runsWhen: (args) => { - return args.options.memberIds !== undefined || args.options.memberUserNames !== undefined; - } - }); - } - - #initTypes(): void { - this.types.boolean.push('isPrivate', 'allowEternalSenders', 'autoSubscribeNewMembers', 'hideFromAddressLists', 'hideFromOutlookClients'); - this.types.string.push('id', 'displayName', 'newDisplayName', 'description', 'ownerIds', 'ownerUserNames', 'memberIds', 'memberUserNames', 'logoPath'); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (!args.options.newDisplayName && - args.options.description === undefined && - args.options.ownerIds === undefined && - args.options.ownerUserNames === undefined && - args.options.memberIds === undefined && - args.options.memberUserNames === undefined && - args.options.isPrivate === undefined && - args.options.logoPath === undefined && - args.options.allowExternalSenders === undefined && - args.options.autoSubscribeNewMembers === undefined && - args.options.hideFromAddressLists === undefined && - args.options.hideFromOutlookClients === undefined) { - return 'Specify at least one option to update.'; + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.id, options.displayName].filter(Boolean).length === 1, { + message: 'Specify either id or displayName' + }) + .refine(options => { + return !!(options.newDisplayName || + options.description !== undefined || + options.ownerIds !== undefined || + options.ownerUserNames !== undefined || + options.memberIds !== undefined || + options.memberUserNames !== undefined || + options.isPrivate !== undefined || + options.logoPath !== undefined || + options.allowExternalSenders !== undefined || + options.autoSubscribeNewMembers !== undefined || + options.hideFromAddressLists !== undefined || + options.hideFromOutlookClients !== undefined); + }, { + message: 'Specify at least one option to update' + }) + .refine(options => { + if (options.ownerIds && options.ownerUserNames) { + return false; } - - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; + return true; + }, { + message: 'Specify either ownerIds or ownerUserNames but not both' + }) + .refine(options => { + if (options.memberIds && options.memberUserNames) { + return false; } - - if (args.options.ownerIds) { - const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.ownerIds); - if (isValidGUIDArrayResult !== true) { - return `The following GUIDs are invalid for the option 'ownerIds': ${isValidGUIDArrayResult}.`; - } + return true; + }, { + message: 'Specify either memberIds or memberUserNames but not both' + }) + .refine(options => { + if (options.ownerIds) { + const isValidGUIDArrayResult = validation.isValidGuidArray(options.ownerIds); + return isValidGUIDArrayResult === true; } - - if (args.options.ownerUserNames) { - const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.ownerUserNames); - if (isValidUPNArrayResult !== true) { - return `The following user principal names are invalid for the option 'ownerUserNames': ${isValidUPNArrayResult}.`; - } + return true; + }, { + message: 'Specify valid GUIDs for the option \'ownerIds\'' + }) + .refine(options => { + if (options.ownerUserNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(options.ownerUserNames); + return isValidUPNArrayResult === true; } - - if (args.options.memberIds) { - const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.memberIds); - if (isValidGUIDArrayResult !== true) { - return `The following GUIDs are invalid for the option 'memberIds': ${isValidGUIDArrayResult}.`; - } + return true; + }, { + message: 'Specify valid user principal names for the option \'ownerUserNames\'' + }) + .refine(options => { + if (options.memberIds) { + const isValidGUIDArrayResult = validation.isValidGuidArray(options.memberIds); + return isValidGUIDArrayResult === true; } - - if (args.options.memberUserNames) { - const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.memberUserNames); - if (isValidUPNArrayResult !== true) { - return `The following user principal names are invalid for the option 'memberUserNames': ${isValidUPNArrayResult}.`; - } + return true; + }, { + message: 'Specify valid GUIDs for the option \'memberIds\'' + }) + .refine(options => { + if (options.memberUserNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(options.memberUserNames); + return isValidUPNArrayResult === true; } - - if (args.options.logoPath) { - const fullPath: string = path.resolve(args.options.logoPath); - + return true; + }, { + message: 'Specify valid user principal names for the option \'memberUserNames\'' + }) + .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; } } - return true; - } - ); + }, { + message: 'File not found or path points to a directory' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-teamify.spec.ts b/src/m365/entra/commands/m365group/m365group-teamify.spec.ts index 51ffd16de12..4ae4eac71de 100644 --- a/src/m365/entra/commands/m365group/m365group-teamify.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-teamify.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -19,6 +20,7 @@ describe(commands.M365GROUP_TEAMIFY, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -28,6 +30,7 @@ describe(commands.M365GROUP_TEAMIFY, () => { sinon.stub(entraGroup, 'isUnifiedGroup').resolves(true); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -77,11 +80,8 @@ describe(commands.M365GROUP_TEAMIFY, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, false); }); it('fails validation if id, displayName and mailNickname options are passed', async () => { @@ -93,22 +93,18 @@ describe(commands.M365GROUP_TEAMIFY, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - id: '8231f9f2-701f-4c6e-93ce-ecb563e3c1ee', - mailNickname: 'GroupName' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: '8231f9f2-701f-4c6e-93ce-ecb563e3c1ee', + mailNickname: 'GroupName' + }); + assert.strictEqual(actual.success, false); }); it('validates for a correct id', async () => { - const actual = await command.validate({ - options: { - id: '8231f9f2-701f-4c6e-93ce-ecb563e3c1ee' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + id: '8231f9f2-701f-4c6e-93ce-ecb563e3c1ee' + }); + assert.strictEqual(actual.success, true); }); it('fails to get M365 group when it does not exists', async () => { @@ -120,10 +116,10 @@ describe(commands.M365GROUP_TEAMIFY, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, mailNickname: 'GroupName' - } + }) }), new CommandError(`The specified group 'GroupName' does not exist.`)); }); @@ -232,10 +228,10 @@ describe(commands.M365GROUP_TEAMIFY, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, mailNickname: 'GroupName' - } + }) }), new CommandError("Multiple groups with mail nickname 'GroupName' found. Found: 00000000-0000-0000-0000-000000000000.")); }); @@ -426,7 +422,7 @@ describe(commands.M365GROUP_TEAMIFY, () => { }); await command.action(logger, { - options: { mailNickname: 'groupname' } + options: commandOptionsSchema.parse({ mailNickname: 'groupname' }) }); assert.strictEqual(requestStub.lastCall.args[0].url, 'https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/team'); }); @@ -480,7 +476,7 @@ describe(commands.M365GROUP_TEAMIFY, () => { }); await command.action(logger, { - options: { id: '8231f9f2-701f-4c6e-93ce-ecb563e3c1ee' } + options: commandOptionsSchema.parse({ id: '8231f9f2-701f-4c6e-93ce-ecb563e3c1ee' }) }); assert.strictEqual(requestStub.lastCall.args[0].url, 'https://graph.microsoft.com/v1.0/groups/8231f9f2-701f-4c6e-93ce-ecb563e3c1ee/team'); }); @@ -588,7 +584,7 @@ describe(commands.M365GROUP_TEAMIFY, () => { }); await command.action(logger, { - options: { mailNickname: 'groupname' } + options: commandOptionsSchema.parse({ mailNickname: 'groupname' }) }); assert.strictEqual(requestStub.lastCall.args[0].url, 'https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/team'); }); @@ -696,7 +692,7 @@ describe(commands.M365GROUP_TEAMIFY, () => { }); await command.action(logger, { - options: { displayName: 'GroupName' } + options: commandOptionsSchema.parse({ displayName: 'GroupName' }) }); assert.strictEqual(requestStub.lastCall.args[0].url, 'https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/team'); }); @@ -725,13 +721,13 @@ describe(commands.M365GROUP_TEAMIFY, () => { }); it('fails validation if the id is not a valid GUID', async () => { - const actual = await command.validate({ options: { id: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: 'invalid' }); + assert.strictEqual(actual.success, false); }); it('passes validation if the id is a valid GUID', async () => { - const actual = await command.validate({ options: { id: '8231f9f2-701f-4c6e-93ce-ecb563e3c1ee' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ id: '8231f9f2-701f-4c6e-93ce-ecb563e3c1ee' }); + assert.strictEqual(actual.success, true); }); it('throws error when the group is not a unified group', async () => { @@ -740,7 +736,7 @@ describe(commands.M365GROUP_TEAMIFY, () => { sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { id: groupId } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: groupId }) } as any), new CommandError(`Specified group with id '${groupId}' is not a Microsoft 365 group.`)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-teamify.ts b/src/m365/entra/commands/m365group/m365group-teamify.ts index 39831cc5db3..fee57ff8914 100644 --- a/src/m365/entra/commands/m365group/m365group-teamify.ts +++ b/src/m365/entra/commands/m365group/m365group-teamify.ts @@ -1,22 +1,27 @@ +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 { entraGroup } from '../../../../utils/entraGroup.js'; import { formatting } from '../../../../utils/formatting.js'; -import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +const options = globalOptionsZod + .extend({ + id: zod.alias('i', z.string().uuid().optional()), + displayName: zod.alias('n', z.string().optional()), + mailNickname: z.string().optional() + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - displayName?: string; - mailNickname?: string; -} - class EntraM365GroupTeamifyCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_TEAMIFY; @@ -26,58 +31,15 @@ class EntraM365GroupTeamifyCommand extends GraphCommand { return 'Creates a new Microsoft Teams team under existing Microsoft 365 group'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - mailNickname: typeof args.options.mailNickname !== 'undefined', - displayName: typeof args.options.displayName !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.id, options.displayName, options.mailNickname].filter(Boolean).length === 1, { + message: 'Specify either id, displayName, or mailNickname' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id [id]' - }, - { - option: '-n, --displayName [displayName]' - }, - { - option: '--mailNickname [mailNickname]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id && !validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'displayName', 'mailNickname'] }); - } - - #initTypes(): void { - this.types.string.push('id', 'displayName', 'mailNickname'); } private async getGroupId(options: Options): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-user-add.spec.ts b/src/m365/entra/commands/m365group/m365group-user-add.spec.ts index a7bdff0faee..d228590a6fe 100644 --- a/src/m365/entra/commands/m365group/m365group-user-add.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-user-add.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -12,7 +13,6 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './m365group-user-add.js'; -import { settingsNames } from '../../../../settingsNames.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { entraUser } from '../../../../utils/entraUser.js'; @@ -24,6 +24,7 @@ describe(commands.M365GROUP_USER_ADD, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -33,6 +34,7 @@ describe(commands.M365GROUP_USER_ADD, () => { sinon.stub(entraGroup, 'isUnifiedGroup').resolves(true); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -74,113 +76,81 @@ describe(commands.M365GROUP_USER_ADD, () => { }); it('fails validation if the groupId is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - groupId: 'not-c49b-4fd4-8223-28f0ac3a6402', - userNames: 'anne.matthews@contoso.onmicrosoft.com' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: 'not-c49b-4fd4-8223-28f0ac3a6402', + userNames: 'anne.matthews@contoso.onmicrosoft.com' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if the teamId is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - teamId: 'not-c49b-4fd4-8223-28f0ac3a6402', - userNames: 'anne.matthews@contoso.onmicrosoft.com' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + teamId: 'not-c49b-4fd4-8223-28f0ac3a6402', + userNames: 'anne.matthews@contoso.onmicrosoft.com' + }); + assert.strictEqual(actual.success, false); }); it('fails validation if neither the groupId nor teamId are provided.', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; + const actual = commandOptionsSchema.safeParse({ + role: 'Member', + userNames: 'anne.matthews@contoso.onmicrosoft.com' }); - - const actual = await command.validate({ - options: { - role: 'Member', - userNames: 'anne.matthews@contoso.onmicrosoft.com' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + assert.strictEqual(actual.success, false); }); it('fails validation when both groupId and teamId are specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; + const actual = commandOptionsSchema.safeParse({ + groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', + teamId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', + userNames: 'anne.matthews@contoso.onmicrosoft.com' }); - - const actual = await command.validate({ - options: { - groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - teamId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - userNames: 'anne.matthews@contoso.onmicrosoft.com' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + assert.strictEqual(actual.success, false); }); 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); + const actual = commandOptionsSchema.safeParse({ groupId: groupId, ids: `${userIds[0]},foo`, role: 'Member' }); + assert.strictEqual(actual.success, false); }); 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); + const actual = commandOptionsSchema.safeParse({ groupId: groupId, userNames: `${userUpns[0]},foo`, role: 'Member' }); + assert.strictEqual(actual.success, false); }); it('fails validation when invalid role specified', async () => { - const actual = await command.validate({ - options: { - groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - userNames: 'anne.matthews@contoso.onmicrosoft.com', - role: 'Invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', + userNames: 'anne.matthews@contoso.onmicrosoft.com', + role: 'Invalid' + }); + assert.strictEqual(actual.success, false); }); it('passes validation when valid groupId, userName and no role specified', async () => { - const actual = await command.validate({ - options: { - groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - userNames: 'anne.matthews@contoso.onmicrosoft.com' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', + userNames: 'anne.matthews@contoso.onmicrosoft.com' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when valid groupId, userName and Owner role specified', async () => { - const actual = await command.validate({ - options: { - groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - userNames: 'anne.matthews@contoso.onmicrosoft.com', - role: 'owner' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', + userNames: 'anne.matthews@contoso.onmicrosoft.com', + role: 'owner' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when valid groupId, userName and Member role specified', async () => { - const actual = await command.validate({ - options: { - groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - userNames: 'anne.matthews@contoso.onmicrosoft.com', - role: 'member' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', + userNames: 'anne.matthews@contoso.onmicrosoft.com', + role: 'member' + }); + assert.strictEqual(actual.success, true); }); it('correctly adds owners to specified Microsoft 365 group', async () => { @@ -200,7 +170,7 @@ describe(commands.M365GROUP_USER_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupName: 'Contoso', userNames: userUpns.join(','), role: 'Owner', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupName: 'Contoso', userNames: userUpns.join(','), role: 'Owner', verbose: true }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [ { id: 1, @@ -237,7 +207,7 @@ describe(commands.M365GROUP_USER_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { teamId: groupId, ids: userIds.join(','), role: 'Member', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ teamId: groupId, ids: userIds.join(','), role: 'Member', verbose: true }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [ { id: 1, @@ -276,7 +246,7 @@ describe(commands.M365GROUP_USER_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { teamName: 'Contoso', ids: userIds.join(','), verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ teamName: 'Contoso', ids: userIds.join(','), verbose: true }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [ { id: 1, @@ -325,7 +295,7 @@ describe(commands.M365GROUP_USER_ADD, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { groupId: groupId, ids: userIds.join(',') } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupId, ids: userIds.join(',') }) }), new CommandError(`One or more added object references already exist for the following modified properties: 'members'.`)); }); @@ -333,7 +303,7 @@ describe(commands.M365GROUP_USER_ADD, () => { sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { groupId: groupId, userNames: 'anne.matthews@contoso.onmicrosoft.com' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupId, userNames: 'anne.matthews@contoso.onmicrosoft.com' }) } as any), new CommandError(`Specified group with id '${groupId}' is not a Microsoft 365 group.`)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-user-add.ts b/src/m365/entra/commands/m365group/m365group-user-add.ts index 0d43d3e3d7f..2b8b4ec8586 100644 --- a/src/m365/entra/commands/m365group/m365group-user-add.ts +++ b/src/m365/entra/commands/m365group/m365group-user-add.ts @@ -1,27 +1,33 @@ +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 { entraGroup } from '../../../../utils/entraGroup.js'; import { validation } from '../../../../utils/validation.js'; import { formatting } from '../../../../utils/formatting.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import { entraUser } from '../../../../utils/entraUser.js'; import commands from '../../commands.js'; +const options = globalOptionsZod + .extend({ + ids: zod.alias('ids', z.string().optional()), + userNames: zod.alias('userNames', z.string().optional()), + groupId: zod.alias('i', z.string().uuid().optional()), + groupName: zod.alias('groupName', z.string().optional()), + teamId: zod.alias('teamId', z.string().uuid().optional()), + teamName: zod.alias('teamName', z.string().optional()), + role: zod.alias('r', z.string().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - ids?: string; - userNames?: string; - groupId?: string; - groupName?: string; - teamId?: string; - teamName?: string; - role?: string; -} - class EntraM365GroupUserAddCommand extends GraphCommand { private readonly allowedRoles: string[] = ['owner', 'member']; @@ -33,98 +39,52 @@ class EntraM365GroupUserAddCommand extends GraphCommand { return 'Adds user to specified Microsoft 365 Group or Microsoft Teams team'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - role: args.options.role !== 'undefined', - teamId: typeof args.options.teamId !== 'undefined', - groupId: typeof args.options.groupId !== 'undefined', - teamName: typeof args.options.teamName !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined', - ids: typeof args.options.ids !== 'undefined', - userNames: typeof args.options.userNames !== 'undefined' - }); - }); - } + public alias(): string[] | undefined { + const teamCommands: string[] = [ + 'teams user add' + ]; - #initOptions(): void { - this.options.unshift( - { - option: '--ids [ids]' - }, - { - option: '--userNames [userNames]' - }, - { - option: '-i, --groupId [groupId]' - }, - { - option: '--groupName [groupName]' - }, - { - option: '--teamId [teamId]' - }, - { - option: '--teamName [teamName]' - }, - { - option: '-r, --role [role]', - autocomplete: this.allowedRoles - } - ); + return teamCommands; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.teamId && !validation.isValidGuid(args.options.teamId as string)) { - return `'${args.options.teamId}' is not a valid GUID for option 'teamId'.`; - } - - if (args.options.groupId && !validation.isValidGuid(args.options.groupId as string)) { - return `'${args.options.groupId}' is not a valid GUID for option 'groupId'.`; - } - - if (args.options.ids) { - const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.ids); - if (isValidGUIDArrayResult !== true) { - return `The following GUIDs are invalid for the option 'ids': ${isValidGUIDArrayResult}.`; - } + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.groupId, options.groupName, options.teamId, options.teamName].filter(Boolean).length === 1, { + message: 'Specify either groupId, groupName, teamId, or teamName' + }) + .refine(options => [options.ids, options.userNames].filter(Boolean).length === 1, { + message: 'Specify either ids or userNames' + }) + .refine(options => { + if (options.ids) { + const isValidGUIDArrayResult = validation.isValidGuidArray(options.ids); + return isValidGUIDArrayResult === true; } - - if (args.options.userNames) { - const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.userNames); - if (isValidUPNArrayResult !== true) { - return `The following user principal names are invalid for the option 'userNames': ${isValidUPNArrayResult}.`; - } + return true; + }, { + message: 'The following GUIDs are invalid for the option \'ids\'' + }) + .refine(options => { + if (options.userNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(options.userNames); + return isValidUPNArrayResult === true; } - - if (args.options.role && !this.allowedRoles.some(role => role.toLowerCase() === args.options.role!.toLowerCase())) { - return `'${args.options.role}' is not a valid role. Allowed values are: ${this.allowedRoles.join(',')}`; + return true; + }, { + message: 'The following user principal names are invalid for the option \'userNames\'' + }) + .refine(options => { + if (options.role) { + return this.allowedRoles.some(role => role.toLowerCase() === options.role!.toLowerCase()); } - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['groupId', 'groupName', 'teamId', 'teamName'] }); - this.optionSets.push({ options: ['ids', 'userNames'] }); - } - - #initTypes(): void { - this.types.string.push('ids', 'userNames', 'groupId', 'groupName', 'teamId', 'teamName', 'role'); + }, { + message: 'Invalid role value. Allowed values are: owner,member' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-user-list.spec.ts b/src/m365/entra/commands/m365group/m365group-user-list.spec.ts index e380f02ae9b..f72eb34adb5 100644 --- a/src/m365/entra/commands/m365group/m365group-user-list.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-user-list.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -19,6 +20,7 @@ describe(commands.M365GROUP_USER_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -28,6 +30,7 @@ describe(commands.M365GROUP_USER_LIST, () => { sinon.stub(entraGroup, 'isUnifiedGroup').resolves(true); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -67,51 +70,41 @@ describe(commands.M365GROUP_USER_LIST, () => { }); it('fails validation if the groupId is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - groupId: 'not-c49b-4fd4-8223-28f0ac3a6402' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: 'not-c49b-4fd4-8223-28f0ac3a6402' + }); + assert.strictEqual(actual.success, false); }); it('fails validation when invalid role specified', async () => { - const actual = await command.validate({ - options: { - groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - role: 'Invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', + role: 'Invalid' + }); + assert.strictEqual(actual.success, false); }); it('passes validation when valid groupId and no role specified', async () => { - const actual = await command.validate({ - options: { - groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when valid groupId and Owner role specified', async () => { - const actual = await command.validate({ - options: { - groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - role: 'Owner' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', + role: 'Owner' + }); + assert.strictEqual(actual.success, true); }); it('passes validation when valid groupId and Member role specified', async () => { - const actual = await command.validate({ - options: { - groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - role: 'Member' - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', + role: 'Member' + }); + assert.strictEqual(actual.success, true); }); it('correctly lists all users in a Microsoft 365 group by group id', async () => { @@ -134,7 +127,7 @@ describe(commands.M365GROUP_USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, groupId: "00000000-0000-0000-0000-000000000000" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, groupId: "00000000-0000-0000-0000-000000000000" }) }); assert(loggerLogSpy.calledOnceWithExactly([ { "id": "00000000-0000-0000-0000-000000000000", @@ -179,7 +172,7 @@ describe(commands.M365GROUP_USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, groupDisplayName: "CLI Test Group" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, groupDisplayName: "CLI Test Group" }) }); assert(loggerLogSpy.calledOnceWithExactly([ { "id": "00000000-0000-0000-0000-000000000000", @@ -213,7 +206,7 @@ describe(commands.M365GROUP_USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", role: "Owner" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: "00000000-0000-0000-0000-000000000000", role: "Owner" }) }); assert(loggerLogSpy.calledOnceWithExactly([ { "id": "00000000-0000-0000-0000-000000000000", @@ -247,7 +240,7 @@ describe(commands.M365GROUP_USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", role: "Member" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: "00000000-0000-0000-0000-000000000000", role: "Member" }) }); assert(loggerLogSpy.calledOnceWithExactly([ { "id": "00000000-0000-0000-0000-000000000000", @@ -290,7 +283,7 @@ describe(commands.M365GROUP_USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, groupId: "00000000-0000-0000-0000-000000000000" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, groupId: "00000000-0000-0000-0000-000000000000" }) }); assert(loggerLogSpy.calledOnceWithExactly([ { "id": "00000000-0000-0000-0000-000000000000", @@ -334,7 +327,7 @@ describe(commands.M365GROUP_USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupId: "2c1ba4c4-cd9b-4417-832f-92a34bc34b2a", properties: "displayName,mail,memberof/id,memberof/displayName" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: "2c1ba4c4-cd9b-4417-832f-92a34bc34b2a", properties: "displayName,mail,memberof/id,memberof/displayName" }) }); assert(loggerLogSpy.calledOnceWithExactly([ { "id": "00000000-0000-0000-0000-000000000000", "displayName": "Karl Matteson", "mail": "karl.matteson@contoso.onmicrosoft.com", "memberOf": [{ "displayName": "Life and Music", "id": "d6c88284-c598-468d-8074-56acaf3c0453" }], "roles": ["Owner"] }, @@ -361,7 +354,7 @@ describe(commands.M365GROUP_USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupId: "2c1ba4c4-cd9b-4417-832f-92a34bc34b2a", filter: "userType eq 'Guest'" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: "2c1ba4c4-cd9b-4417-832f-92a34bc34b2a", filter: "userType eq 'Guest'" }) }); assert(loggerLogSpy.calledOnceWithExactly([ { @@ -379,7 +372,7 @@ describe(commands.M365GROUP_USER_LIST, () => { it('correctly handles error when listing users', async () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - await assert.rejects(command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000" } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: "00000000-0000-0000-0000-000000000000" }) } as any), new CommandError('An error has occurred')); }); @@ -389,7 +382,7 @@ describe(commands.M365GROUP_USER_LIST, () => { sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { verbose: true, groupId: groupId } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, groupId: groupId }) } as any), new CommandError(`Specified group '${groupId}' is not a Microsoft 365 group.`)); }); @@ -399,7 +392,7 @@ describe(commands.M365GROUP_USER_LIST, () => { sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { verbose: true, groupDisplayName: groupDisplayName } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, groupDisplayName: groupDisplayName }) } as any), new CommandError(`Specified group '${groupDisplayName}' is not a Microsoft 365 group.`)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-user-list.ts b/src/m365/entra/commands/m365group/m365group-user-list.ts index 237da67859e..66bb4326f3b 100644 --- a/src/m365/entra/commands/m365group/m365group-user-list.ts +++ b/src/m365/entra/commands/m365group/m365group-user-list.ts @@ -1,25 +1,30 @@ import { User } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { odata } from '../../../../utils/odata.js'; -import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { CliRequestOptions } from '../../../../request.js'; +const options = globalOptionsZod + .extend({ + filter: zod.alias('f', z.string().optional()), + groupId: zod.alias('i', z.string().uuid().optional()), + groupDisplayName: zod.alias('d', z.string().optional()), + properties: zod.alias('p', z.string().optional()), + role: zod.alias('r', z.string().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - filter?: string; - groupId?: string; - groupDisplayName?: string; - properties?: string; - role?: string; -} - interface ExtendedUser extends User { roles: string[]; } @@ -33,72 +38,23 @@ class EntraM365GroupUserListCommand extends GraphCommand { return "Lists users for the specified Microsoft 365 group"; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initOptionSets(); - this.#initValidators(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - groupId: typeof args.options.groupId !== 'undefined', - groupDisplayName: typeof args.options.groupDisplayName !== 'undefined', - role: typeof args.options.role !== 'undefined', - properties: typeof args.options.properties !== 'undefined', - filter: typeof args.options.filter !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: "-i, --groupId [groupId]" - }, - { - option: "-n, --groupDisplayName [groupDisplayName]" - }, - { - option: "-r, --role [type]", - autocomplete: ["Owner", "Member"] - }, - { - option: "-p, --properties [properties]" - }, - { - option: "-f, --filter [filter]" - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { - options: ['groupId', 'groupDisplayName'] - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.groupId && !validation.isValidGuid(args.options.groupId as string)) { - return `${args.options.groupId} is not a valid GUID`; - } - - if (args.options.role) { - if (['Owner', 'Member'].indexOf(args.options.role) === -1) { - return `${args.options.role} is not a valid role value. Allowed values Owner|Member`; - } + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => !!(options.groupId || options.groupDisplayName), { + message: 'Specify either groupId or groupDisplayName' + }) + .refine(options => { + if (options.role && !['Owner', 'Member'].includes(options.role)) { + return false; } - return true; - } - ); + }, { + message: 'Invalid role value. Allowed values Owner|Member' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-user-remove.spec.ts b/src/m365/entra/commands/m365group/m365group-user-remove.spec.ts index 1efb6d4f62a..c73c22fbf2a 100644 --- a/src/m365/entra/commands/m365group/m365group-user-remove.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-user-remove.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -24,6 +25,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; let promptIssued: boolean = false; before(() => { @@ -34,6 +36,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves('00000000-0000-0000-0000-000000000000'); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -84,74 +87,62 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }); it('fails validation if the groupId is not a valid guid', async () => { - const actual = await command.validate({ - options: { - groupId: 'invalid', - userNames: userName - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + groupId: 'invalid', + userNames: userName + }); + assert.strictEqual(actual.success, false); }); it('fails validation if the teamId is not a valid guid', async () => { - const actual = await command.validate({ - options: { - teamId: 'invalid', - userNames: userName - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + teamId: 'invalid', + userNames: userName + }); + assert.strictEqual(actual.success, false); }); it('fails validation if ids contain an invalid guid', async () => { - const actual = await command.validate({ - options: { - teamId: groupOrTeamId, - ids: `invalid,${userId}` - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + teamId: groupOrTeamId, + ids: `invalid,${userId}` + }); + assert.strictEqual(actual.success, false); }); it('fails validation if userNames contain an invalid upn', async () => { - const actual = await command.validate({ - options: { - teamId: groupOrTeamId, - userNames: `invalid,${userName}` - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + teamId: groupOrTeamId, + userNames: `invalid,${userName}` + }); + assert.strictEqual(actual.success, false); }); it('passes validation when a valid teamId and userNames are specified', async () => { - const actual = await command.validate({ - options: { - teamId: groupOrTeamId, - userNames: `${userName},john@contoso.com` - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + teamId: groupOrTeamId, + userNames: `${userName},john@contoso.com` + }); + assert.strictEqual(actual.success, true); }); it('passes validation when a valid teamId and ids are specified', async () => { - const actual = await command.validate({ - options: { - teamId: groupOrTeamId, - ids: `${userId},8b38aeff-1642-47e4-b6ef-9d50d29638b7` - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + teamId: groupOrTeamId, + ids: `${userId},8b38aeff-1642-47e4-b6ef-9d50d29638b7` + }); + assert.strictEqual(actual.success, true); }); it('prompts before removing the specified user from the specified Microsoft 365 Group when force option not passed', async () => { - await command.action(logger, { options: { groupId: groupOrTeamId, userNames: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: userName }) }); assert(promptIssued); }); it('prompts before removing the specified user from the specified Team when force option not passed (debug)', async () => { - await command.action(logger, { options: { debug: true, teamId: "00000000-0000-0000-0000-000000000000", userNames: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, teamId: "00000000-0000-0000-0000-000000000000", userNames: userName }) }); assert(promptIssued); }); @@ -161,7 +152,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { groupId: groupOrTeamId, userNames: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: userName }) }); assert(postSpy.notCalled); }); @@ -170,7 +161,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { debug: true, groupId: groupOrTeamId, userNames: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, groupId: groupOrTeamId, userNames: userName }) }); assert(postSpy.notCalled); }); @@ -194,7 +185,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { groupId: groupOrTeamId, userNames: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: userName }) }); assert(memberDeleteCallIssued); }); @@ -216,7 +207,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }); - await command.action(logger, { options: { groupId: groupOrTeamId, userNames: userName, force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: userName, force: true }) }); assert(memberDeleteCallIssued); }); @@ -242,7 +233,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }); - await command.action(logger, { options: { groupId: groupOrTeamId, userNames: userName, force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: userName, force: true }) }); assert(memberDeleteCallIssued); }); @@ -268,7 +259,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { teamName: groupOrTeamName, userNames: userName, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ teamName: groupOrTeamName, userNames: userName, verbose: true }) }); assert(deleteStub.calledTwice); }); @@ -295,7 +286,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { teamId: groupOrTeamId, ids: userId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ teamId: groupOrTeamId, ids: userId, verbose: true }) }); assert(deleteStub.calledTwice); }); @@ -318,7 +309,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { teamName: groupOrTeamName, userNames: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ teamName: groupOrTeamName, userNames: userName }) }); assert(deleteStub.calledTwice); }); @@ -345,7 +336,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }); - await command.action(logger, { options: { groupId: groupOrTeamId, userNames: userName, force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: userName, force: true }) }); assert(deleteStub.calledTwice); }); @@ -376,7 +367,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }); - await command.action(logger, { options: { groupId: groupOrTeamId, userNames: userName, force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: userName, force: true }) }); assert(memberDeleteCallIssued); }); @@ -401,7 +392,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { ); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await assert.rejects(command.action(logger, { options: { groupId: groupOrTeamId, userNames: userName } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: userName }) }), new CommandError(errorMessage)); }); @@ -424,7 +415,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await assert.rejects(command.action(logger, { options: { groupId: groupOrTeamId, userNames: userName } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: userName }) } as any), new CommandError('Invalid object identifier')); }); @@ -457,7 +448,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await assert.rejects(command.action(logger, { options: { groupId: groupOrTeamId, userNames: userName } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: userName }) }), new CommandError('Invalid object identifier')); }); @@ -465,7 +456,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { groupId: groupOrTeamId, userNames: 'anne.matthews@contoso.onmicrosoft.com', force: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupOrTeamId, userNames: 'anne.matthews@contoso.onmicrosoft.com', force: true }) }), new CommandError(`Specified group with id '${groupOrTeamId}' is not a Microsoft 365 group.`)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-user-remove.ts b/src/m365/entra/commands/m365group/m365group-user-remove.ts index 126ab7dc847..83d923f973a 100644 --- a/src/m365/entra/commands/m365group/m365group-user-remove.ts +++ b/src/m365/entra/commands/m365group/m365group-user-remove.ts @@ -1,28 +1,35 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +import teamsCommands from '../../../teams/commands.js'; + +const options = globalOptionsZod + .extend({ + teamId: zod.alias('teamId', z.string().uuid().optional()), + teamName: zod.alias('teamName', z.string().optional()), + groupId: zod.alias('i', z.string().uuid().optional()), + groupName: zod.alias('groupName', z.string().optional()), + ids: zod.alias('ids', z.string().optional()), + userNames: zod.alias('userNames', z.string().optional()), + force: zod.alias('f', z.boolean().optional()) + }) + .strict(); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - teamId?: string; - teamName?: string; - groupId?: string; - groupName?: string; - ids?: string; - userNames?: string; - force?: boolean; -} - class EntraM365GroupUserRemoveCommand extends GraphCommand { public get name(): string { return commands.M365GROUP_USER_REMOVE; @@ -32,100 +39,40 @@ class EntraM365GroupUserRemoveCommand extends GraphCommand { return 'Removes the specified user from specified Microsoft 365 Group or Microsoft Teams team'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: !!args.options.force, - teamId: typeof args.options.teamId !== 'undefined', - groupId: typeof args.options.groupId !== 'undefined', - teamName: typeof args.options.teamName !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined', - ids: typeof args.options.ids !== 'undefined', - userNames: typeof args.options.userNames !== 'undefined' - }); - }); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: '-i, --groupId [groupId]' - }, - { - option: '--groupName [groupName]' - }, - { - option: '--teamId [teamId]' - }, - { - option: '--teamName [teamName]' - }, - { - option: '--ids [ids]' - }, - { - option: '--userNames [userNames]' - }, - { - option: '-f, --force' - } - ); + public alias(): string[] | undefined { + return [teamsCommands.USER_REMOVE]; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.teamId && !validation.isValidGuid(args.options.teamId as string)) { - return `${args.options.teamId} is not a valid GUID for option 'teamId'.`; - } - - if (args.options.groupId && !validation.isValidGuid(args.options.groupId as string)) { - return `${args.options.groupId} is not a valid GUID for option 'groupId'.`; - } - - if (args.options.ids) { - const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.ids); - if (isValidGUIDArrayResult !== true) { - return `The following GUIDs are invalid for the option 'ids': ${isValidGUIDArrayResult}.`; - } + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => !!(options.groupId || options.groupName || options.teamId || options.teamName), { + message: 'Specify either groupId, groupName, teamId, or teamName' + }) + .refine(options => !!(options.ids || options.userNames), { + message: 'Specify either ids or userNames' + }) + .refine(options => { + if (options.ids) { + const isValidGUIDArrayResult = validation.isValidGuidArray(options.ids); + return isValidGUIDArrayResult === true; } - - if (args.options.userNames) { - const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.userNames); - if (isValidUPNArrayResult !== true) { - return `The following user principal names are invalid for the option 'userNames': ${isValidUPNArrayResult}.`; - } + return true; + }, { + message: 'The following GUIDs are invalid for the option \'ids\'' + }) + .refine(options => { + if (options.userNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(options.userNames); + return isValidUPNArrayResult === true; } - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { - options: ['groupId', 'teamId', 'groupName', 'teamName'] - }, - { - options: ['ids', 'userNames'] - } - ); - } - - #initTypes(): void { - this.types.string.push('groupId', 'groupName', 'teamId', 'teamName', 'ids', 'userNames'); - this.types.boolean.push('force'); + }, { + message: 'The following user principal names are invalid for the option \'userNames\'' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/m365group/m365group-user-set.spec.ts b/src/m365/entra/commands/m365group/m365group-user-set.spec.ts index 87f8d710656..ab7dc68574e 100644 --- a/src/m365/entra/commands/m365group/m365group-user-set.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-user-set.spec.ts @@ -1,8 +1,7 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; -import { cli } from '../../../../cli/cli.js'; -import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -14,6 +13,8 @@ import commands from '../../commands.js'; import command from './m365group-user-set.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { entraUser } from '../../../../utils/entraUser.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { cli } from '../../../../cli/cli.js'; describe(commands.M365GROUP_USER_SET, () => { const groupId = '630dfae3-6904-4154-acc2-812e11205351'; @@ -23,6 +24,7 @@ describe(commands.M365GROUP_USER_SET, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -30,8 +32,9 @@ describe(commands.M365GROUP_USER_SET, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(true); - auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + auth.connection.active = true; }); beforeEach(() => { @@ -72,33 +75,33 @@ describe(commands.M365GROUP_USER_SET, () => { }); 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); + const actual = commandOptionsSchema.safeParse({ groupId: 'foo', ids: userIds[0], role: 'Member' }); + assert.strictEqual(actual.success, false); }); it('fails validation if the teamId is not a valid guid.', async () => { - const actual = await command.validate({ options: { teamId: 'foo', ids: userIds[0], role: 'Member' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ teamId: 'foo', ids: userIds[0], role: 'Member' }); + assert.strictEqual(actual.success, false); }); 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); + const actual = commandOptionsSchema.safeParse({ groupId: groupId, ids: `${userIds[0]},foo`, role: 'Member' }); + assert.strictEqual(actual.success, false); }); 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); + const actual = commandOptionsSchema.safeParse({ groupId: groupId, userNames: `${userUpns[0]},foo`, role: 'Member' }); + assert.strictEqual(actual.success, false); }); 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); + const actual = commandOptionsSchema.safeParse({ groupId: groupId, ids: userIds.join(','), role: 'foo' }); + assert.strictEqual(actual.success, false); }); 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); + const actual = commandOptionsSchema.safeParse({ groupId: groupId, ids: userIds.join(','), role: 'Member' }); + assert.strictEqual(actual.success, true); }); it('successfully updates roles for users to Member for the specified group by ID', async () => { @@ -144,7 +147,7 @@ describe(commands.M365GROUP_USER_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), role: 'Member', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupId, ids: userIds.join(','), role: 'Member', verbose: true }) }); assert.deepStrictEqual(postStub.firstCall.args[0].data.requests, [ { id: 1, @@ -213,7 +216,7 @@ describe(commands.M365GROUP_USER_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupName: 'Contoso', userNames: userUpns.join(','), role: 'Owner', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ groupName: 'Contoso', userNames: userUpns.join(','), role: 'Owner', verbose: true }) }); assert.deepStrictEqual(postStub.firstCall.args[0].data.requests, [ { id: 1, @@ -279,7 +282,7 @@ describe(commands.M365GROUP_USER_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { teamId: groupId, ids: userIds.join(','), role: 'Owner', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ teamId: groupId, ids: userIds.join(','), role: 'Owner', verbose: true }) }); assert.deepStrictEqual(postStub.firstCall.args[0].data.requests, [ { id: 1, @@ -348,7 +351,7 @@ describe(commands.M365GROUP_USER_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { teamName: 'Contoso', ids: userIds.join(','), role: 'Owner', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ teamName: 'Contoso', ids: userIds.join(','), role: 'Owner', verbose: true }) }); assert.deepStrictEqual(postStub.firstCall.args[0].data.requests, [ { id: 1, @@ -398,7 +401,7 @@ describe(commands.M365GROUP_USER_SET, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), role: 'Member' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupId, ids: userIds.join(','), role: 'Member' }) }), new CommandError(`One or more added object references already exist for the following modified properties: 'members'.`)); }); @@ -439,7 +442,7 @@ describe(commands.M365GROUP_USER_SET, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), role: 'Member' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupId, ids: userIds.join(','), role: 'Member' }) }), new CommandError('Service unavailable.')); }); @@ -498,7 +501,7 @@ describe(commands.M365GROUP_USER_SET, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), role: 'Member' } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupId, ids: userIds.join(','), role: 'Member' }) }), new CommandError('Service unavailable.')); }); @@ -506,7 +509,7 @@ describe(commands.M365GROUP_USER_SET, () => { sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { groupId: groupId, userNames: userUpns.join(',') } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ groupId: groupId, userNames: userUpns.join(','), role: 'Member' }) } as any), new CommandError(`Specified group with id '${groupId}' is not a Microsoft 365 group.`)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-user-set.ts b/src/m365/entra/commands/m365group/m365group-user-set.ts index 063876dec42..47e339f67cb 100644 --- a/src/m365/entra/commands/m365group/m365group-user-set.ts +++ b/src/m365/entra/commands/m365group/m365group-user-set.ts @@ -1,27 +1,34 @@ +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 { validation } from '../../../../utils/validation.js'; import { formatting } from '../../../../utils/formatting.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { entraUser } from '../../../../utils/entraUser.js'; +import teamsCommands from '../../../teams/commands.js'; + +const options = globalOptionsZod + .extend({ + ids: zod.alias('ids', z.string().optional()), + userNames: zod.alias('userNames', z.string().optional()), + groupId: zod.alias('i', z.string().uuid().optional()), + groupName: zod.alias('groupName', z.string().optional()), + teamId: zod.alias('teamId', z.string().uuid().optional()), + teamName: zod.alias('teamName', z.string().optional()), + role: zod.alias('r', z.string()) + }) + .strict(); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - ids?: string; - userNames?: string; - groupId?: string; - groupName?: string; - teamId?: string; - teamName?: string; - role: string; -} - class EntraM365GroupUserSetCommand extends GraphCommand { private readonly allowedRoles: string[] = ['owner', 'member']; @@ -33,97 +40,45 @@ class EntraM365GroupUserSetCommand extends GraphCommand { return 'Updates role of the specified user in the specified Microsoft 365 Group or Microsoft Teams team'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - teamId: typeof args.options.teamId !== 'undefined', - teamName: typeof args.options.teamName !== 'undefined', - groupId: typeof args.options.groupId !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined', - ids: typeof args.options.ids !== 'undefined', - userNames: typeof args.options.userNames !== 'undefined' - }); - }); + public alias(): string[] | undefined { + return [teamsCommands.USER_SET]; } - #initOptions(): void { - this.options.unshift( - { - option: '--ids [ids]' - }, - { - option: '--userNames [userNames]' - }, - { - option: '-i, --groupId [groupId]' - }, - { - option: '--groupName [groupName]' - }, - { - option: '--teamId [teamId]' - }, - { - option: '--teamName [teamName]' - }, - { - option: '-r, --role ', - autocomplete: this.allowedRoles - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.teamId && !validation.isValidGuid(args.options.teamId)) { - return `'${args.options.teamId}' is not a valid GUID for option 'teamId'.`; - } - - if (args.options.groupId && !validation.isValidGuid(args.options.groupId)) { - return `'${args.options.groupId}' is not a valid GUID for option 'groupId'.`; + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.groupId, options.groupName, options.teamId, options.teamName].filter(Boolean).length === 1, { + message: 'Specify either groupId, groupName, teamId, or teamName' + }) + .refine(options => [options.ids, options.userNames].filter(Boolean).length === 1, { + message: 'Specify either ids or userNames' + }) + .refine(options => { + if (options.ids) { + const isValidGUIDArrayResult = validation.isValidGuidArray(options.ids); + return isValidGUIDArrayResult === true; } - - if (args.options.ids) { - const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.ids); - if (isValidGUIDArrayResult !== true) { - return `The following GUIDs are invalid for the option 'ids': ${isValidGUIDArrayResult}.`; - } - } - - if (args.options.userNames) { - const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.userNames); - if (isValidUPNArrayResult !== true) { - return `The following user principal names are invalid for the option 'userNames': ${isValidUPNArrayResult}.`; - } - } - - if (args.options.role && !this.allowedRoles.some(role => role.toLowerCase() === args.options.role.toLowerCase())) { - return `'${args.options.role}' is not a valid role. Allowed values are: ${this.allowedRoles.join(',')}`; + return true; + }, { + message: 'Specify valid GUIDs for the option \'ids\'' + }) + .refine(options => { + if (options.userNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(options.userNames); + return isValidUPNArrayResult === true; } - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['groupId', 'groupName', 'teamId', 'teamName'] }); - this.optionSets.push({ options: ['ids', 'userNames'] }); - } - - #initTypes(): void { - this.types.string.push('ids', 'userNames', 'groupId', 'groupName', 'teamId', 'teamName', 'role'); + }, { + message: 'The following user principal names are invalid for the option \'userNames\'' + }) + .refine(options => { + return this.allowedRoles.some(role => role.toLowerCase() === options.role.toLowerCase()); + }, { + message: 'Invalid role value. Allowed values are: owner,member' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise {