From ae4e19aa7d3bf51d7404c7a9c91c1c20085c8032 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 28 May 2026 15:42:45 +0200 Subject: [PATCH] Migrates entra user commands to Zod validation Migrates all entra user commands to use Zod schemas for option validation, replacing the old pattern of constructor-based #initTelemetry, #initOptions, #initValidators, #initOptionSets, and #initTypes methods. Commands migrated: - user-add, user-get, user-set, user-list - user-remove, user-guest-add - user-license-add, user-license-list, user-license-remove - user-groupmembership-list, user-signin-list, user-hibp - user-password-validate - user-recyclebinitem-clear, user-recyclebinitem-remove, user-recyclebinitem-restore - user-registrationdetails-list Updates external dependents (group-member-remove, group-set) to work with the new Options type from user-get. Closes #7301 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/m365/entra/commands/user/user-add.spec.ts | 127 ++++---- src/m365/entra/commands/user/user-add.ts | 211 +++---------- src/m365/entra/commands/user/user-get.spec.ts | 132 +++----- src/m365/entra/commands/user/user-get.ts | 91 ++---- .../user/user-groupmembership-list.spec.ts | 52 ++-- .../user/user-groupmembership-list.ts | 92 ++---- .../commands/user/user-guest-add.spec.ts | 28 +- .../entra/commands/user/user-guest-add.ts | 72 ++--- .../entra/commands/user/user-hibp.spec.ts | 74 ++--- src/m365/entra/commands/user/user-hibp.ts | 62 +--- .../commands/user/user-license-add.spec.ts | 42 ++- .../entra/commands/user/user-license-add.ts | 81 ++--- .../commands/user/user-license-list.spec.ts | 40 +-- .../entra/commands/user/user-license-list.ts | 75 ++--- .../commands/user/user-license-remove.spec.ts | 80 +++-- .../commands/user/user-license-remove.ts | 91 ++---- .../entra/commands/user/user-list.spec.ts | 47 +-- src/m365/entra/commands/user/user-list.ts | 73 ++--- .../user/user-password-validate.spec.ts | 18 +- .../commands/user/user-password-validate.ts | 28 +- .../user/user-recyclebinitem-clear.spec.ts | 17 +- .../user/user-recyclebinitem-clear.ts | 37 +-- .../user/user-recyclebinitem-remove.spec.ts | 26 +- .../user/user-recyclebinitem-remove.ts | 56 +--- .../user/user-recyclebinitem-restore.spec.ts | 20 +- .../user/user-recyclebinitem-restore.ts | 42 +-- .../user-registrationdetails-list.spec.ts | 192 ++++++------ .../user/user-registrationdetails-list.ts | 203 +++---------- .../entra/commands/user/user-remove.spec.ts | 64 ++-- src/m365/entra/commands/user/user-remove.ts | 82 ++--- src/m365/entra/commands/user/user-set.spec.ts | 243 +++++++-------- src/m365/entra/commands/user/user-set.ts | 285 +++++------------- .../commands/user/user-signin-list.spec.ts | 62 ++-- .../entra/commands/user/user-signin-list.ts | 90 ++---- .../spo/commands/group/group-member-remove.ts | 4 +- src/m365/spo/commands/group/group-set.ts | 4 +- 36 files changed, 1026 insertions(+), 1917 deletions(-) diff --git a/src/m365/entra/commands/user/user-add.spec.ts b/src/m365/entra/commands/user/user-add.spec.ts index 7af604b1a33..f0bbea44d87 100644 --- a/src/m365/entra/commands/user/user-add.spec.ts +++ b/src/m365/entra/commands/user/user-add.spec.ts @@ -11,8 +11,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-add.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './user-add.js'; describe(commands.USER_ADD, () => { const graphBaseUrl = 'https://graph.microsoft.com/v1.0/users'; @@ -34,7 +33,7 @@ describe(commands.USER_ADD, () => { const largeString = 'f4gsz5cD0DmR7VpVXhsKlAwIryzpC847Z4qciQ1CDveZCNuCkWtUd9I8QXVLjurVS'; const userResponseWithoutPassword = { - id: "f5caff1f-e9b6-4dba-a65e-d0c908c0e91b", + id: 'f5caff1f-e9b6-4dba-a65e-d0c908c0e91b', businessPhones: [], displayName: displayName, givenName: firstName, @@ -64,19 +63,19 @@ describe(commands.USER_ADD, () => { const graphError = { error: { - code: "Request_BadRequest", - message: "Another object with the same value for property userPrincipalName already exists.", + code: 'Request_BadRequest', + message: 'Another object with the same value for property userPrincipalName already exists.', details: [ { - code: "ObjectConflict", - message: "Another object with the same value for property userPrincipalName already exists.", - target: "userPrincipalName" + code: 'ObjectConflict', + message: 'Another object with the same value for property userPrincipalName already exists.', + target: 'userPrincipalName' } ], innerError: { - date: "2023-02-16T17:22:25", - 'request-id': "2726a9e1-2909-4277-ba89-144558eb9431", - 'client-request-id': "2726a9e1-2909-4277-ba89-144558eb9431" + date: '2023-02-16T17:22:25', + 'request-id': '2726a9e1-2909-4277-ba89-144558eb9431', + 'client-request-id': '2726a9e1-2909-4277-ba89-144558eb9431' } } }; @@ -85,6 +84,7 @@ describe(commands.USER_ADD, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -93,6 +93,7 @@ describe(commands.USER_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -115,8 +116,7 @@ describe(commands.USER_ADD, () => { afterEach(() => { sinonUtil.restore([ request.post, - request.put, - cli.getSettingWithDefaultValue + request.put ]); }); @@ -146,8 +146,7 @@ describe(commands.USER_ADD, () => { throw 'Invalid request'; }); - - await command.action(logger, { options: { verbose: true, userName: userName, displayName: displayName, password: password, forceChangePasswordNextSignIn: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userName: userName, displayName: displayName, password: password, forceChangePasswordNextSignIn: true }) }); assert(loggerLogSpy.calledWith(userResponseWithPassword)); }); @@ -160,7 +159,7 @@ describe(commands.USER_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName, displayName: displayName, password: password, mailNickname: mailNickname, accountEnabled: false } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userName, displayName: displayName, password: password, mailNickname: mailNickname, accountEnabled: false }) }); assert(loggerLogSpy.calledWith(userResponseWithPassword)); }); @@ -173,7 +172,7 @@ describe(commands.USER_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName, displayName: displayName, password: password, extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userName, displayName: displayName, password: password, extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' }) }); assert(loggerLogSpy.calledWith(userResponseWithPasswordAndDirectoryExtension)); }); @@ -194,7 +193,7 @@ describe(commands.USER_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName, displayName: displayName, managerUserId: managerUserId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userName, displayName: displayName, managerUserId: managerUserId }) }); assert.strictEqual(putStub.lastCall.args[0].data['@odata.id'], `${graphBaseUrl}/${managerUserId}`); }); @@ -215,7 +214,7 @@ describe(commands.USER_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName, displayName: displayName, managerUserName: managerUserName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userName, displayName: displayName, managerUserName: managerUserName }) }); assert.strictEqual(putStub.lastCall.args[0].data['@odata.id'], `${graphBaseUrl}/${managerUserName}`); }); @@ -228,80 +227,72 @@ describe(commands.USER_ADD, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { userName: userName, displayName: displayName } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ userName: userName, displayName: displayName }) }), new CommandError(graphError.error.message)); }); - it('fails validation if userName is not a valid userPrincipalName', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userName is not a valid userPrincipalName', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation usageLocation is not a valid usageLocation', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName, usageLocation: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation usageLocation is not a valid usageLocation', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, usageLocation: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation preferredLanguage is not a valid preferredLanguage', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName, preferredLanguage: 'z' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation preferredLanguage is not a valid preferredLanguage', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, preferredLanguage: 'z' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if both managerUserId and managerUserName are specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { displayName: displayName, userName: userName, managerUserId: managerUserId, managerUserName: managerUserName } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if both managerUserId and managerUserName are specified', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, managerUserId: managerUserId, managerUserName: managerUserName }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if managerUserName is not a valid userPrincipalName', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName, managerUserName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if managerUserName is not a valid userPrincipalName', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, managerUserName: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if managerUserId is not a valid GUID', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName, managerUserId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if managerUserId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, managerUserId: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if firstName has more than 64 characters', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName, firstName: largeString } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if firstName has more than 64 characters', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, firstName: largeString }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if lastName has more than 64 characters', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName, lastName: largeString } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if lastName has more than 64 characters', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, lastName: largeString }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if jobTitle has more than 128 characters', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName, jobTitle: largeString + largeString } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if jobTitle has more than 128 characters', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, jobTitle: largeString + largeString }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if companyName has more than 64 characters', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName, companyName: largeString } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if companyName has more than 64 characters', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, companyName: largeString }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if department has more than 64 characters', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName, department: largeString } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if department has more than 64 characters', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, department: largeString }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if only userName and displayName are specified', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if only userName and displayName are specified', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if all options (excluding managerUserName and forceChangePasswordNextSignInWithMfa) are specified', async () => { - const actual = await command.validate({ options: { displayName: displayName, userName: userName, accountEnabled: accountEnabled, mailNickname: mailNickname, password: password, firstName: firstName, lastName: lastName, forceChangePasswordNextSignIn: true, usageLocation: usageLocation, officeLocation: officeLocation, jobTitle: jobTitle, companyName: companyName, department: department, preferredLanguage: preferredLanguage, managerUserId: managerUserId } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if all options (excluding managerUserName and forceChangePasswordNextSignInWithMfa) are specified', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, userName: userName, accountEnabled: accountEnabled, mailNickname: mailNickname, password: password, firstName: firstName, lastName: lastName, forceChangePasswordNextSignIn: true, usageLocation: usageLocation, officeLocation: officeLocation, jobTitle: jobTitle, companyName: companyName, department: department, preferredLanguage: preferredLanguage, managerUserId: managerUserId }); + assert.strictEqual(actual.success, true); }); -}); \ No newline at end of file +}); diff --git a/src/m365/entra/commands/user/user-add.ts b/src/m365/entra/commands/user/user-add.ts index b94b389b702..b16c0f023fa 100644 --- a/src/m365/entra/commands/user/user-add.ts +++ b/src/m365/entra/commands/user/user-add.ts @@ -1,6 +1,7 @@ import { User } from '@microsoft/microsoft-graph-types'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; @@ -10,30 +11,37 @@ interface ExtendedUser extends User { password: string; } +export const options = z.looseObject({ + ...globalOptionsZod.shape, + displayName: z.string(), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid userName.` + }), + accountEnabled: z.boolean().optional(), + mailNickname: z.string().optional(), + password: z.string().optional(), + firstName: z.string().max(64, { error: `The maximum amount of characters for 'firstName' is 64.` }).optional(), + lastName: z.string().max(64, { error: `The maximum amount of characters for 'lastName' is 64.` }).optional(), + forceChangePasswordNextSignIn: z.boolean().optional(), + forceChangePasswordNextSignInWithMfa: z.boolean().optional(), + usageLocation: z.string().regex(/^[a-zA-Z]{2}$/, { error: e => `'${e.input}' is not a valid usageLocation.` }).optional(), + officeLocation: z.string().optional(), + jobTitle: z.string().max(128, { error: `The maximum amount of characters for 'jobTitle' is 128.` }).optional(), + companyName: z.string().max(64, { error: `The maximum amount of characters for 'companyName' is 64.` }).optional(), + department: z.string().max(64, { error: `The maximum amount of characters for 'department' is 64.` }).optional(), + preferredLanguage: z.string().min(2, { error: e => `'${e.input}' is not a valid preferredLanguage.` }).optional(), + managerUserId: z.uuid().optional(), + managerUserName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid user principal name.` + }).optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - displayName: string; - userName: string; - accountEnabled?: boolean; - mailNickname?: string; - password?: string; - firstName?: string; - lastName?: string; - forceChangePasswordNextSignIn?: boolean; - forceChangePasswordNextSignInWithMfa?: boolean; - usageLocation?: string; - officeLocation?: string; - jobTitle?: string; - companyName?: string; - department?: string; - preferredLanguage?: string; - managerUserId?: string; - managerUserName?: string; -} - class EntraUserAddCommand extends GraphCommand { public get name(): string { return commands.USER_ADD; @@ -47,158 +55,19 @@ class EntraUserAddCommand extends GraphCommand { return true; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - accountEnabled: typeof args.options.accountEnabled !== 'undefined', - mailNickname: typeof args.options.mailNickname !== 'undefined', - password: typeof args.options.password !== 'undefined', - firstName: typeof args.options.firstName !== 'undefined', - lastName: typeof args.options.lastName !== 'undefined', - forceChangePasswordNextSignIn: !!args.options.forceChangePasswordNextSignIn, - forceChangePasswordNextSignInWithMfa: !!args.options.forceChangePasswordNextSignInWithMfa, - usageLocation: typeof args.options.usageLocation !== 'undefined', - officeLocation: typeof args.options.officeLocation !== 'undefined', - jobTitle: typeof args.options.jobTitle !== 'undefined', - companyName: typeof args.options.companyName !== 'undefined', - department: typeof args.options.department !== 'undefined', - preferredLanguage: typeof args.options.preferredLanguage !== 'undefined', - managerUserId: typeof args.options.managerUserId !== 'undefined', - managerUserName: typeof args.options.managerUserName !== 'undefined' - }); - this.trackUnknownOptions(this.telemetryProperties, args.options); - }); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: '--displayName ' - }, - { - option: '--userName ' - }, - { - option: '--accountEnabled [accountEnabled]', - autocomplete: ['true', 'false'] - }, - { - option: '--mailNickname [mailNickname]' - }, - { - option: '--password [password]' - }, - { - option: '--firstName [firstName]' - }, - { - option: '--lastName [lastName]' - }, - { - option: '--forceChangePasswordNextSignIn' - }, - { - option: '--forceChangePasswordNextSignInWithMfa' - }, - { - option: '--usageLocation [usageLocation]' - }, - { - option: '--officeLocation [officeLocation]' - }, - { - option: '--jobTitle [jobTitle]' - }, - { - option: '--companyName [companyName]' - }, - { - option: '--department [department]' - }, - { - option: '--preferredLanguage [preferredLanguage]' - }, - { - option: '--managerUserId [managerUserId]' - }, - { - option: '--managerUserName [managerUserName]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (!validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid userName`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => !(options.managerUserId && options.managerUserName), { + error: `Specify either 'managerUserId' or 'managerUserName', but not both.`, + params: { + customCode: 'optionSet', + options: ['managerUserId', 'managerUserName'] } - - if (args.options.usageLocation) { - const regex = new RegExp('^[a-zA-Z]{2}$'); - if (!regex.test(args.options.usageLocation)) { - return `'${args.options.usageLocation}' is not a valid usageLocation.`; - } - } - - if (args.options.preferredLanguage && args.options.preferredLanguage.length < 2) { - return `'${args.options.preferredLanguage}' is not a valid preferredLanguage`; - } - - if (args.options.firstName && args.options.firstName.length > 64) { - return `The maximum amount of characters for 'firstName' is 64.`; - } - - if (args.options.lastName && args.options.lastName.length > 64) { - return `The maximum amount of characters for 'lastName' is 64.`; - } - - if (args.options.jobTitle && args.options.jobTitle.length > 128) { - return `The maximum amount of characters for 'jobTitle' is 128.`; - } - - if (args.options.companyName && args.options.companyName.length > 64) { - return `The maximum amount of characters for 'companyName' is 64.`; - } - - if (args.options.department && args.options.department.length > 64) { - return `The maximum amount of characters for 'department' is 64.`; - } - - if (args.options.managerUserName && !validation.isValidUserPrincipalName(args.options.managerUserName)) { - return `'${args.options.managerUserName}' is not a valid user principal name.`; - } - - if (args.options.managerUserId && !validation.isValidGuid(args.options.managerUserId)) { - return `'${args.options.managerUserId}' is not a valid GUID.`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { - options: ['managerUserId', 'managerUserName'], - runsWhen: (args) => args.options.managerId || args.options.managerUserName - } - ); - } - - #initTypes(): void { - this.types.boolean.push('accountEnabled'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -261,7 +130,7 @@ class EntraUserAddCommand extends GraphCommand { preferredLanguage: options.preferredLanguage }; - this.addUnknownOptionsToPayload(requestBody, options); + this.addUnknownOptionsToPayloadZod(requestBody, options); return requestBody; } diff --git a/src/m365/entra/commands/user/user-get.spec.ts b/src/m365/entra/commands/user/user-get.spec.ts index ed388e1f08e..c0e29da6265 100644 --- a/src/m365/entra/commands/user/user-get.spec.ts +++ b/src/m365/entra/commands/user/user-get.spec.ts @@ -12,8 +12,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-get.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './user-get.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { formatting } from '../../../../utils/formatting.js'; @@ -30,6 +29,7 @@ describe(commands.USER_GET, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -44,6 +44,7 @@ describe(commands.USER_GET, () => { }; } commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -67,7 +68,6 @@ describe(commands.USER_GET, () => { request.get, accessToken.getUserIdFromAccessToken, accessToken.getUserNameFromAccessToken, - cli.getSettingWithDefaultValue, entraUser.getUserIdByEmail ]); }); @@ -94,7 +94,7 @@ describe(commands.USER_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: userId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: userId }) }); assert(loggerLogSpy.calledWith(resultValue)); }); @@ -109,7 +109,7 @@ describe(commands.USER_GET, () => { sinon.stub(accessToken, 'getUserIdFromAccessToken').callsFake(() => { return userId; }); - await command.action(logger, { options: { id: '@meid' } }); + await command.action(logger, { options: { id: '@meid' } as any }); assert(loggerLogSpy.calledWith(resultValue)); }); @@ -122,7 +122,7 @@ describe(commands.USER_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, id: userId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, id: userId }) }); assert(loggerLogSpy.calledWith(resultValue)); }); @@ -135,7 +135,7 @@ describe(commands.USER_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userName }) }); assert(loggerLogSpy.calledWith(resultValue)); }); @@ -148,7 +148,7 @@ describe(commands.USER_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: externalUserName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: externalUserName }) }); assert(loggerLogSpy.calledWith(externalUserResponse)); }); @@ -161,7 +161,7 @@ describe(commands.USER_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userNameWithDollar } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userNameWithDollar }) }); assert(loggerLogSpy.calledWith(userNameWithDollarResponse)); }); @@ -181,7 +181,7 @@ describe(commands.USER_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName, withManager: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userName, withManager: true }) }); assert(loggerLogSpy.calledWith(resultValueWithManger)); }); @@ -196,7 +196,7 @@ describe(commands.USER_GET, () => { sinon.stub(accessToken, 'getUserNameFromAccessToken').callsFake(() => { return userName; }); - await command.action(logger, { options: { userName: '@meusername' } }); + await command.action(logger, { options: { userName: '@meusername' } as any }); assert(loggerLogSpy.calledWith(resultValue)); }); @@ -210,7 +210,7 @@ describe(commands.USER_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { email: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ email: userName }) }); assert(loggerLogSpy.calledWith(resultValue)); }); @@ -223,14 +223,14 @@ describe(commands.USER_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName, properties: 'id,mail' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userName, properties: 'id,mail' }) }); assert(loggerLogSpy.calledWith({ "id": "userId", "mail": null })); }); it('fails to get user when user with provided email does not exists', async () => { sinon.stub(entraUser, 'getUserIdByEmail').withArgs(userName).throws(Error(`The specified user with email ${userName} does not exist`)); - await assert.rejects(command.action(logger, { options: { email: userName } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ email: userName }) }), new CommandError(`The specified user with email ${userName} does not exist`)); }); @@ -246,7 +246,7 @@ describe(commands.USER_GET, () => { } }); - await assert.rejects(command.action(logger, { options: { id: '68be84bf-a585-4776-80b3-30aa5207aa22' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: '68be84bf-a585-4776-80b3-30aa5207aa22' }) }), new CommandError(`Resource '68be84bf-a585-4776-80b3-30aa5207aa22' does not exist or one of its queried reference-property objects are not present.`)); }); @@ -262,97 +262,57 @@ describe(commands.USER_GET, () => { } }); - await assert.rejects(command.action(logger, { options: { userName: userName } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ userName: userName }) }), new CommandError(`Resource '${userName}' does not exist or one of its queried reference-property objects are not present.`)); }); - it('fails validation if id or email or userName options are not passed', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: {} }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id or email or userName options are not passed', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if id, email, and userName options are passed (multiple options)', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { id: "1caf7dcd-7e83-4c3a-94f7-932a1299c844", email: "john.doe@contoso.onmicrosoft.com", userName: "i:0#.f|membership|john.doe@contoso.onmicrosoft.com" } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id, email, and userName options are passed (multiple options)', () => { + const actual = commandOptionsSchema.safeParse({ id: "1caf7dcd-7e83-4c3a-94f7-932a1299c844", email: "john.doe@contoso.onmicrosoft.com", userName: "i:0#.f|membership|john.doe@contoso.onmicrosoft.com" }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if both id and email options are passed (multiple options)', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { id: "1caf7dcd-7e83-4c3a-94f7-932a1299c844", email: "john.doe@contoso.onmicrosoft.com" } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if both id and email options are passed (multiple options)', () => { + const actual = commandOptionsSchema.safeParse({ id: "1caf7dcd-7e83-4c3a-94f7-932a1299c844", email: "john.doe@contoso.onmicrosoft.com" }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if both id and userName options are passed (multiple options)', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { id: "1caf7dcd-7e83-4c3a-94f7-932a1299c844", userName: "john.doe@contoso.onmicrosoft.com" } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if both id and userName options are passed (multiple options)', () => { + const actual = commandOptionsSchema.safeParse({ id: "1caf7dcd-7e83-4c3a-94f7-932a1299c844", userName: "john.doe@contoso.onmicrosoft.com" }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if both email and userName options are passed (multiple options)', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { email: "jonh.deo@contoso.com", userName: "john.doe@contoso.onmicrosoft.com" } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if both email and userName options are passed (multiple options)', () => { + const actual = commandOptionsSchema.safeParse({ email: "jonh.deo@contoso.com", userName: "john.doe@contoso.onmicrosoft.com" }); + assert.notStrictEqual(actual.success, true); }); - 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); + it('fails validation if the id is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation when userName has an invalid value', async () => { - const actual = await command.validate({ options: { userName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when userName has an invalid value', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if the id is a valid GUID', async () => { - const actual = await command.validate({ options: { id: '68be84bf-a585-4776-80b3-30aa5207aa22' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the id is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: '68be84bf-a585-4776-80b3-30aa5207aa22' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the userName is specified', async () => { - const actual = await command.validate({ options: { userName: 'john.doe@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the userName is specified', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'john.doe@contoso.onmicrosoft.com' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the email is specified', async () => { - const actual = await command.validate({ options: { email: 'john.doe@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the email is specified', () => { + const actual = commandOptionsSchema.safeParse({ email: 'john.doe@contoso.onmicrosoft.com' }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/entra/commands/user/user-get.ts b/src/m365/entra/commands/user/user-get.ts index f59a5258116..b5dc27821be 100644 --- a/src/m365/entra/commands/user/user-get.ts +++ b/src/m365/entra/commands/user/user-get.ts @@ -1,6 +1,7 @@ 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 request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; @@ -8,18 +9,23 @@ import commands from '../../commands.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { formatting } from '../../../../utils/formatting.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.uuid().optional().alias('i'), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid userName.` + }).optional().alias('n'), + email: z.string().optional(), + properties: z.string().optional().alias('p'), + withManager: z.boolean().optional() +}); + +export declare type Options = z.infer; + interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { - id?: string; - userName?: string; - email?: string; - properties?: string; - withManager?: boolean; -} - class EntraUserGetCommand extends GraphCommand { public get name(): string { return commands.USER_GET; @@ -29,66 +35,19 @@ class EntraUserGetCommand extends GraphCommand { return 'Gets information about the specified user'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - email: typeof args.options.email !== 'undefined', - properties: args.options.properties, - withManager: typeof args.options.withManager !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id [id]' - }, - { - option: '-n, --userName [userName]' - }, - { - option: '--email [email]' - }, - { - option: '-p, --properties [properties]' - }, - { - option: '--withManager' - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #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`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.id, options.userName, options.email].filter(o => o !== undefined).length === 1, { + error: `Specify either 'id', 'userName', or 'email'.`, + params: { + customCode: 'optionSet', + options: ['id', 'userName', 'email'] } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid userName`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'userName', 'email'] }); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/user/user-groupmembership-list.spec.ts b/src/m365/entra/commands/user/user-groupmembership-list.spec.ts index fd9ab785905..b13d011d869 100644 --- a/src/m365/entra/commands/user/user-groupmembership-list.spec.ts +++ b/src/m365/entra/commands/user/user-groupmembership-list.spec.ts @@ -4,7 +4,7 @@ import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import commands from '../../commands.js'; -import command from './user-groupmembership-list.js'; +import command, { options } from './user-groupmembership-list.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; @@ -40,6 +40,7 @@ describe(commands.USER_GROUPMEMBERSHIP_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -48,6 +49,7 @@ describe(commands.USER_GROUPMEMBERSHIP_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -87,29 +89,29 @@ describe(commands.USER_GROUPMEMBERSHIP_LIST, () => { assert.notStrictEqual(command.description, null); }); - it('passes validation if userId is a valid GUID', async () => { - const actual = await command.validate({ options: { userId: userId } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if userId is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ userId: userId }); + assert.strictEqual(actual.success, true); }); - it('passes validation if userName is a valid UPN', async () => { - const actual = await command.validate({ options: { userName: userName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if userName is a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ userName: userName }); + assert.strictEqual(actual.success, true); }); - it('passes validation if userEmail is a valid email', async () => { - const actual = await command.validate({ options: { userEmail: userName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if userEmail is a valid email', () => { + const actual = commandOptionsSchema.safeParse({ userEmail: userName }); + assert.strictEqual(actual.success, true); }); - it('fails validation if userId is not a valid GUID', async () => { - const actual = await command.validate({ options: { userId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ userId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if userName is not a valid UPN', async () => { - const actual = await command.validate({ options: { userName: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userName is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('retrieves groups memberships for a user specified by id', async () => { @@ -124,7 +126,7 @@ describe(commands.USER_GROUPMEMBERSHIP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userId: userId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userId: userId }) }); assert(loggerLogSpy.calledOnceWithExactly(groupMembershipResults)); }); @@ -141,7 +143,7 @@ describe(commands.USER_GROUPMEMBERSHIP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userName }) }); assert(loggerLogSpy.calledOnceWithExactly(groupMembershipResults)); }); @@ -158,7 +160,7 @@ describe(commands.USER_GROUPMEMBERSHIP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userEmail: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userEmail: userName }) }); assert(loggerLogSpy.calledOnceWithExactly(groupMembershipResults)); }); @@ -174,19 +176,19 @@ describe(commands.USER_GROUPMEMBERSHIP_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userId: userId, securityEnabledOnly: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userId: userId, securityEnabledOnly: true }) }); assert(loggerLogSpy.calledOnceWithExactly(groupMembershipResults)); }); - it('fails validation if userEmail is not a valid email', async () => { - const actual = await command.validate({ options: { userEmail: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userEmail is not a valid email', () => { + const actual = commandOptionsSchema.safeParse({ userEmail: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('handles random API error', async () => { const errorMessage = 'Something went wrong'; sinon.stub(request, 'post').rejects(new Error(errorMessage)); - await assert.rejects(command.action(logger, { options: { userId: userId } }), new CommandError(errorMessage)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ userId: userId }) }), new CommandError(errorMessage)); }); -}); \ No newline at end of file +}); diff --git a/src/m365/entra/commands/user/user-groupmembership-list.ts b/src/m365/entra/commands/user/user-groupmembership-list.ts index ca25dfc3125..7aa0d515359 100644 --- a/src/m365/entra/commands/user/user-groupmembership-list.ts +++ b/src/m365/entra/commands/user/user-groupmembership-list.ts @@ -1,23 +1,31 @@ -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import request, { CliRequestOptions } from '../../../../request.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import request, { CliRequestOptions } from '../../../../request.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { ODataResponse } from '../../../../utils/odata.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userId: z.uuid().optional().alias('i'), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid user principal name.` + }).optional().alias('n'), + userEmail: z.string().refine(email => validation.isValidUserPrincipalName(email), { + error: e => `'${e.input}' is not a valid user email.` + }).optional().alias('e'), + securityEnabledOnly: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { - userId?: string; - userName?: string; - userEmail?: string; - securityEnabledOnly?: boolean; -} - interface UserGroupMembership { groupId: string; } @@ -31,65 +39,19 @@ class EntraUserGroupmembershipListCommand extends GraphCommand { return 'Retrieves all groups where the user is a member of'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - userEmail: typeof args.options.userEmail !== 'undefined', - securityEnabledOnly: !!args.options.securityEnabledOnly - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --userId [userId]' - }, - { - option: '-n, --userName [userName]' - }, - { - option: '-e, --userEmail [userEmail]' - }, - { - option: '--securityEnabledOnly [securityEnabledOnly]' - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId as string)) { - return `${args.options.userId} is not a valid GUID`; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName as string)) { - return `${args.options.userName} is not a valid user principal name`; - } - - if (args.options.userEmail && !validation.isValidUserPrincipalName(args.options.userEmail as string)) { - return `${args.options.userEmail} is not a valid user email`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.userId, options.userName, options.userEmail].filter(o => o !== undefined).length === 1, { + error: `Specify either 'userId', 'userName', or 'userEmail'.`, + params: { + customCode: 'optionSet', + options: ['userId', 'userName', 'userEmail'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['userId', 'userName', 'userEmail'] }); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/user/user-guest-add.spec.ts b/src/m365/entra/commands/user/user-guest-add.spec.ts index 914b76a4d13..95b6606907a 100644 --- a/src/m365/entra/commands/user/user-guest-add.spec.ts +++ b/src/m365/entra/commands/user/user-guest-add.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; 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'; @@ -9,7 +11,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-guest-add.js'; +import command, { options } from './user-guest-add.js'; describe(commands.USER_GUEST_ADD, () => { const emailAddress = 'john.doe@contoso.com'; @@ -40,6 +42,8 @@ describe(commands.USER_GUEST_ADD, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -47,6 +51,8 @@ describe(commands.USER_GUEST_ADD, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -95,10 +101,10 @@ describe(commands.USER_GUEST_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ emailAddress: emailAddress, displayName: displayName - } + }) }); assert(loggerLogSpy.calledWith(requestResponse)); @@ -118,7 +124,7 @@ describe(commands.USER_GUEST_ADD, () => { const ccRecipient = 'Maria.Jones@contoso.com'; const languageCode = 'nl-BE'; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ emailAddress: emailAddress, displayName: displayName, inviteRedirectUrl: redirectUrl, @@ -126,7 +132,7 @@ describe(commands.USER_GUEST_ADD, () => { ccRecipients: ccRecipient, messageLanguage: languageCode, sendInvitationMessage: true - } + }) }); const requestBody = { @@ -154,9 +160,9 @@ describe(commands.USER_GUEST_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ emailAddress: emailAddress - } + }) }); assert.strictEqual(postRequestStub.lastCall.args[0].data.inviteRedirectUrl, 'https://myapplications.microsoft.com'); @@ -174,10 +180,10 @@ describe(commands.USER_GUEST_ADD, () => { const ccRecipient = 'Maria.Jones@contoso.com'; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ emailAddress: emailAddress, ccRecipients: ccRecipient - } + }) }); assert.deepStrictEqual(postRequestStub.lastCall.args[0].data.invitedUserMessageInfo.ccRecipients, [{ emailAddress: { address: ccRecipient } }]); @@ -188,9 +194,9 @@ describe(commands.USER_GUEST_ADD, () => { sinon.stub(request, 'post').rejects({ error: { message: errorMessage } }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ emailAddress: emailAddress - } + }) }), new CommandError(errorMessage)); }); }); diff --git a/src/m365/entra/commands/user/user-guest-add.ts b/src/m365/entra/commands/user/user-guest-add.ts index 3226e65f622..cc79feae15b 100644 --- a/src/m365/entra/commands/user/user-guest-add.ts +++ b/src/m365/entra/commands/user/user-guest-add.ts @@ -1,23 +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 GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + emailAddress: z.string(), + displayName: z.string().optional(), + inviteRedirectUrl: z.string().optional(), + welcomeMessage: z.string().optional(), + messageLanguage: z.string().optional(), + ccRecipients: z.string().optional(), + sendInvitationMessage: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - emailAddress: string; - displayName?: string; - inviteRedirectUrl?: string; - welcomeMessage?: string; - messageLanguage?: string; - ccRecipients?: string; - sendInvitationMessage?: boolean; -} - class EntraUserGuestAddCommand extends GraphCommand { public get name(): string { return commands.USER_GUEST_ADD; @@ -27,50 +31,8 @@ class EntraUserGuestAddCommand extends GraphCommand { return 'Invite an external user to the organization'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - displayName: typeof args.options.displayName !== 'undefined', - inviteRedirectUrl: typeof args.options.inviteRedirectUrl !== 'undefined', - welcomeMessage: typeof args.options.welcomeMessage !== 'undefined', - messageLanguage: typeof args.options.messageLanguage !== 'undefined', - ccRecipients: typeof args.options.ccRecipients !== 'undefined', - sendInvitationMessage: !!args.options.sendInvitationMessage - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--emailAddress ' - }, - { - option: '--displayName [displayName]' - }, - { - option: '--inviteRedirectUrl [inviteRedirectUrl]' - }, - { - option: '--welcomeMessage [welcomeMessage]' - }, - { - option: '--messageLanguage [messageLanguage]' - }, - { - option: '--ccRecipients [ccRecipients]' - }, - { - option: '--sendInvitationMessage' - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/user/user-hibp.spec.ts b/src/m365/entra/commands/user/user-hibp.spec.ts index 8a081c38c8c..32e0a82e374 100644 --- a/src/m365/entra/commands/user/user-hibp.spec.ts +++ b/src/m365/entra/commands/user/user-hibp.spec.ts @@ -11,17 +11,18 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-hibp.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './user-hibp.js'; describe(commands.USER_HIBP, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; sinon.stub(telemetry, 'trackEvent').resolves(); sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); @@ -45,8 +46,7 @@ describe(commands.USER_HIBP, () => { afterEach(() => { sinonUtil.restore([ - request.get, - cli.getSettingWithDefaultValue + request.get ]); }); @@ -62,32 +62,24 @@ describe(commands.USER_HIBP, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if userName and apiKey is not specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: {} }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userName and apiKey is not specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if the userName is not a valid UPN', async () => { - const actual = await command.validate({ options: { userName: 'invalid', apiKey: 'key' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the userName is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'invalid', apiKey: 'key' }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if userName and apiKey is specified', async () => { - const actual = await command.validate({ options: { userName: "account-exists@hibp-integration-tests.com", apiKey: "2975xc539c304xf797f665x43f8x557x" } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if userName and apiKey is specified', () => { + const actual = commandOptionsSchema.safeParse({ userName: "account-exists@hibp-integration-tests.com", apiKey: "2975xc539c304xf797f665x43f8x557x" }); + assert.strictEqual(actual.success, true); }); - it('passes validation if domain is specified', async () => { - const actual = await command.validate({ options: { userName: "account-exists@hibp-integration-tests.com", apiKey: "2975xc539c304xf797f665x43f8x557x", domain: "domain.com" } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if domain is specified', () => { + const actual = commandOptionsSchema.safeParse({ userName: "account-exists@hibp-integration-tests.com", apiKey: "2975xc539c304xf797f665x43f8x557x", domain: "domain.com" }); + assert.strictEqual(actual.success, true); }); it('checks user is pwned using userName', async () => { @@ -99,7 +91,7 @@ describe(commands.USER_HIBP, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: 'account-exists@hibp-integration-tests.com', apiKey: "2975xc539c304xf797f665x43f8x557x" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: 'account-exists@hibp-integration-tests.com', apiKey: "2975xc539c304xf797f665x43f8x557x" }) }); assert(loggerLogSpy.calledWith([{ "Name": "Adobe" }])); }); @@ -113,7 +105,7 @@ describe(commands.USER_HIBP, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, userName: 'account-exists@hibp-integration-tests.com', apiKey: '2975xc539c304xf797f665x43f8x557x' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, userName: 'account-exists@hibp-integration-tests.com', apiKey: '2975xc539c304xf797f665x43f8x557x' }) }); assert(loggerLogSpy.calledWith([{ "Name": "Adobe" }])); }); @@ -127,7 +119,7 @@ describe(commands.USER_HIBP, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: 'account-exists@hibp-integration-tests.com', apiKey: "2975xc539c304xf797f665x43f8x557x" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: 'account-exists@hibp-integration-tests.com', apiKey: "2975xc539c304xf797f665x43f8x557x" }) }); assert(loggerLogSpy.calledWith([{ "Name": "Adobe" }, { "Name": "Gawker" }, { "Name": "Stratfor" }])); }); @@ -141,7 +133,7 @@ describe(commands.USER_HIBP, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: 'account-exists@hibp-integration-tests.com', domain: "adobe.com", apiKey: "2975xc539c304xf797f665x43f8x557x" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: 'account-exists@hibp-integration-tests.com', domain: "adobe.com", apiKey: "2975xc539c304xf797f665x43f8x557x" }) }); assert(loggerLogSpy.calledWith([{ "Name": "Adobe" }])); }); @@ -159,7 +151,7 @@ describe(commands.USER_HIBP, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, userName: 'account-exists@hibp-integration-tests.com', domain: "adobe.xxx", apiKey: "2975xc539c304xf797f665x43f8x557x" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, userName: 'account-exists@hibp-integration-tests.com', domain: "adobe.xxx", apiKey: "2975xc539c304xf797f665x43f8x557x" }) }); assert(loggerLogSpy.calledWith("No pwnage found")); }); @@ -170,7 +162,7 @@ describe(commands.USER_HIBP, () => { } }); - await command.action(logger, { options: { debug: true, userName: 'account-notexists@hibp-integration-tests.com', apiKey: "2975xc539c304xf797f665x43f8x557x" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, userName: 'account-notexists@hibp-integration-tests.com', apiKey: "2975xc539c304xf797f665x43f8x557x" }) }); assert(loggerLogSpy.calledWith("No pwnage found")); }); @@ -181,31 +173,21 @@ describe(commands.USER_HIBP, () => { } }); - await command.action(logger, { options: { verbose: true, userName: 'account-notexists@hibp-integration-tests.com', apiKey: "2975xc539c304xf797f665x43f8x557x" } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userName: 'account-notexists@hibp-integration-tests.com', apiKey: "2975xc539c304xf797f665x43f8x557x" }) }); assert(loggerLogSpy.calledWith("No pwnage found")); }); it('correctly handles unauthorized request', async () => { sinon.stub(request, 'get').rejects(new Error("Access denied due to improperly formed hibp-api-key.")); - await assert.rejects(command.action(logger, { options: { userName: 'account-notexists@hibp-integration-tests.com' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ userName: 'account-notexists@hibp-integration-tests.com', apiKey: 'key' }) }), new CommandError("Access denied due to improperly formed hibp-api-key.")); }); - it('fails validation if the userName is not a valid UPN.', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; + it('fails validation if the userName is not a valid UPN.', () => { + const actual = commandOptionsSchema.safeParse({ + userName: "no-an-email" }); - - const actual = await command.validate({ - options: { - userName: "no-an-email" - } - }, commandInfo); - assert.notStrictEqual(actual, true); + assert.notStrictEqual(actual.success, true); }); }); diff --git a/src/m365/entra/commands/user/user-hibp.ts b/src/m365/entra/commands/user/user-hibp.ts index 70ed76b2710..e6041b0f1ed 100644 --- a/src/m365/entra/commands/user/user-hibp.ts +++ b/src/m365/entra/commands/user/user-hibp.ts @@ -1,21 +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 { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import AnonymousCommand from '../../../base/AnonymousCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: 'Specify valid userName.' + }).alias('n'), + apiKey: z.string(), + domain: z.string().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userName: string; - apiKey: string; - domain?: string -} - class EntraUserHibpCommand extends AnonymousCommand { public get name(): string { return commands.USER_HIBP; @@ -25,46 +31,8 @@ class EntraUserHibpCommand extends AnonymousCommand { return 'Allows you to retrieve all accounts that have been pwned with the specified username'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - domain: args.options.domain - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-n, --userName ' - }, - { - option: '--apiKey, ' - }, - { - option: '--domain, [domain]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (!validation.isValidUserPrincipalName(args.options.userName)) { - return 'Specify valid userName'; - } - - return true; - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/user/user-license-add.spec.ts b/src/m365/entra/commands/user/user-license-add.spec.ts index 511cdfe61b3..f386406aa4b 100644 --- a/src/m365/entra/commands/user/user-license-add.spec.ts +++ b/src/m365/entra/commands/user/user-license-add.spec.ts @@ -12,10 +12,11 @@ import { session } from '../../../../utils/session.js'; import { formatting } from '../../../../utils/formatting.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-license-add.js'; +import command, { options } from './user-license-add.js'; describe(commands.USER_LICENSE_ADD, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validIds = '45715bb8-13f9-4bf6-927f-ef96c102d394,0118A350-71FC-4EC3-8F0C-6A1CB8867561'; const validUserId = 'eb77fbcf-6fe8-458b-985d-1747284793bc'; @@ -46,6 +47,7 @@ describe(commands.USER_LICENSE_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -83,27 +85,23 @@ describe(commands.USER_LICENSE_ADD, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if ids is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - ids: 'Invalid GUID', userId: validUserId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if ids is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + ids: 'Invalid GUID', userId: validUserId + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if userId is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - ids: validIds, userId: 'Invalid GUID' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userId is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + ids: validIds, userId: 'Invalid GUID' + }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if required options specified (ids)', async () => { - const actual = await command.validate({ options: { ids: validIds, userId: validUserId } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (ids)', () => { + const actual = commandOptionsSchema.safeParse({ ids: validIds, userId: validUserId }); + assert.strictEqual(actual.success, true); }); it('adds licenses to a user by userId', async () => { @@ -115,7 +113,7 @@ describe(commands.USER_LICENSE_ADD, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { verbose: true, userId: validUserId, ids: validIds } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userId: validUserId, ids: validIds }) }); assert(loggerLogSpy.calledWith(userLicenseResponse)); }); @@ -128,7 +126,7 @@ describe(commands.USER_LICENSE_ADD, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { verbose: true, userName: validUserName, ids: validIds } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userName: validUserName, ids: validIds }) }); assert(loggerLogSpy.calledWith(userLicenseResponse)); }); @@ -141,9 +139,9 @@ describe(commands.USER_LICENSE_ADD, () => { sinon.stub(request, 'post').rejects(error); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ userName: validUserName, ids: validIds - } + }) }), new CommandError(error.error.message)); }); }); diff --git a/src/m365/entra/commands/user/user-license-add.ts b/src/m365/entra/commands/user/user-license-add.ts index 19767800b39..d21302aabd2 100644 --- a/src/m365/entra/commands/user/user-license-add.ts +++ b/src/m365/entra/commands/user/user-license-add.ts @@ -1,21 +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 { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userId: z.uuid().optional(), + userName: z.string().optional(), + ids: z.string().refine(ids => !ids.split(',').some(e => !validation.isValidGuid(e)), { + error: e => `'${e.input}' contains one or more invalid GUIDs.` + }) +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userId: string; - userName: string; - ids: string; -} - class EntraUserLicenseAddCommand extends GraphCommand { public get name(): string { return commands.USER_LICENSE_ADD; @@ -25,58 +31,19 @@ class EntraUserLicenseAddCommand extends GraphCommand { return 'Assigns a license to a user'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--userId [userId]' - }, - { - option: '--userName [userName]' - }, - { - option: '--ids ' - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId as string)) { - return `${args.options.userId} is not a valid GUID`; - } - - if (args.options.ids && args.options.ids.split(',').some(e => !validation.isValidGuid(e))) { - return `${args.options.ids} contains one or more invalid GUIDs`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.userId, options.userName].filter(o => o !== undefined).length === 1, { + error: `Specify either 'userId' or 'userName'.`, + params: { + customCode: 'optionSet', + options: ['userId', 'userName'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['userId', 'userName'] } - ); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -84,7 +51,7 @@ class EntraUserLicenseAddCommand extends GraphCommand { const requestBody = { "addLicenses": addLicenses, "removeLicenses": [] }; const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/users/${formatting.encodeQueryParameter(args.options.userId || args.options.userName)}/assignLicense`, + url: `${this.resource}/v1.0/users/${formatting.encodeQueryParameter(args.options.userId ?? args.options.userName!)}/assignLicense`, headers: { accept: 'application/json;odata.metadata=none' }, diff --git a/src/m365/entra/commands/user/user-license-list.spec.ts b/src/m365/entra/commands/user/user-license-list.spec.ts index 5cf4f07e801..b9dbefe6654 100644 --- a/src/m365/entra/commands/user/user-license-list.spec.ts +++ b/src/m365/entra/commands/user/user-license-list.spec.ts @@ -13,7 +13,7 @@ import { session } from '../../../../utils/session.js'; import { formatting } from '../../../../utils/formatting.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-license-list.js'; +import command, { options } from './user-license-list.js'; describe(commands.USER_LICENSE_LIST, () => { const userId = '59f80e08-24b1-41f8-8586-16765fd830d3'; @@ -52,6 +52,7 @@ describe(commands.USER_LICENSE_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let assertAccessTokenTypeStub: sinon.SinonStub; before(() => { @@ -65,6 +66,7 @@ describe(commands.USER_LICENSE_LIST, () => { }; auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -108,30 +110,30 @@ describe(commands.USER_LICENSE_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['id', 'skuId', 'skuPartNumber']); }); - it('fails validation if userId is not a valid GUID', async () => { - const actual = await command.validate({ options: { userId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ userId: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if userName is not a valid UPN', async () => { - const actual = await command.validate({ options: { userName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userName is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('validates for a correct input with a userId defined', async () => { - const actual = await command.validate({ options: { userId: userId } }, commandInfo); - assert.strictEqual(actual, true); + it('validates for a correct input with a userId defined', () => { + const actual = commandOptionsSchema.safeParse({ userId: userId }); + assert.strictEqual(actual.success, true); }); - it('passes validation if no options specified', async () => { - const actual = await command.validate({ options: {} }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if no options specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, true); }); it('ensures delegated permissions are enforced', async () => { sinon.stub(request, 'get').resolves(licenseResponse); - await command.action(logger, { options: { userId: userId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userId: userId }) }); assert(assertAccessTokenTypeStub.calledOnceWithExactly('delegated')); }); @@ -144,7 +146,7 @@ describe(commands.USER_LICENSE_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogSpy.calledWith(licenseResponse.value)); }); @@ -157,7 +159,7 @@ describe(commands.USER_LICENSE_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userId: userId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userId: userId }) }); assert(loggerLogSpy.calledWith(licenseResponse.value)); }); @@ -170,7 +172,7 @@ describe(commands.USER_LICENSE_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userName }) }); assert(loggerLogSpy.calledWith(licenseResponse.value)); }); @@ -183,7 +185,7 @@ describe(commands.USER_LICENSE_LIST, () => { sinon.stub(request, 'get').rejects(error); await assert.rejects(command.action(logger, { - options: { userName: userName } + options: commandOptionsSchema.parse({ userName: userName }) }), new CommandError(error.error.message)); }); -}); \ No newline at end of file +}); diff --git a/src/m365/entra/commands/user/user-license-list.ts b/src/m365/entra/commands/user/user-license-list.ts index 546e20d403f..e732ee8802e 100644 --- a/src/m365/entra/commands/user/user-license-list.ts +++ b/src/m365/entra/commands/user/user-license-list.ts @@ -1,20 +1,26 @@ +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 { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import commands from '../../commands.js'; import GraphDelegatedCommand from '../../../base/GraphDelegatedCommand.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userId: z.uuid().optional(), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid user principal name (UPN).` + }).optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userId?: string; - userName?: string; -} - class EntraUserLicenseListCommand extends GraphDelegatedCommand { public get name(): string { return commands.USER_LICENSE_LIST; @@ -28,56 +34,19 @@ class EntraUserLicenseListCommand extends GraphDelegatedCommand { return ['id', 'skuId', 'skuPartNumber']; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--userId [userId]' - }, - { - option: '--userName [userName]' - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `${args.options.userId} is not a valid GUID`; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid user principal name (UPN)`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => !options.userId || !options.userName, { + error: `Specify either 'userId' or 'userName', but not both.`, + params: { + customCode: 'optionSet', + options: ['userId', 'userName'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ - options: ['userId', 'userName'], - runsWhen: (args) => args.options.userId || args.options.userName - }); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/user/user-license-remove.spec.ts b/src/m365/entra/commands/user/user-license-remove.spec.ts index e5b457e3b88..90df74826cb 100644 --- a/src/m365/entra/commands/user/user-license-remove.spec.ts +++ b/src/m365/entra/commands/user/user-license-remove.spec.ts @@ -12,10 +12,11 @@ import { session } from '../../../../utils/session.js'; import { formatting } from '../../../../utils/formatting.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-license-remove.js'; +import command, { options } from './user-license-remove.js'; describe(commands.USER_LICENSE_REMOVE, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validUserId = '3a081d91-5ea8-40a7-8ac9-abbaa3fcb893'; const validUserName = 'John.Doe@contoso.com'; @@ -34,6 +35,7 @@ describe(commands.USER_LICENSE_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -77,64 +79,58 @@ describe(commands.USER_LICENSE_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if ids is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - ids: 'Invalid GUID', userId: validUserId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if ids is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + ids: 'Invalid GUID', userId: validUserId + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if userId is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - ids: validIds, userId: 'Invalid GUID' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userId is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + ids: validIds, userId: 'Invalid GUID' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation when userName is not a valid upn', async () => { - const actual = await command.validate({ - options: { - ids: validIds, userName: 'Invalid upn' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when userName is not a valid upn', () => { + const actual = commandOptionsSchema.safeParse({ + ids: validIds, userName: 'Invalid upn' + }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if required options specified (userId)', async () => { - const actual = await command.validate({ options: { ids: validIds, userId: validUserId } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (userId)', () => { + const actual = commandOptionsSchema.safeParse({ ids: validIds, userId: validUserId }); + assert.strictEqual(actual.success, true); }); - it('passes validation if required options specified (userName)', async () => { - const actual = await command.validate({ options: { ids: validIds, userName: validUserName } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (userName)', () => { + const actual = commandOptionsSchema.safeParse({ ids: validIds, userName: validUserName }); + assert.strictEqual(actual.success, true); }); it('prompts before removing the specified user licenses when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ ids: validIds, userId: validUserId - } + }) }); assert(promptIssued); }); it('aborts removing the specified user licenses when force option not passed and prompt not confirmed', async () => { - const postSpy = sinon.spy(request, 'delete'); + const postSpy = sinon.stub(request, 'post').resolves(); sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ ids: validIds, userId: validUserId - } + }) }); assert(postSpy.notCalled); }); @@ -148,7 +144,7 @@ describe(commands.USER_LICENSE_REMOVE, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { userId: validUserId, ids: validIdsSingle, force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userId: validUserId, ids: validIdsSingle, force: true }) }); assert(postSpy.called); }); @@ -165,9 +161,9 @@ describe(commands.USER_LICENSE_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, userName: validUserName, ids: validIds - } + }) }); assert(postSpy.called); }); @@ -182,9 +178,9 @@ describe(commands.USER_LICENSE_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, userId: validUserId, ids: validIds, force: true - } + }) }); assert(postSpy.called); }); @@ -205,9 +201,9 @@ describe(commands.USER_LICENSE_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, userId: validUserId, ids: validIdsSingle, force: true - } + }) }), new CommandError(error.error.message)); }); @@ -220,9 +216,9 @@ describe(commands.USER_LICENSE_REMOVE, () => { sinon.stub(request, 'post').callsFake(async () => { throw error; }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ userName: validUserName, ids: validIds, force: true - } + }) }), new CommandError(error.error.message)); }); }); diff --git a/src/m365/entra/commands/user/user-license-remove.ts b/src/m365/entra/commands/user/user-license-remove.ts index 4511ed7c1dc..1fc3459c0d0 100644 --- a/src/m365/entra/commands/user/user-license-remove.ts +++ b/src/m365/entra/commands/user/user-license-remove.ts @@ -1,5 +1,6 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import commands from '../../commands.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; @@ -7,17 +8,24 @@ import { formatting } from '../../../../utils/formatting.js'; import { cli } from '../../../../cli/cli.js'; import GraphCommand from '../../../base/GraphCommand.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userId: z.uuid().optional(), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid user principal name (UPN).` + }).optional(), + ids: z.string().refine(ids => !ids.split(',').some(e => !validation.isValidGuid(e)), { + error: e => `'${e.input}' contains one or more invalid GUIDs.` + }), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userId?: string; - userName?: string; - ids: string; - force?: boolean; -} - class EntraUserLicenseRemoveCommand extends GraphCommand { public get name(): string { @@ -28,66 +36,19 @@ class EntraUserLicenseRemoveCommand extends GraphCommand { return 'Removes a license from a user'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - force: !!args.options.force - }); - }); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: '--userId [userId]' - }, - { - option: '--userName [userName]' - }, - { - option: '--ids ' - }, - { - option: '-f, --force' - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['userId', 'userName'] } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId as string)) { - return `${args.options.userId} is not a valid GUID`; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid user principal name (UPN)`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.userId, options.userName].filter(o => o !== undefined).length === 1, { + error: `Specify either 'userId' or 'userName'.`, + params: { + customCode: 'optionSet', + options: ['userId', 'userName'] } - - if (args.options.ids && args.options.ids.split(',').some(e => !validation.isValidGuid(e))) { - return `${args.options.ids} contains one or more invalid GUIDs`; - } - - return true; - } - ); + }); } public async commandAction(logger: Logger, args: any): Promise { diff --git a/src/m365/entra/commands/user/user-list.spec.ts b/src/m365/entra/commands/user/user-list.spec.ts index f75c290d2f0..80a16e5d828 100644 --- a/src/m365/entra/commands/user/user-list.spec.ts +++ b/src/m365/entra/commands/user/user-list.spec.ts @@ -12,10 +12,11 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-list.js'; +import command, { options } from './user-list.js'; describe(commands.USER_LIST, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; @@ -27,6 +28,7 @@ describe(commands.USER_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -64,14 +66,19 @@ describe(commands.USER_LIST, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if type is not a valid user type', async () => { - const actual = await command.validate({ options: { type: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if type is not a valid user type', () => { + const actual = commandOptionsSchema.safeParse({ type: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if type is a valid user type', async () => { - const actual = await command.validate({ options: { type: 'Member' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if type is a valid user type', () => { + const actual = commandOptionsSchema.safeParse({ type: 'Member' }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation for unknown options because schema is loose', () => { + const actual = commandOptionsSchema.safeParse({ surname: 'M' }); + assert.strictEqual(actual.success, true); }); it('lists users in the tenant with the default properties (debug)', async () => { @@ -88,7 +95,7 @@ describe(commands.USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogSpy.calledWith([ { "id": "1f5595b2-aa07-445d-9801-a45ea18160b2", "displayName": "Aarif Sherzai", "mail": "AarifS@contoso.onmicrosoft.com", "userPrincipalName": "AarifS@contoso.onmicrosoft.com" }, { "id": "717f1683-00fa-488c-b68d-5d0051f6bcfa", "displayName": "Achim Maier", "mail": "AchimM@contoso.onmicrosoft.com", "userPrincipalName": "AchimM@contoso.onmicrosoft.com" } @@ -108,7 +115,7 @@ describe(commands.USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { properties: 'displayName,mail' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ properties: 'displayName,mail' }) }); assert(loggerLogSpy.calledWith([ { "displayName": "Aarif Sherzai", "mail": "AarifS@contoso.onmicrosoft.com" }, { "displayName": "Achim Maier", "mail": "AchimM@contoso.onmicrosoft.com" } ])); @@ -127,7 +134,7 @@ describe(commands.USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { properties: 'displayName,manager/displayName,manager/department' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ properties: 'displayName,manager/displayName,manager/department' }) }); assert(loggerLogSpy.calledWith([ { "displayName": "Aarif Sherzai", "manager": { "displayName": "Jon Doe", "department": "IT" } }, { "displayName": "Achim Maier", "manager": { "displayName": "Jon Doe", "department": "IT" } } ])); @@ -146,7 +153,7 @@ describe(commands.USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { surname: 'M' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ surname: 'M' }) }); assert(loggerLogSpy.calledWith([ { "id": "1f5595b2-aa07-445d-9801-a45ea18160b2", "displayName": "Achim Maier", "mail": "AchimM@contoso.onmicrosoft.com", "userPrincipalName": "AchimM@contoso.onmicrosoft.com" }, { "id": "0fe76bf5-222b-48f8-a5c1-a3a03b96d472", "displayName": "Karl Matteson", "mail": "KarlM@contoso.onmicrosoft.com", "userPrincipalName": "KarlM@contoso.onmicrosoft.com" } ])); @@ -165,7 +172,7 @@ describe(commands.USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { surname: 'M', givenName: 'A' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ surname: 'M', givenName: 'A' }) }); assert(loggerLogSpy.calledWith([ { "id": "1f5595b2-aa07-445d-9801-a45ea18160b2", "displayName": "Achim Maier", "mail": "AchimM@contoso.onmicrosoft.com", "userPrincipalName": "AchimM@contoso.onmicrosoft.com" }, { "id": "7f50c7d9-916b-4da9-949e-09a431de2646", "displayName": "Anne Matthews", "mail": "AnneM@contoso.onmicrosoft.com", "userPrincipalName": "AnneM@contoso.onmicrosoft.com" } ])); @@ -184,7 +191,7 @@ describe(commands.USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { surname: 'S', type: 'Guest' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ surname: 'S', type: 'Guest' }) }); assert(loggerLogSpy.calledWith([ { "id": "7dc52cef-c513-4a53-bd43-93e9f6727911", "displayName": "Aarif Sherzai", "mail": "AarifS@fabrikam.onmicrosoft.com", "userPrincipalName": "AarifS_fabrikam.onmicrosoft.com#EXT#@contoso.onmicrosoft.com" } ])); @@ -203,7 +210,7 @@ describe(commands.USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { type: 'Guest', properties: 'displayName' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ type: 'Guest', properties: 'displayName' }) }); assert(loggerLogSpy.calledWith([ { "id": "7dc52cef-c513-4a53-bd43-93e9f6727911", "displayName": "Aarif Sherzai", "mail": "AarifS@fabrikam.onmicrosoft.com", "userPrincipalName": "AarifS_fabrikam.onmicrosoft.com#EXT#@contoso.onmicrosoft.com" } ])); @@ -221,7 +228,7 @@ describe(commands.USER_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { displayName: displayName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ displayName: displayName }) }); assert(loggerLogSpy.calledWith([])); }); @@ -239,12 +246,12 @@ describe(commands.USER_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ surname: 'M', givenName: 'A', - output: "json", + output: 'json', verbose: true - } + }) }); assert(loggerLogSpy.calledWith([ { "id": "1f5595b2-aa07-445d-9801-a45ea18160b2", "displayName": "Achim Maier", "mail": "AchimM@contoso.onmicrosoft.com", "userPrincipalName": "AchimM@contoso.onmicrosoft.com" }, { "id": "7f50c7d9-916b-4da9-949e-09a431de2646", "displayName": "Anne Matthews", "mail": "AnneM@contoso.onmicrosoft.com", "userPrincipalName": "AnneM@contoso.onmicrosoft.com" } @@ -269,11 +276,11 @@ describe(commands.USER_LIST, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError('An error has occurred')); }); it('handles error when option to filter by specified without a value (flag)', async () => { - await assert.rejects(command.action(logger, { options: { surname: true } } as any), new CommandError('Specify value for the surname property')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ surname: true }) }), new CommandError('Specify value for the surname property')); }); it('allows unknown options', () => { diff --git a/src/m365/entra/commands/user/user-list.ts b/src/m365/entra/commands/user/user-list.ts index 3f8eda97002..dec37511feb 100644 --- a/src/m365/entra/commands/user/user-list.ts +++ b/src/m365/entra/commands/user/user-list.ts @@ -1,24 +1,29 @@ 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 { formatting } from '../../../../utils/formatting.js'; import { odata } from '../../../../utils/odata.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { optionsUtils } from '../../../../utils/optionsUtils.js'; +import { zod } from '../../../../utils/zod.js'; + +const allowedTypes = { Member: 'Member', Guest: 'Guest' } as const; + +export const options = z.looseObject({ + ...globalOptionsZod.shape, + type: zod.coercedEnum(allowedTypes).optional(), + properties: z.string().optional().alias('p') +}); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - type?: string; - properties?: string; -} - class EntraUserListCommand extends GraphCommand { - private static readonly allowedTypes: string[] = ['Member', 'Guest']; - public get name(): string { return commands.USER_LIST; } @@ -35,50 +40,8 @@ class EntraUserListCommand extends GraphCommand { return ['id', 'displayName', 'mail', 'userPrincipalName']; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - type: typeof args.options.type !== 'undefined', - properties: typeof args.options.properties !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--type [type]', - autocomplete: EntraUserListCommand.allowedTypes - }, - { - option: '-p, --properties [properties]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.type && !EntraUserListCommand.allowedTypes.some(t => t.toLowerCase() === args.options.type!.toLowerCase())) { - return `'${args.options.type}' is not a valid value for option 'type'. Allowed values are: ${EntraUserListCommand.allowedTypes.join(', ')}.`; - } - - return true; - } - ); - } - - #initTypes(): void { - this.types.string.push('type', 'properties'); + public get schema(): z.ZodTypeAny | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -116,13 +79,13 @@ class EntraUserListCommand extends GraphCommand { private getFilter(options: Options): string | null { const filters: string[] = []; - const unknownOptions = optionsUtils.getUnknownOptions(options, this.options); + const unknownOptions = optionsUtils.getUnknownOptions(options, zod.schemaToOptions(this.schema!)); Object.keys(unknownOptions).forEach(key => { - if (typeof options[key] === 'boolean') { + if (typeof (options as any)[key] === 'boolean') { throw `Specify value for the ${key} property`; } - filters.push(`startsWith(${key}, '${formatting.encodeQueryParameter(options[key].toString())}')`); + filters.push(`startsWith(${key}, '${formatting.encodeQueryParameter((options as any)[key].toString())}')`); }); if (options.type) { diff --git a/src/m365/entra/commands/user/user-password-validate.spec.ts b/src/m365/entra/commands/user/user-password-validate.spec.ts index dc0eee53ab5..36b60c16cc9 100644 --- a/src/m365/entra/commands/user/user-password-validate.spec.ts +++ b/src/m365/entra/commands/user/user-password-validate.spec.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; 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'; @@ -9,12 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-password-validate.js'; +import command, { options } from './user-password-validate.js'; describe(commands.USER_PASSWORD_VALIDATE, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -22,6 +26,8 @@ describe(commands.USER_PASSWORD_VALIDATE, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -80,7 +86,7 @@ describe(commands.USER_PASSWORD_VALIDATE, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await command.action(logger, { options: { password: 'cli365' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ password: 'cli365' }) }); assert(loggerLogSpy.calledWith({ "isValid": false, "validationResults": [ @@ -114,7 +120,7 @@ describe(commands.USER_PASSWORD_VALIDATE, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await command.action(logger, { options: { password: 'cli365password' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ password: 'cli365password' }) }); assert(loggerLogSpy.calledWith({ "isValid": false, "validationResults": [ @@ -148,7 +154,7 @@ describe(commands.USER_PASSWORD_VALIDATE, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await command.action(logger, { options: { password: 'MyP@ssW0rd' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ password: 'MyP@ssW0rd' }) }); assert(loggerLogSpy.calledWith({ "isValid": false, "validationResults": [ @@ -182,7 +188,7 @@ describe(commands.USER_PASSWORD_VALIDATE, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await command.action(logger, { options: { password: 'cli365P@ssW0rd' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ password: 'cli365P@ssW0rd' }) }); assert(loggerLogSpy.calledWith({ "isValid": true, "validationResults": [ @@ -207,7 +213,7 @@ describe(commands.USER_PASSWORD_VALIDATE, () => { } }); - await assert.rejects(command.action(logger, { options: { password: 'cli365P@ssW0rd079654' } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ password: 'cli365P@ssW0rd079654' }) }), new CommandError(`An error has occurred`)); }); }); diff --git a/src/m365/entra/commands/user/user-password-validate.ts b/src/m365/entra/commands/user/user-password-validate.ts index ed88f2d94b1..b312c6d4415 100644 --- a/src/m365/entra/commands/user/user-password-validate.ts +++ b/src/m365/entra/commands/user/user-password-validate.ts @@ -1,17 +1,21 @@ +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 GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + password: z.string().alias('p') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - password: string; -} - class EntraUserPasswordValidateCommand extends GraphCommand { public get name(): string { return commands.USER_PASSWORD_VALIDATE; @@ -21,18 +25,8 @@ class EntraUserPasswordValidateCommand extends GraphCommand { return "Check a user's password against the organization's password validation policy"; } - constructor() { - super(); - - this.#initOptions(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-p, --password ' - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/user/user-recyclebinitem-clear.spec.ts b/src/m365/entra/commands/user/user-recyclebinitem-clear.spec.ts index 52876df2226..09477698bc5 100644 --- a/src/m365/entra/commands/user/user-recyclebinitem-clear.spec.ts +++ b/src/m365/entra/commands/user/user-recyclebinitem-clear.spec.ts @@ -2,6 +2,7 @@ import assert from 'assert'; import sinon from 'sinon'; 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'; @@ -11,12 +12,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-recyclebinitem-clear.js'; +import command, { options } from './user-recyclebinitem-clear.js'; describe(commands.USER_RECYCLEBINITEM_CLEAR, () => { let log: string[]; let logger: Logger; let promptIssued: boolean = false; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const deletedUsersResponse = [{ id: '4c099956-ca9a-4e60-ad5f-3f8447122706' }]; const graphGetUrl = 'https://graph.microsoft.com/v1.0/directory/deletedItems/microsoft.graph.user?$select=id'; @@ -28,6 +31,8 @@ describe(commands.USER_RECYCLEBINITEM_CLEAR, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -94,7 +99,7 @@ describe(commands.USER_RECYCLEBINITEM_CLEAR, () => { throw 'Invalid request'; }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(amountOfBatches, 1); }); @@ -119,12 +124,12 @@ describe(commands.USER_RECYCLEBINITEM_CLEAR, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { force: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ force: true, verbose: true }) }); assert.strictEqual(amountOfBatches, 3); }); it('prompts before removing users', async () => { - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(promptIssued); }); @@ -135,7 +140,7 @@ describe(commands.USER_RECYCLEBINITEM_CLEAR, () => { return; }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(postStub.notCalled); }); @@ -153,7 +158,7 @@ describe(commands.USER_RECYCLEBINITEM_CLEAR, () => { } }); - await assert.rejects(command.action(logger, { options: { force: true } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ force: true }) }), new CommandError('An error has occurred while processing this request.')); }); }); diff --git a/src/m365/entra/commands/user/user-recyclebinitem-clear.ts b/src/m365/entra/commands/user/user-recyclebinitem-clear.ts index 91a6bc37006..caeb18b332e 100644 --- a/src/m365/entra/commands/user/user-recyclebinitem-clear.ts +++ b/src/m365/entra/commands/user/user-recyclebinitem-clear.ts @@ -1,20 +1,24 @@ import { User } 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 { odata } from '../../../../utils/odata.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - force?: boolean; -} - class EntraUserRecycleBinItemClearCommand extends GraphCommand { public get name(): string { return commands.USER_RECYCLEBINITEM_CLEAR; @@ -24,27 +28,8 @@ class EntraUserRecycleBinItemClearCommand extends GraphCommand { return 'Removes all users from the tenant recycle bin'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: !!args.options.force - }); - }); - } - - #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/user/user-recyclebinitem-remove.spec.ts b/src/m365/entra/commands/user/user-recyclebinitem-remove.spec.ts index 3d8602e56b9..796ec04b64f 100644 --- a/src/m365/entra/commands/user/user-recyclebinitem-remove.spec.ts +++ b/src/m365/entra/commands/user/user-recyclebinitem-remove.spec.ts @@ -11,7 +11,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-recyclebinitem-remove.js'; +import command, { options } from './user-recyclebinitem-remove.js'; describe(commands.USER_RECYCLEBINITEM_REMOVE, () => { const validUserId = 'd839826a-81bf-4c38-8f80-f150d11ce6c7'; @@ -20,6 +20,7 @@ describe(commands.USER_RECYCLEBINITEM_REMOVE, () => { let logger: Logger; let promptIssued: boolean = false; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -28,6 +29,7 @@ describe(commands.USER_RECYCLEBINITEM_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -83,7 +85,7 @@ describe(commands.USER_RECYCLEBINITEM_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: validUserId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: validUserId, verbose: true }) }); assert(deleteStub.called); }); @@ -95,12 +97,12 @@ describe(commands.USER_RECYCLEBINITEM_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: validUserId, force: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: validUserId, force: true, verbose: true }) }); assert(deleteStub.called); }); it('prompts before removing user', async () => { - await command.action(logger, { options: { id: validUserId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: validUserId }) }); assert(promptIssued); }); @@ -109,7 +111,7 @@ describe(commands.USER_RECYCLEBINITEM_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(false); const deleteStub = sinon.stub(request, 'delete').resolves(); - await command.action(logger, { options: { id: validUserId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: validUserId }) }); assert(deleteStub.notCalled); }); @@ -127,17 +129,17 @@ describe(commands.USER_RECYCLEBINITEM_REMOVE, () => { } }); - await assert.rejects(command.action(logger, { options: { force: true, id: validUserId } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ force: true, id: validUserId }) }), new CommandError(`Resource '${validUserId}' does not exist or one of its queried reference-property objects are not present.`)); }); - it('fails validation if id is not a valid GUID', async () => { - const actual = await command.validate({ options: { id: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if id is a valid GUID', async () => { - const actual = await command.validate({ options: { id: validUserId } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if id is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: validUserId }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/entra/commands/user/user-recyclebinitem-remove.ts b/src/m365/entra/commands/user/user-recyclebinitem-remove.ts index 191c3ec74f7..f4f91a1b0fb 100644 --- a/src/m365/entra/commands/user/user-recyclebinitem-remove.ts +++ b/src/m365/entra/commands/user/user-recyclebinitem-remove.ts @@ -1,20 +1,23 @@ +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 { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.uuid(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; - force?: boolean; -} - class EntraUserRecycleBinItemRemoveCommand extends GraphCommand { public get name(): string { return commands.USER_RECYCLEBINITEM_REMOVE; @@ -24,43 +27,8 @@ class EntraUserRecycleBinItemRemoveCommand extends GraphCommand { return 'Removes a user from the recycle bin in the current tenant'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--id ' - }, - { - 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; - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/user/user-recyclebinitem-restore.spec.ts b/src/m365/entra/commands/user/user-recyclebinitem-restore.spec.ts index dba24067764..cbb229380f9 100644 --- a/src/m365/entra/commands/user/user-recyclebinitem-restore.spec.ts +++ b/src/m365/entra/commands/user/user-recyclebinitem-restore.spec.ts @@ -11,7 +11,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-recyclebinitem-restore.js'; +import command, { options } from './user-recyclebinitem-restore.js'; describe(commands.USER_RECYCLEBINITEM_RESTORE, () => { const validUserId = 'd839826a-81bf-4c38-8f80-f150d11ce6c7'; @@ -35,6 +35,7 @@ describe(commands.USER_RECYCLEBINITEM_RESTORE, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -43,6 +44,7 @@ describe(commands.USER_RECYCLEBINITEM_RESTORE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -89,7 +91,7 @@ describe(commands.USER_RECYCLEBINITEM_RESTORE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: validUserId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ id: validUserId, verbose: true }) }); assert(loggerLogSpy.calledWith(userResponse)); }); @@ -107,17 +109,17 @@ describe(commands.USER_RECYCLEBINITEM_RESTORE, () => { } }); - await assert.rejects(command.action(logger, { options: { id: validUserId } } as any), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: validUserId }) }), new CommandError(`Resource '${validUserId}' does not exist or one of its queried reference-property objects are not present.`)); }); - it('fails validation if id is not a valid GUID', async () => { - const actual = await command.validate({ options: { id: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: 'invalid' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if id is a valid GUID', async () => { - const actual = await command.validate({ options: { id: validUserId } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if id is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: validUserId }); + assert.strictEqual(actual.success, true); }); }); \ No newline at end of file diff --git a/src/m365/entra/commands/user/user-recyclebinitem-restore.ts b/src/m365/entra/commands/user/user-recyclebinitem-restore.ts index 52e392cfd98..86f94f43343 100644 --- a/src/m365/entra/commands/user/user-recyclebinitem-restore.ts +++ b/src/m365/entra/commands/user/user-recyclebinitem-restore.ts @@ -1,19 +1,22 @@ 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 request, { CliRequestOptions } from '../../../../request.js'; -import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.uuid() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; -} - class EntraUserRecycleBinItemRestoreCommand extends GraphCommand { public get name(): string { return commands.USER_RECYCLEBINITEM_RESTORE; @@ -23,31 +26,8 @@ class EntraUserRecycleBinItemRestoreCommand extends GraphCommand { return 'Restores a user from the tenant recycle bin'; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--id ' - } - ); - } - - #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; - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/user/user-registrationdetails-list.spec.ts b/src/m365/entra/commands/user/user-registrationdetails-list.spec.ts index 8ec4876ed63..bbb7fa00836 100644 --- a/src/m365/entra/commands/user/user-registrationdetails-list.spec.ts +++ b/src/m365/entra/commands/user/user-registrationdetails-list.spec.ts @@ -3,7 +3,7 @@ import sinon from 'sinon'; import auth from '../../../../Auth.js'; import commands from '../../commands.js'; import request from '../../../../request.js'; -import command from './user-registrationdetails-list.js'; +import command, { options } from './user-registrationdetails-list.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; @@ -18,66 +18,66 @@ import { formatting } from '../../../../utils/formatting.js'; describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { const registrationDetails = [ { - "id": "61b0c52f-a902-4769-9a09-c6628335b00a", - "userPrincipalName": "AdeleV@contoso.onmicrosoft.com", - "userDisplayName": "Adele Vance", - "userType": "member", - "isAdmin": false, - "isSsprRegistered": false, - "isSsprEnabled": false, - "isSsprCapable": false, - "isMfaRegistered": false, - "isMfaCapable": false, - "isPasswordlessCapable": false, - "methodsRegistered": [], - "isSystemPreferredAuthenticationMethodEnabled": false, - "systemPreferredAuthenticationMethods": [], - "userPreferredMethodForSecondaryAuthentication": "none", - "lastUpdatedDateTime": "2024-01-11T11:38:04.5006379Z" + 'id': '61b0c52f-a902-4769-9a09-c6628335b00a', + 'userPrincipalName': 'AdeleV@contoso.onmicrosoft.com', + 'userDisplayName': 'Adele Vance', + 'userType': 'member', + 'isAdmin': false, + 'isSsprRegistered': false, + 'isSsprEnabled': false, + 'isSsprCapable': false, + 'isMfaRegistered': false, + 'isMfaCapable': false, + 'isPasswordlessCapable': false, + 'methodsRegistered': [], + 'isSystemPreferredAuthenticationMethodEnabled': false, + 'systemPreferredAuthenticationMethods': [], + 'userPreferredMethodForSecondaryAuthentication': 'none', + 'lastUpdatedDateTime': '2024-01-11T11:38:04.5006379Z' }, { - "id": "f9e0ee63-73dc-48a9-aa97-e5159ec11705", - "userPrincipalName": "JohannaL@contoso.onmicrosoft.com", - "userDisplayName": "Johanna Lorenz", - "userType": "member", - "isAdmin": false, - "isSsprRegistered": false, - "isSsprEnabled": false, - "isSsprCapable": false, - "isMfaRegistered": true, - "isMfaCapable": true, - "isPasswordlessCapable": false, - "methodsRegistered": [ - "microsoftAuthenticatorPush", - "softwareOneTimePasscode" + 'id': 'f9e0ee63-73dc-48a9-aa97-e5159ec11705', + 'userPrincipalName': 'JohannaL@contoso.onmicrosoft.com', + 'userDisplayName': 'Johanna Lorenz', + 'userType': 'member', + 'isAdmin': false, + 'isSsprRegistered': false, + 'isSsprEnabled': false, + 'isSsprCapable': false, + 'isMfaRegistered': true, + 'isMfaCapable': true, + 'isPasswordlessCapable': false, + 'methodsRegistered': [ + 'microsoftAuthenticatorPush', + 'softwareOneTimePasscode' ], - "isSystemPreferredAuthenticationMethodEnabled": false, - "systemPreferredAuthenticationMethods": [], - "userPreferredMethodForSecondaryAuthentication": "push", - "lastUpdatedDateTime": "2024-01-11T11:38:04.5053823Z" + 'isSystemPreferredAuthenticationMethodEnabled': false, + 'systemPreferredAuthenticationMethods': [], + 'userPreferredMethodForSecondaryAuthentication': 'push', + 'lastUpdatedDateTime': '2024-01-11T11:38:04.5053823Z' }, { - "id": "abcd1234-e024-4bc6-8e98-123458962525", - "userPrincipalName": "JohnDoe@contoso.onmicrosoft.com", - "userDisplayName": "John Doe", - "userType": "member", - "isAdmin": true, - "isSsprRegistered": true, - "isSsprEnabled": true, - "isSsprCapable": true, - "isMfaRegistered": true, - "isMfaCapable": true, - "isPasswordlessCapable": false, - "methodsRegistered": [ - "email", - "mobilePhone", - "microsoftAuthenticatorPush", - "softwareOneTimePasscode" + 'id': 'abcd1234-e024-4bc6-8e98-123458962525', + 'userPrincipalName': 'JohnDoe@contoso.onmicrosoft.com', + 'userDisplayName': 'John Doe', + 'userType': 'member', + 'isAdmin': true, + 'isSsprRegistered': true, + 'isSsprEnabled': true, + 'isSsprCapable': true, + 'isMfaRegistered': true, + 'isMfaCapable': true, + 'isPasswordlessCapable': false, + 'methodsRegistered': [ + 'email', + 'mobilePhone', + 'microsoftAuthenticatorPush', + 'softwareOneTimePasscode' ], - "isSystemPreferredAuthenticationMethodEnabled": false, - "systemPreferredAuthenticationMethods": [], - "userPreferredMethodForSecondaryAuthentication": "push", - "lastUpdatedDateTime": "2024-01-11T11:38:04.5040399Z" + 'isSystemPreferredAuthenticationMethodEnabled': false, + 'systemPreferredAuthenticationMethods': [], + 'userPreferredMethodForSecondaryAuthentication': 'push', + 'lastUpdatedDateTime': '2024-01-11T11:38:04.5040399Z' } ]; @@ -85,6 +85,7 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -93,6 +94,7 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -114,7 +116,6 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { afterEach(() => { sinonUtil.restore([ request.get, - cli.getSettingWithDefaultValue, entraUser.getUpnByUserId ]); }); @@ -136,58 +137,55 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['userPrincipalName', 'methodsRegistered', 'lastUpdatedDateTime']); }); - it('fails validation if userType contains invalid value', async () => { - const actual = await command.validate({ options: { userType: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userType contains invalid value', () => { + const actual = commandOptionsSchema.safeParse({ userType: 'foo' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if userPreferredMethodForSecondaryAuthentication contains invalid value', async () => { - const actual = await command.validate({ options: { userPreferredMethodForSecondaryAuthentication: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userPreferredMethodForSecondaryAuthentication contains invalid value', () => { + const actual = commandOptionsSchema.safeParse({ userPreferredMethodForSecondaryAuthentication: 'foo' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if systemPreferredAuthenticationMethods contains invalid value', async () => { - const actual = await command.validate({ options: { systemPreferredAuthenticationMethods: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if systemPreferredAuthenticationMethods contains invalid value', () => { + const actual = commandOptionsSchema.safeParse({ systemPreferredAuthenticationMethods: 'foo' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if methodsRegistered contains invalid value', async () => { - const actual = await command.validate({ options: { methodsRegistered: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if methodsRegistered contains invalid value', () => { + const actual = commandOptionsSchema.safeParse({ methodsRegistered: 'foo' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if userIds contains invalid GUID', async () => { + it('fails validation if userIds contains invalid GUID', () => { const userIds = ['7167b488-1ffb-43f1-9547-35969469bada', 'foo']; - const actual = await command.validate({ options: { userIds: userIds.join(',') } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userIds: userIds.join(',') }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if userPrincipalNames contains invalid user principal name', async () => { + it('fails validation if userPrincipalNames contains invalid user principal name', () => { const userPrincipalNames = ['john.doe@contoso.com', 'foo']; - const actual = await command.validate({ options: { userPrincipalNames: userPrincipalNames.join(',') } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userPrincipalNames: userPrincipalNames.join(',') }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if all optional parameters are valid', async () => { + it('passes validation if all optional parameters are valid', () => { const userIds = ['7167b488-1ffb-43f1-9547-35969469bada', '6dcd4ce0-4f89-11d3-9a0c-0305e82c3302']; const userPrincipalNames = ['john.doe@contoso.com', 'adele.vance@contoso.com']; - const actual = await command.validate({ - options: - { - userType: 'guest', - userPreferredMethodForSecondaryAuthentication: 'push', - systemPreferredAuthenticationMethods: 'push', - methodsRegistered: 'microsoftAuthenticatorPush', - userIds: userIds.join(','), - userPrincipalNames: userPrincipalNames.join(',') - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + userType: 'guest', + userPreferredMethodForSecondaryAuthentication: 'push', + systemPreferredAuthenticationMethods: 'push', + methodsRegistered: 'microsoftAuthenticatorPush', + userIds: userIds.join(','), + userPrincipalNames: userPrincipalNames.join(',') + }); + assert.strictEqual(actual.success, true); }); it('should get a list of user registration details', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails`) { + if (opts.url === 'https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails') { return { value: registrationDetails }; @@ -196,14 +194,14 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert(loggerLogSpy.calledWith(registrationDetails)); }); it('should get a list of user registration details with selected properties', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails?$select=id,userPrincipalName,methodsRegistered`) { + if (opts.url === 'https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails?$select=id,userPrincipalName,methodsRegistered') { return { value: registrationDetails }; @@ -212,7 +210,7 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { properties: 'id,userPrincipalName,methodsRegistered' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ properties: 'id,userPrincipalName,methodsRegistered' }) }); assert(loggerLogSpy.calledWith(registrationDetails)); }); @@ -246,7 +244,7 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ isAdmin: true, userType: 'member', userPreferredMethodForSecondaryAuthentication: 'oath, voiceMobile,push', @@ -261,7 +259,7 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { methodsRegistered: 'fido2, microsoftAuthenticatorPush', userIds: '7167b488-1ffb-43f1-9547-35969469bada, 6dcd4ce0-4f89-11d3-9a0c-0305e82c3302', userPrincipalNames: 'john.doe@contoso.com, adele.vance@contoso.com' - } + }) }); assert(loggerLogSpy.calledWith(registrationDetails)); @@ -281,7 +279,7 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ isAdmin: false, isSelfServicePasswordResetCapable: false, isSelfServicePasswordResetEnabled: false, @@ -290,7 +288,7 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { isMfaRegistered: false, isPasswordlessCapable: false, isSystemPreferredAuthenticationMethodEnabled: false - } + }) }); assert(loggerLogSpy.calledWith(registrationDetails)); @@ -308,7 +306,7 @@ describe(commands.USER_REGISTRATIONDETAILS_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: {} }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError('Invalid request')); }); -}); \ No newline at end of file +}); diff --git a/src/m365/entra/commands/user/user-registrationdetails-list.ts b/src/m365/entra/commands/user/user-registrationdetails-list.ts index 9d8b8507c1c..3ba4e98fc61 100644 --- a/src/m365/entra/commands/user/user-registrationdetails-list.ts +++ b/src/m365/entra/commands/user/user-registrationdetails-list.ts @@ -1,5 +1,6 @@ -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { odata } from '../../../../utils/odata.js'; @@ -8,31 +9,53 @@ import { entraUser } from '../../../../utils/entraUser.js'; import { validation } from '../../../../utils/validation.js'; import { formatting } from '../../../../utils/formatting.js'; +const authenticationMethodValues = ['push', 'oath', 'voiceMobile', 'voiceAlternateMobile', 'voiceOffice', 'sms', 'none'] as const; +const methodsRegisteredValues = ['mobilePhone', 'email', 'fido2', 'microsoftAuthenticatorPush', 'softwareOneTimePasscode'] as const; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + isAdmin: z.boolean().optional(), + userType: z.enum(['member', 'guest']).optional(), + userPreferredMethodForSecondaryAuthentication: z.string().refine(val => { + const methods = val.split(',').map(m => m.trim()); + return methods.every(m => (authenticationMethodValues as readonly string[]).includes(m)); + }, { + error: e => `'${e.input}' is not a valid userPreferredMethodForSecondaryAuthentication value. Allowed values ${authenticationMethodValues.join(', ')}.` + }).optional(), + systemPreferredAuthenticationMethods: z.string().refine(val => { + const methods = val.split(',').map(m => m.trim()); + return methods.every(m => (authenticationMethodValues as readonly string[]).includes(m)); + }, { + error: e => `'${e.input}' is not a valid systemPreferredAuthenticationMethods value. Allowed values ${authenticationMethodValues.join(', ')}.` + }).optional(), + isSelfServicePasswordResetRegistered: z.boolean().optional(), + isSelfServicePasswordResetEnabled: z.boolean().optional(), + isSelfServicePasswordResetCapable: z.boolean().optional(), + isMfaRegistered: z.boolean().optional(), + isMfaCapable: z.boolean().optional(), + isPasswordlessCapable: z.boolean().optional(), + isSystemPreferredAuthenticationMethodEnabled: z.boolean().optional(), + methodsRegistered: z.string().refine(val => { + const methods = val.split(',').map(m => m.trim()); + return methods.every(m => (methodsRegisteredValues as readonly string[]).includes(m)); + }, { + error: e => `'${e.input}' is not a valid methodsRegistered value. Allowed values ${methodsRegisteredValues.join(', ')}.` + }).optional(), + userIds: z.string().refine(val => validation.isValidGuidArray(val) === true, { + error: e => `The following GUIDs are invalid for the option 'userIds': ${validation.isValidGuidArray(e.input as string)}.` + }).optional(), + userPrincipalNames: z.string().refine(val => validation.isValidUserPrincipalNameArray(val) === true, { + error: e => `The following user principal names are invalid for the option 'userPrincipalNames': ${validation.isValidUserPrincipalNameArray(e.input as string)}.` + }).optional(), + properties: z.string().optional().alias('p') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - isAdmin?: boolean; - userType?: string; - userPreferredMethodForSecondaryAuthentication?: string; - systemPreferredAuthenticationMethods?: string; - isSelfServicePasswordResetRegistered?: boolean; - isSelfServicePasswordResetEnabled?: boolean; - isSelfServicePasswordResetCapable?: boolean; - isMfaRegistered?: boolean; - isMfaCapable?: boolean; - isPasswordlessCapable?: boolean; - isSystemPreferredAuthenticationMethodEnabled?: boolean; - methodsRegistered?: string; - userIds?: string; - userPrincipalNames?: string; - properties?: string; -} - -const authenticationMethods = ['push', 'oath', 'voiceMobile', 'voiceAlternateMobile', 'voiceOffice', 'sms', 'none']; -const methodsRegistered = ['mobilePhone', 'email', 'fido2', 'microsoftAuthenticatorPush', 'softwareOneTimePasscode']; - class EntraUserRegistrationDetailsListCommand extends GraphCommand { public get name(): string { return commands.USER_REGISTRATIONDETAILS_LIST; @@ -45,140 +68,8 @@ class EntraUserRegistrationDetailsListCommand extends GraphCommand { return ['userPrincipalName', 'methodsRegistered', 'lastUpdatedDateTime']; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - isAdmin: !!args.options.isAdmin, - userType: typeof args.options.userType !== 'undefined', - userPreferredMethodForSecondaryAuthentication: typeof args.options.userPreferredMethodForSecondaryAuthentication !== 'undefined', - systemPreferredAuthenticationMethods: typeof args.options.systemPreferredAuthenticationMethods !== 'undefined', - isSelfServicePasswordResetRegistered: !!args.options.isSelfServicePasswordResetRegistered, - isSelfServicePasswordResetEnabled: !!args.options.isSelfServicePasswordResetEnabled, - isSelfServicePasswordResetCapable: !!args.options.isSelfServicePasswordResetCapable, - isMfaRegistered: !!args.options.isMfaRegistered, - isMfaCapable: !!args.options.isMfaCapable, - isPasswordlessCapable: !!args.options.isPasswordlessCapable, - isSystemPreferredAuthenticationMethodEnabled: !!args.options.isSystemPreferredAuthenticationMethodEnabled, - methodsRegistered: typeof args.options.methodsRegistered !== 'undefined', - userIds: typeof args.options.userIds !== 'undefined', - userPrincipalNames: typeof args.options.userPrincipalNames !== 'undefined', - properties: typeof args.options.properties !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--isAdmin [isAdmin]' - }, - { - option: '--userType [userType]', - autocomplete: ['member', 'guest'] - }, - { - option: '--userPreferredMethodForSecondaryAuthentication [userPreferredMethodForSecondaryAuthentication ]', - autocomplete: authenticationMethods - }, - { - option: '--systemPreferredAuthenticationMethods [systemPreferredAuthenticationMethods ]', - autocomplete: authenticationMethods - }, - { - option: '--isSelfServicePasswordResetRegistered [isSelfServicePasswordResetRegistered]' - }, - { - option: '--isSelfServicePasswordResetEnabled [isSelfServicePasswordResetEnabled]' - }, - { - option: '--isSelfServicePasswordResetCapable [isSelfServicePasswordResetCapable]' - }, - { - option: '--isMfaRegistered [isMfaRegistered]' - }, - { - option: '--isMfaCapable [isMfaCapable]' - }, - { - option: '--isPasswordlessCapable [isPasswordlessCapable]' - }, - { - option: '--isSystemPreferredAuthenticationMethodEnabled [isSystemPreferredAuthenticationMethodEnabled]' - }, - { - option: '--methodsRegistered [methodsRegistered]', - autocomplete: methodsRegistered - }, - { - option: '--userIds [userIds]' - }, - { - option: '--userPrincipalNames [userPrincipalNames]' - }, - { - option: '-p, --properties [properties]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userType) { - if (['member', 'guest'].every(type => type !== args.options.userType)) { - return `'${args.options.userType}' is not a valid userType value. Allowed values member, guest`; - } - } - - if (args.options.userPreferredMethodForSecondaryAuthentication) { - const methods = args.options.userPreferredMethodForSecondaryAuthentication.split(',').map(m => m.trim()); - const invalidMethods = methods.filter(m => !authenticationMethods.includes(m)); - if (invalidMethods.length > 0) { - return `'${args.options.userPreferredMethodForSecondaryAuthentication}' is not a valid userPreferredMethodForSecondaryAuthentication value. Invalid values: ${invalidMethods.join(',')}. Allowed values ${authenticationMethods.join(', ')}`; - } - } - - if (args.options.systemPreferredAuthenticationMethods) { - const methods = args.options.systemPreferredAuthenticationMethods.split(',').map(m => m.trim()); - const invalidMethods = methods.filter(m => !authenticationMethods.includes(m)); - if (invalidMethods.length > 0) { - return `'${args.options.systemPreferredAuthenticationMethods}' is not a valid systemPreferredAuthenticationMethods value. Invalid values: ${invalidMethods.join(',')}. Allowed values ${authenticationMethods.join(', ')}`; - } - } - - if (args.options.methodsRegistered) { - const methods = args.options.methodsRegistered.split(',').map(m => m.trim()); - const invalidMethods = methods.filter(m => !methodsRegistered.includes(m)); - if (invalidMethods.length > 0) { - return `'${args.options.methodsRegistered}' is not a valid methodsRegistered value. Invalid values: ${invalidMethods.join(',')}. Allowed values ${methodsRegistered.join(', ')}`; - } - } - - if (args.options.userIds) { - const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.userIds); - if (isValidGUIDArrayResult !== true) { - return `The following GUIDs are invalid for the option 'userIds': ${isValidGUIDArrayResult}.`; - } - } - - if (args.options.userPrincipalNames) { - const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.userPrincipalNames); - if (isValidUPNArrayResult !== true) { - return `The following user principal names are invalid for the option 'userPrincipalNames': ${isValidUPNArrayResult}.`; - } - } - - return true; - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/user/user-remove.spec.ts b/src/m365/entra/commands/user/user-remove.spec.ts index 73fbd5fbe15..d20f2e0d1ec 100644 --- a/src/m365/entra/commands/user/user-remove.spec.ts +++ b/src/m365/entra/commands/user/user-remove.spec.ts @@ -11,10 +11,11 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-remove.js'; +import command, { options } from './user-remove.js'; describe(commands.USER_REMOVE, () => { let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; //#region Mocked Responses const validId = '3a081d91-5ea8-40a7-8ac9-abbaa3fcb893'; const validUsername = 'john.doe@contoso.com'; @@ -31,6 +32,7 @@ describe(commands.USER_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -74,39 +76,35 @@ describe(commands.USER_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if id is not a valid guid.', async () => { - const actual = await command.validate({ - options: { - id: 'Invalid GUID' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if id is not a valid guid.', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'Invalid GUID' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation when userName is not a valid upn', async () => { - const actual = await command.validate({ - options: { - userName: 'Invalid upn' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when userName is not a valid upn', () => { + const actual = commandOptionsSchema.safeParse({ + userName: 'Invalid upn' + }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if required options specified (userId)', async () => { - const actual = await command.validate({ options: { id: validId } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (userId)', () => { + const actual = commandOptionsSchema.safeParse({ id: validId }); + assert.strictEqual(actual.success, true); }); - it('passes validation if required options specified (userName)', async () => { - const actual = await command.validate({ options: { userName: validUsername } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (userName)', () => { + const actual = commandOptionsSchema.safeParse({ userName: validUsername }); + assert.strictEqual(actual.success, true); }); it('prompts before removing the specified user when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validId - } + }) }); assert(promptIssued); @@ -116,9 +114,9 @@ describe(commands.USER_REMOVE, () => { const deleteStub = sinon.stub(request, 'delete').resolves(); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: validId - } + }) }); assert(deleteStub.notCalled); }); @@ -136,10 +134,10 @@ describe(commands.USER_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, id: validId - } + }) }); assert(deleteStub.called); }); @@ -157,10 +155,10 @@ describe(commands.USER_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, userName: validUsername - } + }) }); assert(deleteStub.called); }); @@ -175,11 +173,11 @@ describe(commands.USER_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, userName: validUsername, force: true - } + }) }); assert(deleteStub.called); }); @@ -194,11 +192,11 @@ describe(commands.USER_REMOVE, () => { sinon.stub(request, 'delete').rejects(error); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, id: validId, force: true - } + }) }), new CommandError(error.error.message)); }); }); diff --git a/src/m365/entra/commands/user/user-remove.ts b/src/m365/entra/commands/user/user-remove.ts index 76025701f98..85b2bc85167 100644 --- a/src/m365/entra/commands/user/user-remove.ts +++ b/src/m365/entra/commands/user/user-remove.ts @@ -1,21 +1,27 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import commands from '../../commands.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import { cli } from '../../../../cli/cli.js'; import GraphCommand from '../../../base/GraphCommand.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.uuid().optional(), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid user principal name (UPN).` + }).optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - userName?: string; - force?: boolean; -} - class EntraUserRemoveCommand extends GraphCommand { public get name(): string { @@ -26,62 +32,22 @@ class EntraUserRemoveCommand extends GraphCommand { return 'Removes a specific user'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--id [id]' - }, - { - option: '--userName [userName]' - }, - { - option: '-f, --force' - } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initOptionSets(): void { - this.optionSets.push( - { options: ['id', 'userName'] } - ); - } - - #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`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.id, options.userName].filter(o => o !== undefined).length === 1, { + error: `Specify either 'id' or 'userName'.`, + params: { + customCode: 'optionSet', + options: ['id', 'userName'] } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid user principal name (UPN)`; - } - - return true; - } - ); + }); } - public async commandAction(logger: Logger, args: any): Promise { + public async commandAction(logger: Logger, args: CommandArgs): Promise { if (this.verbose) { await logger.logToStderr(`Removing user '${args.options.id || args.options.userName}'...`); } diff --git a/src/m365/entra/commands/user/user-set.spec.ts b/src/m365/entra/commands/user/user-set.spec.ts index 99c2fbc84c1..e81b44bf428 100644 --- a/src/m365/entra/commands/user/user-set.spec.ts +++ b/src/m365/entra/commands/user/user-set.spec.ts @@ -13,8 +13,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-set.js'; -import { settingsNames } from '../../../../settingsNames.js'; +import command, { options } from './user-set.js'; describe(commands.USER_SET, () => { const currentPassword = '9%9OLUg6p@Ra'; @@ -37,6 +36,7 @@ describe(commands.USER_SET, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -51,6 +51,7 @@ describe(commands.USER_SET, () => { }; } commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -75,9 +76,9 @@ describe(commands.USER_SET, () => { request.patch, request.post, request.put, + request.delete, accessToken.getUserNameFromAccessToken, - accessToken.getUserIdFromAccessToken, - cli.getSettingWithDefaultValue + accessToken.getUserIdFromAccessToken ]); }); @@ -98,133 +99,109 @@ describe(commands.USER_SET, () => { assert.strictEqual(command.allowUnknownOptions(), true); }); - it('fails validation if neither the id nor the userName are specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: {} }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if neither the id nor the userName are specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if both the id and the userName are specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { id: id, userName: userName } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if both the id and the userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ id: id, userName: userName }); + assert.notStrictEqual(actual.success, true); }); - 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); + it('fails validation if the id is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if currentPassword is set without newPassword', async () => { - const actual = await command.validate({ options: { id: id, currentPassword: currentPassword } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if currentPassword is set without newPassword', () => { + const actual = commandOptionsSchema.safeParse({ id: id, currentPassword: currentPassword }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if newPassword is set without currentPassword', async () => { - const actual = await command.validate({ options: { id: id, newPassword: newPassword } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if newPassword is set without currentPassword', () => { + const actual = commandOptionsSchema.safeParse({ id: id, newPassword: newPassword }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if resetPassword is set without a password', async () => { - const actual = await command.validate({ options: { id: id, resetPassword: true } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if resetPassword is set without a password', () => { + const actual = commandOptionsSchema.safeParse({ id: id, resetPassword: true }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if resetPassword and password is set and currentPassword is also set', async () => { - const actual = await command.validate({ options: { id: id, resetPassword: true, password: newPassword, currentPassword: currentPassword } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if resetPassword and password is set and currentPassword is also set', () => { + const actual = commandOptionsSchema.safeParse({ id: id, resetPassword: true, newPassword: newPassword, currentPassword: currentPassword }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation when userName has an invalid value', async () => { - const actual = await command.validate({ options: { userName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when userName has an invalid value', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation usageLocation is not a valid usageLocation', async () => { - const actual = await command.validate({ options: { displayName: displayName, id: id, usageLocation: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation usageLocation is not a valid usageLocation', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, id: id, usageLocation: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation preferredLanguage is not a valid preferredLanguage', async () => { - const actual = await command.validate({ options: { displayName: displayName, id: id, preferredLanguage: 'z' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation preferredLanguage is not a valid preferredLanguage', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, id: id, preferredLanguage: 'z' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if both managerUserId and managerUserName are specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { displayName: displayName, id: id, managerUserId: managerUserId, managerUserName: managerUserName } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if both managerUserId and managerUserName are specified', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, id: id, managerUserId: managerUserId, managerUserName: managerUserName }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if managerUserName is not a valid userName', async () => { - const actual = await command.validate({ options: { displayName: displayName, id: id, managerUserName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if managerUserName is not a valid userName', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, id: id, managerUserName: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if managerUserId is not a valid GUID', async () => { - const actual = await command.validate({ options: { displayName: displayName, id: id, managerUserId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if managerUserId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, id: id, managerUserId: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if firstName has more than 64 characters', async () => { - const actual = await command.validate({ options: { displayName: displayName, id: id, firstName: largeString } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if firstName has more than 64 characters', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, id: id, firstName: largeString }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if lastName has more than 64 characters', async () => { - const actual = await command.validate({ options: { displayName: displayName, id: id, lastName: largeString } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if lastName has more than 64 characters', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, id: id, lastName: largeString }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if jobTitle has more than 128 characters', async () => { - const actual = await command.validate({ options: { displayName: displayName, id: id, jobTitle: largeString + largeString } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if jobTitle has more than 128 characters', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, id: id, jobTitle: largeString + largeString }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if companyName has more than 64 characters', async () => { - const actual = await command.validate({ options: { displayName: displayName, id: id, companyName: largeString } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if companyName has more than 64 characters', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, id: id, companyName: largeString }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if department has more than 64 characters', async () => { - const actual = await command.validate({ options: { displayName: displayName, id: id, department: largeString } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if department has more than 64 characters', () => { + const actual = commandOptionsSchema.safeParse({ displayName: displayName, id: id, department: largeString }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if forceChangePasswordNextSignIn is set without resetPassword', async () => { - const actual = await command.validate({ options: { id: id, forceChangePasswordNextSignIn: true } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if forceChangePasswordNextSignIn is set without resetPassword', () => { + const actual = commandOptionsSchema.safeParse({ id: id, forceChangePasswordNextSignIn: true }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if forceChangePasswordNextSignInWithMfa is set without resetPassword', async () => { - const actual = await command.validate({ options: { id: id, forceChangePasswordNextSignInWithMfa: true } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if forceChangePasswordNextSignInWithMfa is set without resetPassword', () => { + const actual = commandOptionsSchema.safeParse({ id: id, forceChangePasswordNextSignInWithMfa: true }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if the id is a valid GUID', async () => { - const actual = await command.validate({ options: { id: id } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the id is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: id }); + assert.strictEqual(actual.success, true); }); it('allows unknown properties', () => { @@ -234,42 +211,42 @@ describe(commands.USER_SET, () => { it('throws error when id is not equal to current signed in id in Cli when passing both the options currentPassword and newPassword', async () => { sinon.stub(accessToken, 'getUserIdFromAccessToken').returns('7c47b08e-e7b3-427a-9eba-b679815148e9'); - await assert.rejects(command.action(logger, { options: { verbose: true, id: id, newPassword: newPassword, currentPassword: currentPassword } } as any), - new CommandError(`You can only change your own password. Please use --id @meId to reference to your own userId`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, id: id, newPassword: newPassword, currentPassword: currentPassword }) }), + new CommandError('You can only change your own password. Please use --id @meId to reference to your own userId')); }); it('throws error when userName is not equal to current signed in userName in Cli when passing both the options currentPassword and newPassword', async () => { sinon.stub(accessToken, 'getUserNameFromAccessToken').returns('john@contoso.com'); - await assert.rejects(command.action(logger, { options: { verbose: true, userName: userName, newPassword: newPassword, currentPassword: currentPassword } } as any), - new CommandError(`You can only change your own password. Please use --userName @meUserName to reference to your own user principal name`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userName: userName, newPassword: newPassword, currentPassword: currentPassword }) }), + new CommandError('You can only change your own password. Please use --userName @meUserName to reference to your own user principal name')); }); it('correctly handles user or property not found', async () => { sinon.stub(request, 'patch').rejects({ - "error": { - "code": "Request_ResourceNotFound", - "message": "Resource '1caf7dcd-7e83-4c3a-94f7-932a1299c844' does not exist or one of its queried reference-property objects are not present.", - "innerError": { - "request-id": "9b0df954-93b5-4de9-8b99-43c204a8aaf8", - "date": "2018-04-24T18:56:48" + error: { + code: 'Request_ResourceNotFound', + message: "Resource '1caf7dcd-7e83-4c3a-94f7-932a1299c844' does not exist or one of its queried reference-property objects are not present.", + innerError: { + 'request-id': '9b0df954-93b5-4de9-8b99-43c204a8aaf8', + date: '2018-04-24T18:56:48' } } }); - await assert.rejects(command.action(logger, { options: { verbose: true, id: id, NonExistingProperty: 'Value' } } as any), - new CommandError(`Resource '1caf7dcd-7e83-4c3a-94f7-932a1299c844' does not exist or one of its queried reference-property objects are not present.`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, id: id, NonExistingProperty: 'Value' }) }), + new CommandError(`Resource '${id}' does not exist or one of its queried reference-property objects are not present.`)); }); it('correctly updates information about the specified user', async () => { sinon.stub(request, 'patch').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/v1.0/users/`) > -1) { + if ((opts.url as string).indexOf('/v1.0/users/') > -1) { return; } throw 'Invalid request'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, id: id, Department: 'Sales & Marketing', @@ -282,21 +259,21 @@ describe(commands.USER_SET, () => { jobTitle: jobTitle, department: department, preferredLanguage: preferredLanguage - } - } as any); + }) + }); assert(loggerLogSpy.notCalled); }); it('correctly updates information about the specified user with unknown options', async () => { const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/v1.0/users/`) > -1) { + if ((opts.url as string).indexOf('/v1.0/users/') > -1) { return; } throw 'Invalid request'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, id: id, department: 'Sales & Marketing', @@ -310,8 +287,8 @@ describe(commands.USER_SET, () => { preferredLanguage: preferredLanguage, accountEnabled: false, extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' - } - } as any); + }) + }); assert.deepStrictEqual(patchStub.lastCall.args[0].data, { companyName: 'Contoso', department: 'Sales & Marketing', @@ -337,11 +314,11 @@ describe(commands.USER_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ id: id, companyName: '' - } - } as any); + }) + }); assert.strictEqual(patchStub.lastCall.args[0].data.companyName, null); }); @@ -359,15 +336,15 @@ describe(commands.USER_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, id: id, resetPassword: true, newPassword: newPassword, forceChangePasswordNextSignIn: true, forceChangePasswordNextSignInWithMfa: true - } - } as any); + }) + }); assert(loggerLogSpy.notCalled); }); @@ -383,13 +360,13 @@ describe(commands.USER_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, userName: userName, resetPassword: true, newPassword: newPassword - } - } as any); + }) + }); assert(loggerLogSpy.notCalled); }); @@ -406,13 +383,13 @@ describe(commands.USER_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, userName: userName, currentPassword: currentPassword, newPassword: newPassword - } - } as any); + }) + }); assert(loggerLogSpy.notCalled); }); @@ -429,31 +406,31 @@ describe(commands.USER_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, id: id, currentPassword: currentPassword, newPassword: newPassword - } - } as any); + }) + }); assert(loggerLogSpy.notCalled); }); it('correctly enables the specified user', async () => { sinon.stub(request, 'patch').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/v1.0/users/`) > -1) { + if ((opts.url as string).indexOf('/v1.0/users/') > -1) { return; } throw 'Invalid request'; }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, userName: userName, accountEnabled: true - } - } as any); + }) + }); assert(loggerLogSpy.notCalled); }); @@ -466,7 +443,7 @@ describe(commands.USER_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName, managerUserId: managerUserId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ userName: userName, managerUserId: managerUserId }) }); assert.deepEqual(putStub.lastCall.args[0].data, { '@odata.id': `https://graph.microsoft.com/v1.0/users/${managerUserId}` }); @@ -481,7 +458,7 @@ describe(commands.USER_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, userName: userName, managerUserName: managerUserName } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userName: userName, managerUserName: managerUserName }) }); assert.deepEqual(putStub.lastCall.args[0].data, { '@odata.id': `https://graph.microsoft.com/v1.0/users/${managerUserName}` }); @@ -496,7 +473,7 @@ describe(commands.USER_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, userName: userName, removeManager: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userName: userName, removeManager: true }) }); assert(deleteStub.called); }); }); diff --git a/src/m365/entra/commands/user/user-set.ts b/src/m365/entra/commands/user/user-set.ts index 677261a7477..1ced7a28d4c 100644 --- a/src/m365/entra/commands/user/user-set.ts +++ b/src/m365/entra/commands/user/user-set.ts @@ -1,6 +1,7 @@ +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { accessToken } from '../../../../utils/accessToken.js'; import { formatting } from '../../../../utils/formatting.js'; @@ -8,33 +9,40 @@ import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.looseObject({ + ...globalOptionsZod.shape, + id: z.uuid().optional().alias('i'), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid userName.` + }).optional().alias('n'), + accountEnabled: z.boolean().optional(), + resetPassword: z.boolean().optional(), + forceChangePasswordNextSignIn: z.boolean().optional(), + forceChangePasswordNextSignInWithMfa: z.boolean().optional(), + currentPassword: z.string().optional(), + newPassword: z.string().optional(), + displayName: z.string().optional(), + firstName: z.string().max(64, { error: `The max length for the firstName option is 64 characters.` }).optional(), + lastName: z.string().max(64, { error: `The max length for the lastName option is 64 characters.` }).optional(), + usageLocation: z.string().regex(/^[a-zA-Z]{2}$/, { error: e => `'${e.input}' is not a valid usageLocation.` }).optional(), + officeLocation: z.string().optional(), + jobTitle: z.string().max(128, { error: `The max length for the jobTitle option is 128 characters.` }).optional(), + companyName: z.string().max(64, { error: `The max length for the companyName option is 64 characters.` }).optional(), + department: z.string().max(64, { error: `The max length for the department option is 64 characters.` }).optional(), + preferredLanguage: z.string().min(2, { error: e => `'${e.input}' is not a valid preferredLanguage.` }).optional(), + managerUserId: z.uuid().optional(), + managerUserName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid user principal name.` + }).optional(), + removeManager: z.boolean().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - userName?: string; - accountEnabled?: boolean; - resetPassword?: boolean; - forceChangePasswordNextSignIn?: boolean; - forceChangePasswordNextSignInWithMfa?: boolean; - currentPassword?: string; - newPassword?: string; - displayName?: string; - firstName?: string; - lastName?: string; - usageLocation?: string; - officeLocation?: string; - jobTitle?: string; - companyName?: string; - department?: string; - preferredLanguage?: string; - managerUserId?: string; - managerUserName?: string; - removeManager?: boolean; -} - class EntraUserSetCommand extends GraphCommand { public get name(): string { return commands.USER_SET; @@ -48,199 +56,46 @@ class EntraUserSetCommand extends GraphCommand { return true; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initTypes(); - 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', - userName: typeof args.options.userName !== 'undefined', - accountEnabled: !!args.options.accountEnabled, - resetPassword: !!args.options.resetPassword, - forceChangePasswordNextSignIn: !!args.options.forceChangePasswordNextSignIn, - currentPassword: typeof args.options.currentPassword !== 'undefined', - newPassword: typeof args.options.newPassword !== 'undefined', - displayName: typeof args.options.displayName !== 'undefined', - firstName: typeof args.options.firstName !== 'undefined', - lastName: typeof args.options.lastName !== 'undefined', - forceChangePasswordNextSignInWithMfa: !!args.options.forceChangePasswordNextSignInWithMfa, - usageLocation: typeof args.options.usageLocation !== 'undefined', - officeLocation: typeof args.options.officeLocation !== 'undefined', - jobTitle: typeof args.options.jobTitle !== 'undefined', - companyName: typeof args.options.companyName !== 'undefined', - department: typeof args.options.department !== 'undefined', - preferredLanguage: typeof args.options.preferredLanguage !== 'undefined', - managerUserId: typeof args.options.managerUserId !== 'undefined', - managerUserName: typeof args.options.managerUserName !== 'undefined', - removeManager: typeof args.options.removeManager !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.id, options.userName].filter(o => o !== undefined).length === 1, { + error: `Specify either 'id' or 'userName'.`, + params: { + customCode: 'optionSet', + options: ['id', 'userName'] + } + }) + .refine(options => { + if (!options.managerUserId && !options.managerUserName && !options.removeManager) { + return true; + } + return [options.managerUserId, options.managerUserName, options.removeManager].filter(o => o !== undefined).length === 1; + }, { + error: `Specify either 'managerUserId', 'managerUserName', or 'removeManager'.`, + params: { + customCode: 'optionSet', + options: ['managerUserId', 'managerUserName', 'removeManager'] + } + }) + .refine(options => !(!options.resetPassword && ((options.currentPassword && !options.newPassword) || (options.newPassword && !options.currentPassword))), { + error: `Specify both currentPassword and newPassword when you want to change your password.` + }) + .refine(options => !(options.resetPassword && options.currentPassword), { + error: `When resetting a user's password, don't specify the current password.` + }) + .refine(options => !(options.resetPassword && !options.newPassword), { + error: `When resetting a user's password, specify the new password to set for the user, using the newPassword option.` + }) + .refine(options => !(options.forceChangePasswordNextSignIn && !options.resetPassword), { + error: `The option forceChangePasswordNextSignIn can only be used in combination with the resetPassword option.` + }) + .refine(options => !(options.forceChangePasswordNextSignInWithMfa && !options.resetPassword), { + error: `The option forceChangePasswordNextSignInWithMfa can only be used in combination with the resetPassword option.` }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id [id]' - }, - { - option: '-n, --userName [userName]' - }, - { - option: '--accountEnabled [accountEnabled]', - autocomplete: ['true', 'false'] - }, - { - option: '--resetPassword' - }, - { - option: '--forceChangePasswordNextSignIn' - }, - { - option: '--currentPassword [currentPassword]' - }, - { - option: '--newPassword [newPassword]' - }, - { - option: '--displayName [displayName]' - }, - { - option: '--firstName [firstName]' - }, - { - option: '--lastName [lastName]' - }, - { - option: '--forceChangePasswordNextSignInWithMfa' - }, - { - option: '--usageLocation [usageLocation]' - }, - { - option: '--officeLocation [officeLocation]' - }, - { - option: '--jobTitle [jobTitle]' - }, - { - option: '--companyName [companyName]' - }, - { - option: '--department [department]' - }, - { - option: '--preferredLanguage [preferredLanguage]' - }, - { - option: '--managerUserId [managerUserId]' - }, - { - option: '--managerUserName [managerUserName]' - }, - { - option: '--removeManager' - } - ); - } - - #initTypes(): void { - this.types.boolean.push('accountEnabled'); - } - - #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`; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid userName`; - } - - if (!args.options.resetPassword && ((args.options.currentPassword && !args.options.newPassword) || (args.options.newPassword && !args.options.currentPassword))) { - return `Specify both currentPassword and newPassword when you want to change your password`; - } - - if (args.options.resetPassword && args.options.currentPassword) { - return `When resetting a user's password, don't specify the current password`; - } - - if (args.options.resetPassword && !args.options.newPassword) { - return `When resetting a user's password, specify the new password to set for the user, using the newPassword option`; - } - - if (args.options.firstName && args.options.firstName.length > 64) { - return `The max lenght for the firstName option is 64 characters`; - } - - if (args.options.lastName && args.options.lastName.length > 64) { - return `The max lenght for the lastName option is 64 characters`; - } - - if (args.options.forceChangePasswordNextSignIn && !args.options.resetPassword) { - return `The option forceChangePasswordNextSignIn can only be used in combination with the resetPassword option`; - } - - if (args.options.forceChangePasswordNextSignInWithMfa && !args.options.resetPassword) { - return `The option forceChangePasswordNextSignInWithMfa can only be used in combination with the resetPassword option`; - } - - if (args.options.usageLocation) { - const regex = new RegExp('^[a-zA-Z]{2}$'); - if (!regex.test(args.options.usageLocation)) { - return `'${args.options.usageLocation}' is not a valid usageLocation.`; - } - } - - if (args.options.jobTitle && args.options.jobTitle.length > 128) { - return `The max lenght for the jobTitle option is 128 characters`; - } - - if (args.options.companyName && args.options.companyName.length > 64) { - return `The max lenght for the companyName option is 64 characters`; - } - - if (args.options.department && args.options.department.length > 64) { - return `The max lenght for the department option is 64 characters`; - } - - if (args.options.preferredLanguage && args.options.preferredLanguage.length < 2) { - return `'${args.options.preferredLanguage}' is not a valid preferredLanguage`; - } - - if (args.options.managerUserName && !validation.isValidUserPrincipalName(args.options.managerUserName)) { - return `'${args.options.managerUserName}' is not a valid user principal name`; - } - - if (args.options.managerUserId && !validation.isValidGuid(args.options.managerUserId)) { - return `'${args.options.managerUserId}' is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { - options: ['id', 'userName'] - }, - { - options: ['managerUserId', 'managerUserName', 'removeManager'], - runsWhen: (args) => args.options.managerUserId || args.options.managerUserName || args.options.removeManager - } - ); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -314,7 +169,7 @@ class EntraUserSetCommand extends GraphCommand { accountEnabled: options.accountEnabled }; - this.addUnknownOptionsToPayload(requestBody, options); + this.addUnknownOptionsToPayloadZod(requestBody, options); if (options.resetPassword) { requestBody.passwordProfile = { diff --git a/src/m365/entra/commands/user/user-signin-list.spec.ts b/src/m365/entra/commands/user/user-signin-list.spec.ts index 5cf81cc1507..9acd0ae3a88 100644 --- a/src/m365/entra/commands/user/user-signin-list.spec.ts +++ b/src/m365/entra/commands/user/user-signin-list.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './user-signin-list.js'; +import command, { options } from './user-signin-list.js'; describe(commands.USER_SIGNIN_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const jsonOutput = { "value": [ @@ -153,6 +154,7 @@ describe(commands.USER_SIGNIN_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -203,7 +205,7 @@ describe(commands.USER_SIGNIN_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true }) }); assert(loggerLogSpy.calledWith( jsonOutput.value )); @@ -216,7 +218,7 @@ describe(commands.USER_SIGNIN_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, userName: 'testaccount1@contoso.com' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userName: 'testaccount1@contoso.com' }) }); assert(loggerLogSpy.calledWith( jsonOutput.value )); @@ -230,7 +232,7 @@ describe(commands.USER_SIGNIN_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, userId: '737002f2-9582-4068-b706-044e09481897' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userId: '737002f2-9582-4068-b706-044e09481897' }) }); assert(loggerLogSpy.calledWith( jsonOutput.value )); @@ -244,7 +246,7 @@ describe(commands.USER_SIGNIN_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, appId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, appId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064' }) }); assert(loggerLogSpy.calledWith( jsonOutput.value )); @@ -258,7 +260,7 @@ describe(commands.USER_SIGNIN_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, appDisplayName: 'Graph explorer' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, appDisplayName: 'Graph explorer' }) }); assert(loggerLogSpy.calledWith( jsonOutput.value )); @@ -272,7 +274,7 @@ describe(commands.USER_SIGNIN_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, userName: 'testaccount1@contoso.com', appId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userName: 'testaccount1@contoso.com', appId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064' }) }); assert(loggerLogSpy.calledWith( jsonOutput.value )); @@ -286,7 +288,7 @@ describe(commands.USER_SIGNIN_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true, userName: 'testaccount1@contoso.com', appDisplayName: 'Graph explorer' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, userName: 'testaccount1@contoso.com', appDisplayName: 'Graph explorer' }) }); assert(loggerLogSpy.calledWith( jsonOutput.value )); @@ -296,41 +298,41 @@ describe(commands.USER_SIGNIN_LIST, () => { const errorMessage = 'Something went wrong'; sinon.stub(request, 'get').rejects(new Error(errorMessage)); - await assert.rejects(command.action(logger, { options: { userName: 'testaccount1@contoso.com', appDisplayName: 'Graph explorer' } }), new CommandError(errorMessage)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ userName: 'testaccount1@contoso.com', appDisplayName: 'Graph explorer' }) }), new CommandError(errorMessage)); }); - it('fails validation if userId and userName specified', async () => { - const actual = await command.validate({ options: { userId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064', userName: 'Graph explorer' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if userId and userName specified', () => { + const actual = commandOptionsSchema.safeParse({ userId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064', userName: 'Graph explorer' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if the userId is not a valid GUID', async () => { - const actual = await command.validate({ options: { userId: 'not-c49b-4fd4-8223-28f0ac3a6402' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ userId: 'not-c49b-4fd4-8223-28f0ac3a6402' }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if the userId is a valid GUID', async () => { - const actual = await command.validate({ options: { userId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the userId is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ userId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064' }); + assert.strictEqual(actual.success, true); }); - it('fails validation when userName has an invalid value', async () => { - const actual = await command.validate({ options: { userName: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation when userName has an invalid value', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'invalid' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if appId and appDisplayName specified', async () => { - const actual = await command.validate({ options: { appId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064', appDisplayName: 'Graph explorer' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if appId and appDisplayName specified', () => { + const actual = commandOptionsSchema.safeParse({ appId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064', appDisplayName: 'Graph explorer' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if the appId is not a valid GUID', async () => { - const actual = await command.validate({ options: { appId: 'not-c49b-4fd4-8223-28f0ac3a6402' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the appId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ appId: 'not-c49b-4fd4-8223-28f0ac3a6402' }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if the appId is a valid GUID', async () => { - const actual = await command.validate({ options: { appId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the appId is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ appId: 'de8bc8b5-d9f9-48b1-a8ad-b748da725064' }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/entra/commands/user/user-signin-list.ts b/src/m365/entra/commands/user/user-signin-list.ts index afa9ee5eab8..b847762131a 100644 --- a/src/m365/entra/commands/user/user-signin-list.ts +++ b/src/m365/entra/commands/user/user-signin-list.ts @@ -1,23 +1,29 @@ import { SignIn } 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 { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid userName.` + }).optional().alias('n'), + userId: z.uuid().optional(), + appDisplayName: z.string().optional(), + appId: z.uuid().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userName?: string; - userId?: string; - appDisplayName?: string; - appId?: string; -} - class EntraUserSigninListCommand extends GraphCommand { public get name(): string { return commands.USER_SIGNIN_LIST; @@ -27,68 +33,18 @@ class EntraUserSigninListCommand extends GraphCommand { return 'Retrieves the Entra ID user sign-ins for the tenant'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userName: typeof args.options.userName !== 'undefined', - userId: typeof args.options.userId !== 'undefined', - appDisplayName: typeof args.options.appDisplayName !== 'undefined', - appId: typeof args.options.appId !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => !(options.userId && options.userName), { + error: `Specify either 'userId' or 'userName', but not both.` + }) + .refine(options => !(options.appId && options.appDisplayName), { + error: `Specify either 'appId' or 'appDisplayName', but not both.` }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-n, --userName [userName]' - }, - { - option: '--userId [userId]' - }, - { - option: '--appDisplayName [appDisplayName]' - }, - { - option: '--appId [appId]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && args.options.userName) { - return 'Specify either userId or userName, but not both'; - } - - if (args.options.appId && args.options.appDisplayName) { - return 'Specify either appId or appDisplayName, but not both'; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `${args.options.userName} is not a valid userName`; - } - - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `${args.options.userId} is not a valid GUID`; - } - - if (args.options.appId && !validation.isValidGuid(args.options.appId)) { - return `${args.options.appId} is not a valid GUID`; - } - - return true; - } - ); } public defaultProperties(): string[] | undefined { diff --git a/src/m365/spo/commands/group/group-member-remove.ts b/src/m365/spo/commands/group/group-member-remove.ts index 420a5b09d83..fa03435b0ed 100644 --- a/src/m365/spo/commands/group/group-member-remove.ts +++ b/src/m365/spo/commands/group/group-member-remove.ts @@ -138,8 +138,8 @@ class SpoGroupMemberRemoveCommand extends SpoCommand { const options: EntraUserGetCommandOptions = { email: args.options.email, output: 'json', - debug: args.options.debug, - verbose: args.options.verbose + debug: args.options.debug as boolean, + verbose: args.options.verbose as boolean }; const userGetOutput: CommandOutput = await cli.executeCommandWithOutput(entraUserGetCommand as Command, { options: { ...options, _: [] } }); diff --git a/src/m365/spo/commands/group/group-set.ts b/src/m365/spo/commands/group/group-set.ts index fb5b949f724..f05f2938212 100644 --- a/src/m365/spo/commands/group/group-set.ts +++ b/src/m365/spo/commands/group/group-set.ts @@ -192,8 +192,8 @@ class SpoGroupSetCommand extends SpoCommand { userName: options.ownerUserName, email: options.ownerEmail, output: 'json', - debug: options.debug, - verbose: options.verbose + debug: options.debug as boolean, + verbose: options.verbose as boolean }; const output: CommandOutput = await cli.executeCommandWithOutput(entraUserGetCommand as Command, { options: { ...cmdOptions, _: [] } });