diff --git a/src/m365/app/commands/app-get.spec.ts b/src/m365/app/commands/app-get.spec.ts index ce1da3e713b..697a6c3c5be 100644 --- a/src/m365/app/commands/app-get.spec.ts +++ b/src/m365/app/commands/app-get.spec.ts @@ -1,22 +1,27 @@ import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../Auth.js'; +import { cli } from '../../../cli/cli.js'; +import { CommandInfo } from '../../../cli/CommandInfo.js'; import { Logger } from '../../../cli/Logger.js'; import { CommandError } from '../../../Command.js'; import request from '../../../request.js'; import { telemetry } from '../../../telemetry.js'; +import { entraApp } from '../../../utils/entraApp.js'; 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 './app-get.js'; -import { entraApp } from '../../../utils/entraApp.js'; describe(commands.GET, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -33,6 +38,8 @@ describe(commands.GET, () => { ] })); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -76,9 +83,9 @@ describe(commands.GET, () => { sinon.stub(entraApp, 'getAppRegistrationByAppId').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' - } + }) }), new CommandError(`App with appId '9b1b1e42-794b-4c71-93ac-5ed92488b67f' not found in Microsoft Entra ID`)); }); @@ -98,9 +105,9 @@ describe(commands.GET, () => { sinon.stub(entraApp, 'getAppRegistrationByAppId').resolves(appResponse.value[0]); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' - } + }) }); const call: sinon.SinonSpyCall = loggerLogSpy.lastCall; assert.strictEqual(call.args[0].id, '340a4aa3-1af6-43ac-87d8-189819003952'); diff --git a/src/m365/app/commands/app-get.ts b/src/m365/app/commands/app-get.ts index cdd19da059e..fd290a8cb25 100644 --- a/src/m365/app/commands/app-get.ts +++ b/src/m365/app/commands/app-get.ts @@ -1,7 +1,8 @@ +import { z } from 'zod'; import { Logger } from '../../../cli/Logger.js'; -import AppCommand, { AppCommandArgs } from '../../base/AppCommand.js'; -import commands from '../commands.js'; import { entraApp } from '../../../utils/entraApp.js'; +import AppCommand, { AppCommandArgs, appCommandOptions } from '../../base/AppCommand.js'; +import commands from '../commands.js'; class AppGetCommand extends AppCommand { public get name(): string { @@ -12,6 +13,10 @@ class AppGetCommand extends AppCommand { return 'Retrieves information about the current Microsoft Entra app'; } + public get schema(): z.ZodTypeAny | undefined { + return appCommandOptions; + } + public async commandAction(logger: Logger, args: AppCommandArgs): Promise { try { const app = await entraApp.getAppRegistrationByAppId(args.options.appId!); diff --git a/src/m365/app/commands/app-open.spec.ts b/src/m365/app/commands/app-open.spec.ts index 0cdbb987f24..1ca99111a4a 100644 --- a/src/m365/app/commands/app-open.spec.ts +++ b/src/m365/app/commands/app-open.spec.ts @@ -1,11 +1,12 @@ import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../Auth.js'; -import { CommandError } from '../../../Command.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 { telemetry } from '../../../telemetry.js'; import { browserUtil } from '../../../utils/browserUtil.js'; import { pid } from '../../../utils/pid.js'; @@ -20,6 +21,7 @@ describe(commands.OPEN, () => { let getSettingWithDefaultValueStub: sinon.SinonStub; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -37,6 +39,7 @@ describe(commands.OPEN, () => { ] })); commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -75,22 +78,22 @@ describe(commands.OPEN, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the appId is not a valid guid', async () => { - const actual = await command.validate({ options: { appId: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the appId is not a valid guid', () => { + const actual = commandOptionsSchema.safeParse({ appId: 'abc' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if valid appId-guid is specified', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if valid appId-guid is specified', () => { + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }); + assert.strictEqual(actual.success, true); }); it('shows message with url when the app specified with the appId is found', async () => { const appId = "9b1b1e42-794b-4c71-93ac-5ed92488b67f"; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: appId - } + }) }); assert(loggerLogSpy.calledWith(`Use a web browser to open the page https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/Overview/appId/${appId}/isMSAApp/`)); }); @@ -98,10 +101,10 @@ describe(commands.OPEN, () => { it('shows message with url when the app specified with the appId is found (verbose)', async () => { const appId = "9b1b1e42-794b-4c71-93ac-5ed92488b67f"; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ verbose: true, appId: appId - } + }) }); assert(loggerLogSpy.calledWith(`Use a web browser to open the page https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/Overview/appId/${appId}/isMSAApp/`)); }); @@ -109,10 +112,10 @@ describe(commands.OPEN, () => { it('shows message with preview-url when the app specified with the appId is found', async () => { const appId = "9b1b1e42-794b-4c71-93ac-5ed92488b67f"; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: appId, preview: true - } + }) }); assert(loggerLogSpy.calledWith(`Use a web browser to open the page https://preview.portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/Overview/appId/${appId}/isMSAApp/`)); }); @@ -131,9 +134,9 @@ describe(commands.OPEN, () => { const appId = "9b1b1e42-794b-4c71-93ac-5ed92488b67f"; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: appId - } + }) }); assert(loggerLogSpy.calledWith(`Opening the following page in your browser: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/Overview/appId/${appId}/isMSAApp/`)); }); @@ -152,10 +155,10 @@ describe(commands.OPEN, () => { const appId = "9b1b1e42-794b-4c71-93ac-5ed92488b67f"; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: appId, preview: true - } + }) }); assert(loggerLogSpy.calledWith(`Opening the following page in your browser: https://preview.portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/Overview/appId/${appId}/isMSAApp/`)); }); @@ -174,10 +177,10 @@ describe(commands.OPEN, () => { const appId = "9b1b1e42-794b-4c71-93ac-5ed92488b67f"; await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: appId, preview: true - } + }) }), new CommandError('An error occurred')); assert(loggerLogSpy.calledWith(`Opening the following page in your browser: https://preview.portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/Overview/appId/${appId}/isMSAApp/`)); }); diff --git a/src/m365/app/commands/app-open.ts b/src/m365/app/commands/app-open.ts index 4e10b1b05ea..526f81c83d8 100644 --- a/src/m365/app/commands/app-open.ts +++ b/src/m365/app/commands/app-open.ts @@ -1,20 +1,22 @@ +import { z } from 'zod'; import { cli } from '../../../cli/cli.js'; import { Logger } from '../../../cli/Logger.js'; -import GlobalOptions from '../../../GlobalOptions.js'; import { settingsNames } from '../../../settingsNames.js'; import { browserUtil } from '../../../utils/browserUtil.js'; -import AppCommand from '../../base/AppCommand.js'; +import AppCommand, { appCommandOptions } from '../../base/AppCommand.js'; import commands from '../commands.js'; +const options = appCommandOptions + .extend({ + preview: z.boolean().optional().default(false) + }) + .strict(); +type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appId?: string; - preview?: boolean; -} - class AppOpenCommand extends AppCommand { public get name(): string { return commands.OPEN; @@ -24,27 +26,8 @@ class AppOpenCommand extends AppCommand { return 'Opens Microsoft Entra app in the Microsoft Entra ID portal'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - appId: typeof args.options.appId !== 'undefined', - preview: typeof args.options.preview !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '--appId [appId]' }, - { option: '--preview' } - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/app/commands/permission/permission-add.spec.ts b/src/m365/app/commands/permission/permission-add.spec.ts index c14a1cd4e12..0f09eacc7db 100644 --- a/src/m365/app/commands/permission/permission-add.spec.ts +++ b/src/m365/app/commands/permission/permission-add.spec.ts @@ -2,12 +2,14 @@ import { Application, ServicePrincipal } from '@microsoft/microsoft-graph-types' import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; -import { CommandError } from '../../../../Command.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'; +import { settingsNames } from '../../../../settingsNames.js'; import { telemetry } from '../../../../telemetry.js'; import { odata } from '../../../../utils/odata.js'; import { pid } from '../../../../utils/pid.js'; @@ -15,7 +17,6 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './permission-add.js'; -import { settingsNames } from '../../../../settingsNames.js'; describe(commands.PERMISSION_ADD, () => { //#region Mocked responses @@ -30,6 +31,7 @@ describe(commands.PERMISSION_ADD, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -47,6 +49,7 @@ describe(commands.PERMISSION_ADD, () => { })); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { if (settingName === 'prompt') { return false; @@ -112,7 +115,7 @@ describe(commands.PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { applicationPermissions: applicationPermissions, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ applicationPermissions: applicationPermissions, verbose: true }) }); assert(patchStub.called); }); @@ -144,7 +147,7 @@ describe(commands.PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { applicationPermissions: applicationPermissions, grantAdminConsent: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ applicationPermissions: applicationPermissions, grantAdminConsent: true, verbose: true }) }); assert.strictEqual(amountOfPostCalls, 2); }); @@ -167,7 +170,7 @@ describe(commands.PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { delegatedPermissions: delegatedPermissions, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ delegatedPermissions: delegatedPermissions, verbose: true }) }); assert(patchStub.called); }); @@ -200,7 +203,7 @@ describe(commands.PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { delegatedPermissions: delegatedPermissions, grantAdminConsent: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ delegatedPermissions: delegatedPermissions, grantAdminConsent: true, verbose: true }) }); assert.deepStrictEqual(postStub.lastCall.args[0].data, { clientId: servicePrincipalId, consentType: 'AllPrincipals', @@ -244,7 +247,7 @@ describe(commands.PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { delegatedPermissions: delegatedPermissions, applicationPermissions: applicationPermissions, grantAdminConsent: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ delegatedPermissions: delegatedPermissions, applicationPermissions: applicationPermissions, grantAdminConsent: true, verbose: true }) }); assert.strictEqual(amountOfPostCalls, 3); }); @@ -260,7 +263,7 @@ describe(commands.PERMISSION_ADD, () => { } }); - await assert.rejects(command.action(logger, { options: { applicationPermissions: applicationPermissions, verbose: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ applicationPermissions: applicationPermissions, verbose: true }) }), new CommandError(`App with id ${appId} not found in Microsoft Entra ID.`)); }); @@ -279,7 +282,7 @@ describe(commands.PERMISSION_ADD, () => { } }); - await assert.rejects(command.action(logger, { options: { applicationPermissions: api, verbose: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ applicationPermissions: api, verbose: true }) }), new CommandError(`Service principal ${servicePrincipalName} not found`)); }); @@ -299,26 +302,26 @@ describe(commands.PERMISSION_ADD, () => { } }); - await assert.rejects(command.action(logger, { options: { applicationPermissions: api, verbose: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ applicationPermissions: api, verbose: true }) }), new CommandError(`Permission ${permissionName} for service principal ${servicePrincipalName} not found`)); }); - it('passes validation if applicationPermissions is passed', async () => { - const actual = await command.validate({ options: { applicationPermissions: applicationPermissions } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if applicationPermissions is passed', () => { + const actual = commandOptionsSchema.safeParse({ applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, true); }); - it('passes validation if delegatedPermissions is passed', async () => { - const actual = await command.validate({ options: { delegatedPermissions: delegatedPermissions } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if delegatedPermissions is passed', () => { + const actual = commandOptionsSchema.safeParse({ delegatedPermissions: delegatedPermissions }); + assert.strictEqual(actual.success, true); }); - it('passes validation if both applicationPermissions or delegatedPermissions are passed', async () => { - const actual = await command.validate({ options: { applicationPermissions: applicationPermissions, delegatedPermissions: delegatedPermissions } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if both applicationPermissions or delegatedPermissions are passed', () => { + const actual = commandOptionsSchema.safeParse({ applicationPermissions: applicationPermissions, delegatedPermissions: delegatedPermissions }); + assert.strictEqual(actual.success, true); }); - it('fails validation if both applicationPermissions or delegatedPermissions is not passed', async () => { + it('fails validation if both applicationPermissions or delegatedPermissions is not passed', () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { if (settingName === settingsNames.prompt) { return false; @@ -327,7 +330,7 @@ describe(commands.PERMISSION_ADD, () => { return defaultValue; }); - const actual = await command.validate({ options: {} }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, false); }); }); \ No newline at end of file diff --git a/src/m365/app/commands/permission/permission-add.ts b/src/m365/app/commands/permission/permission-add.ts index 5bb975df4f7..45650b3e356 100644 --- a/src/m365/app/commands/permission/permission-add.ts +++ b/src/m365/app/commands/permission/permission-add.ts @@ -1,23 +1,26 @@ import { Application, AppRole, PermissionScope, RequiredResourceAccess, ResourceAccess, ServicePrincipal } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import { odata } from '../../../../utils/odata.js'; -import AppCommand from '../../../base/AppCommand.js'; +import AppCommand, { appCommandOptions } from '../../../base/AppCommand.js'; import commands from '../../commands.js'; +const options = appCommandOptions + .extend({ + applicationPermissions: z.string().optional(), + delegatedPermissions: z.string().optional(), + grantAdminConsent: z.boolean().optional() + }) + .strict(); + +type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appId?: string; - applicationPermissions?: string; - delegatedPermissions?: string; - grantAdminConsent?: boolean; -} - interface AppPermission { resourceId: string; resourceAccess: ResourceAccess[]; @@ -38,39 +41,16 @@ class AppPermissionAddCommand extends AppCommand { return 'Adds the specified application and/or delegated permissions to the current Microsoft Entra app API permissions'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initOptionSets(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - appId: typeof args.options.appId !== 'undefined', - applicationPermissions: typeof args.options.applicationPermissions !== 'undefined', - delegatedPermissions: typeof args.options.delegatedPermissions !== 'undefined', - grantAdminConsent: !!args.options.grantAdminConsent + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => options.applicationPermissions || options.delegatedPermissions, { + message: 'Specify at least one of applicationPermissions or delegatedPermissions, or both.', + path: ['delegatedPermissions'] }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '--appId [appId]' }, - { option: '--applicationPermissions [applicationPermissions]' }, - { option: '--delegatedPermissions [delegatedPermissions]' }, - { option: '--grantAdminConsent' } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ - options: ['applicationPermissions', 'delegatedPermissions'], - runsWhen: (args) => args.options.delegatedPermissions === undefined && args.options.applicationPermissions === undefined - }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/app/commands/permission/permission-list.spec.ts b/src/m365/app/commands/permission/permission-list.spec.ts index db6cac2952d..c73d5d48899 100644 --- a/src/m365/app/commands/permission/permission-list.spec.ts +++ b/src/m365/app/commands/permission/permission-list.spec.ts @@ -1,24 +1,29 @@ import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; +import { entraApp } from '../../../../utils/entraApp.js'; 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 './permission-list.js'; import { appRegApplicationPermissions, appRegDelegatedPermissionsMultipleResources, appRegNoApiPermissions, flowServiceOAuth2PermissionScopes, msGraphPrincipalAppRoles, msGraphPrincipalOAuth2PermissionScopes } from './permission-list.mock.js'; -import { entraApp } from '../../../../utils/entraApp.js'; describe(commands.PERMISSION_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let loggerLogToStderrSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; //#region Mocked Responses const appId = '9c79078b-815e-4a3e-bb80-2aaf2d9e9b3d'; @@ -40,6 +45,8 @@ describe(commands.PERMISSION_LIST, () => { sinon.stub(fs, 'existsSync').returns(true); sinon.stub(fs, 'readFileSync').returns(JSON.stringify(appResponse)); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -146,7 +153,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([ { "resource": "Microsoft Flow Service", @@ -243,7 +250,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogToStderrSpy.called); }); @@ -314,7 +321,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([ { "resource": "Microsoft Flow Service", @@ -370,7 +377,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([ { "resource": "Microsoft Graph", @@ -392,7 +399,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([])); }); @@ -464,7 +471,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([ { "resource": "Microsoft Graph", @@ -557,7 +564,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: { debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); assert(loggerLogToStderrSpy.called); }); @@ -604,7 +611,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([ { "resource": "Microsoft Graph", @@ -632,7 +639,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: {} }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError(error)); }); @@ -650,7 +657,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: {} }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError(`An error has occurred`)); }); @@ -680,7 +687,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: {} }), 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 retrieving OAuth2 permission scopes for service principal', async () => { @@ -755,7 +762,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: {} }), 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 retrieving app role assignments for service principal', async () => { @@ -795,7 +802,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: {} }), 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 retrieving app roles for service principal', async () => { @@ -870,7 +877,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: {} }), 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 retrieving Microsoft Entra registration', async () => { @@ -887,7 +894,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await assert.rejects(command.action(logger, { options: {} }), new CommandError(`An error has occurred`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError(`An error has occurred`)); }); it('handles non-existent service principal from app registration permissions', async () => { @@ -903,7 +910,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([ { "resource": "00000003-0000-0000-c000-000000000000", @@ -981,7 +988,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([ { "resource": "Microsoft Graph", @@ -1073,7 +1080,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([ { "resource": "Microsoft Flow Service", @@ -1131,7 +1138,7 @@ describe(commands.PERMISSION_LIST, () => { } }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual(JSON.stringify(loggerLogSpy.lastCall.args[0]), JSON.stringify([ { "resource": "Microsoft Graph", diff --git a/src/m365/app/commands/permission/permission-list.ts b/src/m365/app/commands/permission/permission-list.ts index 0a63bc4c5e3..293a718a27d 100644 --- a/src/m365/app/commands/permission/permission-list.ts +++ b/src/m365/app/commands/permission/permission-list.ts @@ -1,9 +1,10 @@ import { Application, AppRole, AppRoleAssignment, OAuth2PermissionGrant, PermissionScope, RequiredResourceAccess, ResourceAccess, ServicePrincipal } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; import request, { CliRequestOptions } from '../../../../request.js'; -import AppCommand from '../../../base/AppCommand.js'; -import commands from '../../commands.js'; import { entraApp } from '../../../../utils/entraApp.js'; +import AppCommand, { appCommandOptions } from '../../../base/AppCommand.js'; +import commands from '../../commands.js'; interface ApiPermission { resource: string; @@ -30,6 +31,10 @@ class AppPermissionListCommand extends AppCommand { return 'Lists API permissions for the current Microsoft Entra app'; } + public get schema(): z.ZodTypeAny | undefined { + return appCommandOptions; + } + public async commandAction(logger: Logger): Promise { try { const servicePrincipal = await this.getServicePrincipal({ appId: this.appId }, logger, GetServicePrincipal.withPermissions); diff --git a/src/m365/base/AppCommand.spec.ts b/src/m365/base/AppCommand.spec.ts index 2feff675613..ab3d0ee5a4c 100644 --- a/src/m365/base/AppCommand.spec.ts +++ b/src/m365/base/AppCommand.spec.ts @@ -1,13 +1,14 @@ import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { z } from 'zod'; import { cli } from '../../cli/cli.js'; import { CommandInfo } from '../../cli/CommandInfo.js'; import { Logger } from '../../cli/Logger.js'; import Command, { CommandError } from '../../Command.js'; +import { telemetry } from '../../telemetry.js'; import { sinonUtil } from '../../utils/sinonUtil.js'; import AppCommand from './AppCommand.js'; -import { telemetry } from '../../telemetry.js'; class MockCommand extends AppCommand { public get name(): string { @@ -30,9 +31,11 @@ describe('AppCommand', () => { let logger: Logger; let log: string[]; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { commandInfo = cli.getCommandInfo(new MockCommand()); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; sinon.stub(telemetry, 'trackEvent').resolves(); }); @@ -70,19 +73,19 @@ describe('AppCommand', () => { it('returns error if .m365rc.json file not found in the current directory', async () => { sinon.stub(fs, 'existsSync').returns(false); - await assert.rejects(cmd.action(logger, { options: {} }), new CommandError('Could not find file: .m365rc.json')); + await assert.rejects(cmd.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError('Could not find file: .m365rc.json')); }); it('returns error if the .m365rc.json file is empty', async () => { sinon.stub(fs, 'existsSync').returns(true); sinon.stub(fs, 'readFileSync').returns(''); - await assert.rejects(cmd.action(logger, { options: {} }), new CommandError('File .m365rc.json is empty')); + await assert.rejects(cmd.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError('File .m365rc.json is empty')); }); it(`returns error if the .m365rc.json file contents couldn't be parsed`, async () => { sinon.stub(fs, 'existsSync').returns(true); sinon.stub(fs, 'readFileSync').returns('{'); - await assert.rejects(cmd.action(logger, { options: {} }), new CommandError('Could not parse file: .m365rc.json')); + await assert.rejects(cmd.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError('Could not parse file: .m365rc.json')); }); it(`returns error if the .m365rc.json file is empty doesn't contain any apps`, async () => { @@ -90,7 +93,7 @@ describe('AppCommand', () => { sinon.stub(fs, 'readFileSync').returns(JSON.stringify({ apps: [] })); - await assert.rejects(cmd.action(logger, { options: {} }), new CommandError('No Entra apps found in .m365rc.json')); + await assert.rejects(cmd.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError('No Entra apps found in .m365rc.json')); }); it(`returns error if the specified appId not found in the .m365rc.json file`, async () => { @@ -103,7 +106,7 @@ describe('AppCommand', () => { } ] })); - await assert.rejects(cmd.action(logger, { options: { appId: 'e23d235c-fcdf-45d1-ac5f-24ab2ee06951' } }), + await assert.rejects(cmd.action(logger, { options: commandOptionsSchema.parse({ appId: 'e23d235c-fcdf-45d1-ac5f-24ab2ee06951' }) }), new CommandError('App e23d235c-fcdf-45d1-ac5f-24ab2ee06951 not found in .m365rc.json')); }); @@ -124,7 +127,7 @@ describe('AppCommand', () => { const cliPromptStub = sinon.stub(cli, 'handleMultipleResultsFound').callsFake(async () => ( { appIdIndex: 0 } )); - await assert.rejects(cmd.action(logger, { options: {} })); + await assert.rejects(cmd.action(logger, { options: commandOptionsSchema.parse({}) })); assert(cliPromptStub.called); }); @@ -149,7 +152,7 @@ describe('AppCommand', () => { sinon.stub(Command.prototype, 'action').resolves(); try { - await cmd.action(logger, { options: {} }); + await cmd.action(logger, { options: commandOptionsSchema.parse({}) }); assert.strictEqual((cmd as any).appId, '9c79078b-815e-4a3e-bb80-2aaf2d9e9b3d'); } finally { @@ -171,7 +174,7 @@ describe('AppCommand', () => { } ] })); - await assert.rejects(cmd.action(logger, { options: { appId: '9c79078b-815e-4a3e-bb80-2aaf2d9e9b3d' } })); + await assert.rejects(cmd.action(logger, { options: commandOptionsSchema.parse({ appId: '9c79078b-815e-4a3e-bb80-2aaf2d9e9b3d' }) })); assert.strictEqual((cmd as any).appId, '9c79078b-815e-4a3e-bb80-2aaf2d9e9b3d'); }); @@ -185,17 +188,17 @@ describe('AppCommand', () => { } ] })); - await assert.rejects(cmd.action(logger, { options: {} })); + await assert.rejects(cmd.action(logger, { options: commandOptionsSchema.parse({}) })); assert.strictEqual((cmd as any).appId, 'e23d235c-fcdf-45d1-ac5f-24ab2ee0695d'); }); - it('fails validation if the specified appId is not a valid GUID', async () => { - const actual = await cmd.validate({ options: { appId: 'e23d235c-fcdf-45d1-ac5f-24ab2ee0695' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the specified appId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ appId: 'e23d235c-fcdf-45d1-ac5f-24ab2ee0695' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if the specified appId is a valid GUID', async () => { - const actual = await cmd.validate({ options: { appId: 'e23d235c-fcdf-45d1-ac5f-24ab2ee0695d' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the specified appId is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ appId: 'e23d235c-fcdf-45d1-ac5f-24ab2ee0695d' }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/base/AppCommand.ts b/src/m365/base/AppCommand.ts index 21a005fd593..becbd30b44f 100644 --- a/src/m365/base/AppCommand.ts +++ b/src/m365/base/AppCommand.ts @@ -1,18 +1,19 @@ import fs from 'fs'; +import { z } from 'zod'; import { cli } from '../../cli/cli.js'; import { Logger } from '../../cli/Logger.js'; -import Command, { CommandArgs, CommandError } from '../../Command.js'; -import GlobalOptions from '../../GlobalOptions.js'; -import { validation } from '../../utils/validation.js'; -import { M365RcJson, M365RcJsonApp } from './M365RcJson.js'; +import Command, { CommandError, globalOptionsZod } from '../../Command.js'; import { formatting } from '../../utils/formatting.js'; +import { M365RcJson, M365RcJsonApp } from './M365RcJson.js'; -export interface AppCommandArgs { - options: AppCommandOptions; -} +export const appCommandOptions = globalOptionsZod + .extend({ + appId: z.string().uuid().optional() + }); +type Options = z.infer; -interface AppCommandOptions extends GlobalOptions { - appId?: string; +export interface AppCommandArgs { + options: Options; } export default abstract class AppCommand extends Command { @@ -23,29 +24,8 @@ export default abstract class AppCommand extends Command { return 'https://graph.microsoft.com'; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { option: '--appId [appId]' } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.appId && !validation.isValidGuid(args.options.appId)) { - return `${args.options.appId} is not a valid GUID`; - } - - return true; - }, - ); + public get schema(): z.ZodTypeAny | undefined { + return appCommandOptions; } public async action(logger: Logger, args: AppCommandArgs): Promise {