From f4df8607eb699b8f5cc21e5d614fa8c6ab7e4380 Mon Sep 17 00:00:00 2001 From: Martin Lingstuyl Date: Fri, 25 Jul 2025 11:44:31 +0200 Subject: [PATCH] Migrates 'entra app' commands to Zod --- .../commands/adaptivecard-send.ts | 2 +- src/m365/entra/commands/app/app-add.spec.ts | 389 +++++++++--------- src/m365/entra/commands/app/app-add.ts | 296 ++++++------- src/m365/entra/commands/app/app-get.spec.ts | 242 ++++++----- src/m365/entra/commands/app/app-get.ts | 81 ++-- src/m365/entra/commands/app/app-list.spec.ts | 25 +- src/m365/entra/commands/app/app-list.ts | 37 +- .../commands/app/app-permission-add.spec.ts | 172 ++++++-- .../entra/commands/app/app-permission-add.ts | 92 ++--- .../commands/app/app-permission-list.spec.ts | 73 ++-- .../entra/commands/app/app-permission-list.ts | 87 ++-- .../app/app-permission-remove.spec.ts | 96 +++-- .../commands/app/app-permission-remove.ts | 155 +++---- .../entra/commands/app/app-remove.spec.ts | 138 ++++--- src/m365/entra/commands/app/app-remove.ts | 73 +--- .../entra/commands/app/app-role-add.spec.ts | 95 +++-- src/m365/entra/commands/app/app-role-add.ts | 111 ++--- .../entra/commands/app/app-role-list.spec.ts | 61 +-- src/m365/entra/commands/app/app-role-list.ts | 52 +-- .../commands/app/app-role-remove.spec.ts | 227 +++++----- .../entra/commands/app/app-role-remove.ts | 89 ++-- src/m365/entra/commands/app/app-set.spec.ts | 174 ++++---- src/m365/entra/commands/app/app-set.ts | 149 +++---- .../openextension/openextension-add.ts | 3 +- .../openextension/openextension-set.ts | 3 +- 25 files changed, 1341 insertions(+), 1581 deletions(-) diff --git a/src/m365/adaptivecard/commands/adaptivecard-send.ts b/src/m365/adaptivecard/commands/adaptivecard-send.ts index a1dc6d7e53b..23ab2c274b7 100644 --- a/src/m365/adaptivecard/commands/adaptivecard-send.ts +++ b/src/m365/adaptivecard/commands/adaptivecard-send.ts @@ -18,7 +18,7 @@ export const options = globalOptionsZod card: z.string().optional(), cardData: z.string().optional() }) - .and(z.any()); + .and(z.unknown()); declare type Options = z.infer; diff --git a/src/m365/entra/commands/app/app-add.spec.ts b/src/m365/entra/commands/app/app-add.spec.ts index bae57cc666f..734922b0599 100644 --- a/src/m365/entra/commands/app/app-add.spec.ts +++ b/src/m365/entra/commands/app/app-add.spec.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -145,6 +146,7 @@ describe(commands.APP_ADD, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -160,6 +162,7 @@ describe(commands.APP_ADD, () => { }; } commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -285,9 +288,9 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app' - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', @@ -375,10 +378,10 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', @@ -465,10 +468,10 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', multitenant: true - } + }) }); assert(loggerLogSpy.calledWith({ appId: '62f0f128-987f-47f2-827a-be50d0d894c7', @@ -564,11 +567,11 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', redirectUris: 'https://myapp.azurewebsites.net,http://localhost:4000', platform: 'web' - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'd2941a3b-aad4-49e0-8a1d-b82de0b46067', @@ -662,11 +665,11 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', redirectUris: 'https://login.microsoftonline.com/common/oauth2/nativeclient', platform: 'publicClient' - } + }) }); assert(loggerLogSpy.calledWith({ appId: '1ce0287c-9ccc-457e-a0cf-3ec5b734c092', @@ -766,10 +769,10 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', withSecret: true - } + }) }); assert(loggerLogSpy.calledWith({ appId: '3c5bd51d-f1ac-4344-bd16-43396cadff14', @@ -873,11 +876,11 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, name: 'My Microsoft Entra app', withSecret: true - } + }) }); assert(loggerLogSpy.calledWith({ appId: '3c5bd51d-f1ac-4344-bd16-43396cadff14', @@ -1027,11 +1030,11 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', withSecret: true, apisApplication: 'https://graph.microsoft.com/Group.ReadWrite.All,https://graph.microsoft.com/Directory.Read.All' - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'dbfdad7a-5105-45fc-8290-eb0b0b24ac58', @@ -1189,12 +1192,12 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', withSecret: true, apisApplication: 'https://graph.microsoft.com/Group.ReadWrite.All,https://graph.microsoft.com/Directory.Read.All', apisDelegated: 'https://graph.microsoft.com/Directory.Read.All' - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'dbfdad7a-5105-45fc-8290-eb0b0b24ac58', @@ -1346,13 +1349,13 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', platform: 'spa', redirectUris: 'https://myspa.azurewebsites.net,http://localhost:8080', apisDelegated: 'https://graph.microsoft.com/Calendars.Read,https://graph.microsoft.com/Directory.Read.All', implicitFlow: true - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'c505d465-9e4e-4bb4-b653-7b36d77cc94a', @@ -1500,14 +1503,14 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, name: 'My Microsoft Entra app', platform: 'spa', redirectUris: 'https://myspa.azurewebsites.net,http://localhost:8080', apisDelegated: 'https://graph.microsoft.com/Calendars.Read,https://graph.microsoft.com/Directory.Read.All', implicitFlow: true - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'c505d465-9e4e-4bb4-b653-7b36d77cc94a', @@ -1605,10 +1608,10 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', uri: 'https://contoso.onmicrosoft.com/myapp' - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'b08d9318-5612-4f87-9f94-7414ef6f0c8a', @@ -1706,11 +1709,11 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, name: 'My Microsoft Entra app', uri: 'https://contoso.onmicrosoft.com/myapp' - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'b08d9318-5612-4f87-9f94-7414ef6f0c8a', @@ -1822,14 +1825,14 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', uri: 'api://caf406b91cd4.ngrok.io/_appId_', scopeName: 'access_as_user', scopeAdminConsentDescription: 'Access as a user', scopeAdminConsentDisplayName: 'Access as a user', scopeConsentBy: 'admins' - } + }) }); assert(loggerLogSpy.calledWith({ appId: '13e11551-2967-4985-8c55-cd2aaa6b80ad', @@ -1941,14 +1944,14 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', uri: 'api://caf406b91cd4.ngrok.io/_appId_', scopeName: 'access_as_user', scopeAdminConsentDescription: 'Access as a user', scopeAdminConsentDisplayName: 'Access as a user', scopeConsentBy: 'adminsAndUsers' - } + }) }); assert(loggerLogSpy.calledWith({ appId: '13e11551-2967-4985-8c55-cd2aaa6b80ad', @@ -2043,11 +2046,11 @@ describe(commands.APP_ADD, () => { sinon.stub(fs, 'readFileSync').returns("somecertificatebase64string"); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', certificateDisplayName: 'some certificate', certificateFile: 'C:\\temp\\some-certificate.cer' - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', @@ -2140,11 +2143,11 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', certificateDisplayName: 'some certificate', certificateBase64Encoded: 'somecertificatebase64string' - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', @@ -2300,12 +2303,12 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', apisApplication: 'https://graph.microsoft.com/Group.ReadWrite.All', grantAdminConsent: true, debug: true - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'dbfdad7a-5105-45fc-8290-eb0b0b24ac58', @@ -2324,11 +2327,11 @@ describe(commands.APP_ADD, () => { sinon.stub(request, 'post').rejects('Issued POST request'); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', withSecret: true, apisApplication: 'https://graph.microsoft.com/Group.ReadWrite.All,https://graph.microsoft.com/Directory.Read.All' - } + }) } as any), new CommandError('An error has occurred')); }); @@ -2471,12 +2474,13 @@ describe(commands.APP_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', platform: 'spa', apisDelegated: 'https://myapi.onmicrosoft.com/access_as_user', - implicitFlow: true - } + implicitFlow: true, + redirectUris: 'http://localhost' + }) } as any), new CommandError('Service principal https://myapi.onmicrosoft.com not found')); }); @@ -2619,12 +2623,13 @@ describe(commands.APP_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', platform: 'spa', apisDelegated: 'https://graph.microsoft.com/Read.Everything', - implicitFlow: true - } + implicitFlow: true, + redirectUris: 'http://localhost' + }) } as any), new CommandError('Permission Read.Everything for service principal https://graph.microsoft.com not found')); }); @@ -2714,10 +2719,10 @@ describe(commands.APP_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', withSecret: true - } + }) } as any), new CommandError('An error has occurred')); }); @@ -2727,9 +2732,9 @@ describe(commands.APP_ADD, () => { sinon.stub(request, 'post').rejects({ error: { message: 'An error has occurred' } }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app' - } + }) } as any), new CommandError('An error has occurred')); }); @@ -2811,10 +2816,10 @@ describe(commands.APP_ADD, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', uri: 'https://contoso.onmicrosoft.com/myapp' - } + }) } as any), new CommandError('An error has occurred')); }); @@ -2823,12 +2828,12 @@ describe(commands.APP_ADD, () => { sinon.stub(fs, 'readFileSync').throws(new Error("An error has occurred")); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, name: 'My Microsoft Entra app', certificateDisplayName: 'some certificate', certificateFile: 'C:\\temp\\some-certificate.cer' - } + }) } as any), new CommandError(`Error reading certificate file: Error: An error has occurred. Please add the certificate using base64 option '--certificateBase64Encoded'.`)); }); @@ -2956,12 +2961,12 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', platform: 'web', redirectUris: 'https://global.consent.azure-apim.net/redirect', apisDelegated: 'https://admin.services.crm.dynamics.com/user_impersonation' - } + }) }); assert(loggerLogSpy.calledWith({ appId: '702e65ba-cacb-4a2f-aa5c-e6460967bc20', @@ -3206,9 +3211,9 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest) - } + }) }); assert(loggerLogSpy.calledWith({ appId: '689d2d97-7b80-4283-9185-ee24b5648607', @@ -3453,9 +3458,9 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest) - } + }) }); assert(loggerLogSpy.calledWith({ appId: '689d2d97-7b80-4283-9185-ee24b5648607', @@ -3687,9 +3692,9 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest) - } + }) }); assert(loggerLogSpy.calledWith({ appId: '689d2d97-7b80-4283-9185-ee24b5648607', @@ -4007,9 +4012,9 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest) - } + }) }); assert(loggerLogSpy.calledWith({ appId: '19180b97-8f30-43ac-8a22-19565de0b064', @@ -4327,9 +4332,9 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest) - } + }) }); assert(loggerLogSpy.calledWith({ appId: '19180b97-8f30-43ac-8a22-19565de0b064', @@ -4647,9 +4652,9 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest) - } + }) }); assert(loggerLogSpy.calledWith({ appId: '19180b97-8f30-43ac-8a22-19565de0b064', @@ -4956,9 +4961,9 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest) - } + }) }); assert(loggerLogSpy.calledWith({ appId: '19180b97-8f30-43ac-8a22-19565de0b064', @@ -5305,9 +5310,9 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest) - } + }) }); assert(loggerLogSpy.calledWith({ appId: '19180b97-8f30-43ac-8a22-19565de0b064', @@ -5688,10 +5693,10 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest), apisApplication: 'https://graph.microsoft.com/Group.ReadWrite.All,https://graph.microsoft.com/Directory.Read.All' - } + }) }); assert(loggerLogSpy.calledWith({ appId: '19180b97-8f30-43ac-8a22-19565de0b064', @@ -5779,9 +5784,9 @@ describe(commands.APP_ADD, () => { const fsWriteFileSyncSpy = sinon.spy(fs, 'writeFileSync'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app' - } + }) }); assert(fsWriteFileSyncSpy.notCalled); }); @@ -5871,10 +5876,10 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', save: true - } + }) }); assert.strictEqual(filePath, '.m365rc.json'); assert.strictEqual(fileContents, JSON.stringify({ @@ -5971,10 +5976,10 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', save: true - } + }) }); assert.strictEqual(filePath, '.m365rc.json'); assert.strictEqual(fileContents, JSON.stringify({ @@ -6078,10 +6083,10 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', save: true - } + }) }); assert.strictEqual(filePath, '.m365rc.json'); assert.strictEqual(fileContents, JSON.stringify({ @@ -6190,11 +6195,11 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, name: 'My Microsoft Entra app', save: true - } + }) }); assert.strictEqual(filePath, '.m365rc.json'); assert.strictEqual(fileContents, JSON.stringify({ @@ -6291,10 +6296,10 @@ describe(commands.APP_ADD, () => { const fsWriteFileSyncSpy = sinon.spy(fs, 'writeFileSync'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', save: true - } + }) }); assert(fsWriteFileSyncSpy.notCalled); }); @@ -6380,10 +6385,10 @@ describe(commands.APP_ADD, () => { const fsWriteFileSyncSpy = sinon.spy(fs, 'writeFileSync'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', save: true - } + }) }); assert(fsWriteFileSyncSpy.notCalled); }); @@ -6468,168 +6473,168 @@ describe(commands.APP_ADD, () => { sinon.stub(fs, 'writeFileSync').throws(new Error('Error occurred while saving app info')); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', save: true - } + }) }); }); - it('fails validation if specified platform value is not valid', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if specified platform value is not valid', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'abc' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if platform value is spa', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'spa', redirectUris: 'http://localhost:8080' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if platform value is spa', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'spa', redirectUris: 'http://localhost:8080' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if platform value is web', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'web', redirectUris: 'http://localhost:8080' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if platform value is web', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'web', redirectUris: 'http://localhost:8080' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if platform value is publicClient', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'publicClient', redirectUris: 'http://localhost:8080' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if platform value is publicClient', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'publicClient', redirectUris: 'http://localhost:8080' }); + assert.strictEqual(actual.success, true); }); - it('fails validation if redirectUris specified without platform', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', redirectUris: 'http://localhost:8080' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if redirectUris specified without platform', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', redirectUris: 'http://localhost:8080' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if redirectUris specified with platform', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', redirectUris: 'http://localhost:8080', platform: 'spa' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if redirectUris specified with platform', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', redirectUris: 'http://localhost:8080', platform: 'spa' }); + assert.strictEqual(actual.success, true); }); - it('fails validation if platform is spa and redirectUris is not specified', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'spa' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if platform is spa and redirectUris is not specified', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'spa' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if platform is web and redirectUris is not specified', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'web' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if platform is web and redirectUris is not specified', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'web' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if platform is publicClient and redirectUris is not specified', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'publicClient' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if platform is publicClient and redirectUris is not specified', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'publicClient' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if scopeName specified without uri', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', scopeName: 'access_as_user', scopeAdminConsentDescription: 'Access as user', scopeAdminConsentDisplayName: 'Access as user' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if scopeName specified without uri', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', scopeName: 'access_as_user', scopeAdminConsentDescription: 'Access as user', scopeAdminConsentDisplayName: 'Access as user' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if scopeName specified without scopeAdminConsentDescription', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', scopeName: 'access_as_user', uri: 'https://contoso.onmicrosoft.com/myapp', scopeAdminConsentDisplayName: 'Access as user' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if scopeName specified without scopeAdminConsentDescription', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', scopeName: 'access_as_user', uri: 'https://contoso.onmicrosoft.com/myapp', scopeAdminConsentDisplayName: 'Access as user' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if scopeName specified without scopeAdminConsentDisplayName', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', scopeName: 'access_as_user', uri: 'https://contoso.onmicrosoft.com/myapp', scopeAdminConsentDescription: 'Access as user' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if scopeName specified without scopeAdminConsentDisplayName', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', scopeName: 'access_as_user', uri: 'https://contoso.onmicrosoft.com/myapp', scopeAdminConsentDescription: 'Access as user' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if scopeName specified with uri, scopeAdminConsentDisplayName and scopeAdminConsentDescription', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', scopeName: 'access_as_user', uri: 'https://contoso.onmicrosoft.com/myapp', scopeAdminConsentDescription: 'Access as user', scopeAdminConsentDisplayName: 'Access as user' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if scopeName specified with uri, scopeAdminConsentDisplayName and scopeAdminConsentDescription', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', scopeName: 'access_as_user', uri: 'https://contoso.onmicrosoft.com/myapp', scopeAdminConsentDescription: 'Access as user', scopeAdminConsentDisplayName: 'Access as user' }); + assert.strictEqual(actual.success, true); }); - it('fails validation if specified scopeConsentBy value is not valid', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', scopeConsentBy: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if specified scopeConsentBy value is not valid', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', scopeConsentBy: 'abc' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if scopeConsentBy is admins', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', scopeConsentBy: 'admins' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if scopeConsentBy is admins', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', scopeConsentBy: 'admins' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if scopeConsentBy is adminsAndUsers', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', scopeConsentBy: 'adminsAndUsers' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if scopeConsentBy is adminsAndUsers', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', scopeConsentBy: 'adminsAndUsers' }); + assert.strictEqual(actual.success, true); }); - it('fails validation if specified manifest is not a valid JSON string', async () => { + it('fails validation if specified manifest is not a valid JSON string', () => { const manifest = '{'; - const actual = await command.validate({ options: { manifest: manifest } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ manifest: manifest }); + assert.strictEqual(actual.success, false); }); - it(`fails validation if manifest is valid JSON but it doesn't contain name and name option not specified`, async () => { + it(`fails validation if manifest is valid JSON but it doesn't contain name and name option not specified`, () => { const manifest = '{}'; - const actual = await command.validate({ options: { manifest: manifest } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ manifest: manifest }); + assert.strictEqual(actual.success, false); }); - it('fails validation if certificateDisplayName is specified without certificate', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', certificateDisplayName: 'Some certificate' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if certificateDisplayName is specified without certificate', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', certificateDisplayName: 'Some certificate' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if both certificateBase64Encoded and certificateFile are specified', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', certificateFile: 'c:\\temp\\some-certificate.cer', certificateBase64Encoded: 'somebase64string' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if both certificateBase64Encoded and certificateFile are specified', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', certificateFile: 'c:\\temp\\some-certificate.cer', certificateBase64Encoded: 'somebase64string' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if certificateFile specified with certificateDisplayName', async () => { + it('passes validation if certificateFile specified with certificateDisplayName', () => { sinon.stub(fs, 'existsSync').callsFake(_ => true); - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', certificateDisplayName: 'Some certificate', certificateFile: 'c:\\temp\\some-certificate.cer' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', certificateDisplayName: 'Some certificate', certificateFile: 'c:\\temp\\some-certificate.cer' }); + assert.strictEqual(actual.success, true); }); - it('fails validation when certificate file is not found', async () => { + it('fails validation when certificate file is not found', () => { sinon.stub(fs, 'existsSync').callsFake(_ => false); - const actual = await command.validate({ options: { debug: true, name: 'My Microsoft Entra app', certificateDisplayName: 'some certificate', certificateFile: 'C:\\temp\\some-certificate.cer' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ debug: true, name: 'My Microsoft Entra app', certificateDisplayName: 'some certificate', certificateFile: 'C:\\temp\\some-certificate.cer' }); + assert.strictEqual(actual.success, false); }); - it('passes validation if certificateBase64Encoded specified with certificateDisplayName', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', certificateDisplayName: 'Some certificate', certificateBase64Encoded: 'somebase64string' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if certificateBase64Encoded specified with certificateDisplayName', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', certificateDisplayName: 'Some certificate', certificateBase64Encoded: 'somebase64string' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if manifest is valid JSON', async () => { + it('passes validation if manifest is valid JSON', () => { const manifest = '{"name": "My app"}'; - const actual = await command.validate({ options: { manifest: manifest } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ manifest: manifest }); + assert.strictEqual(actual.success, true); }); it('passes validation if platform is apple and bundleId is specified', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'apple', bundleId: 'com.contoso.app' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'apple', bundleId: 'com.contoso.app' }); + assert.strictEqual(actual.success, true); }); it('fails validation if platform is apple, but bundleId is missing', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'apple' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'apple' }); + assert.notStrictEqual(actual.success, true); }); it('passes validation if platform is android and bundleId and signatureHash is specified', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'apple', bundleId: 'com.contoso.app', signatureHash: '2pmj9i4rSx0yEb/viWBYkE/ZQrk=' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'apple', bundleId: 'com.contoso.app', signatureHash: '2pmj9i4rSx0yEb/viWBYkE/ZQrk=' }); + assert.strictEqual(actual.success, true); }); it('fails validation if platform is android, but bundleId is missing', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'android', signatureHash: '2pmj9i4rSx0yEb/viWBYkE/ZQrk=' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'android', signatureHash: '2pmj9i4rSx0yEb/viWBYkE/ZQrk=' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if platform is android, but signatureHash is missing', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'android', bundleId: 'com.contoso.app' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'android', bundleId: 'com.contoso.app' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if platform is android, but bundleId and signatureHash is missing', async () => { - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', platform: 'android' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', platform: 'android' }); + assert.notStrictEqual(actual.success, true); }); it('creates Microsoft Entra app reg for a web app from a manifest with redirectUris and options overriding them', async () => { @@ -6868,11 +6873,11 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest), platform: "spa", redirectUris: "http://localhost/auth,https://24c4-2001-1c00-80c-d00-e5da-977c-7c52-5197.ngrok.io/auth" - } + }) }); assert(loggerLogSpy.calledWith({ @@ -7124,10 +7129,10 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ manifest: JSON.stringify(manifest), apisApplication: 'https://graph.microsoft.com/Group.ReadWrite.All,https://graph.microsoft.com/Directory.Read.All' - } + }) }); assert(loggerLogSpy.calledWith({ @@ -7275,11 +7280,11 @@ describe(commands.APP_ADD, () => { (command as any).manifest = manifestWithSecret; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', manifest: JSON.stringify(manifestWithSecret), withSecret: true - } + }) }); assert(loggerLogSpy.calledWith({ @@ -7434,12 +7439,12 @@ describe(commands.APP_ADD, () => { (command as any).manifest = basicManifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', manifest: JSON.stringify(basicManifest), certificateDisplayName: 'some certificate', certificateBase64Encoded: 'somecertificatebase64string' - } + }) }); assert(loggerLogSpy.calledWith({ @@ -7585,12 +7590,12 @@ describe(commands.APP_ADD, () => { (command as any).manifest = basicManifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', manifest: JSON.stringify(basicManifest), platform: 'publicClient', redirectUris: 'https://login.microsoftonline.com/common/oauth2/nativeclient' - } + }) }); assert(loggerLogSpy.calledWith({ @@ -7736,12 +7741,12 @@ describe(commands.APP_ADD, () => { (command as any).manifest = basicManifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', manifest: JSON.stringify(basicManifest), implicitFlow: true, multitenant: true - } + }) }); assert(loggerLogSpy.calledWith({ @@ -7905,7 +7910,7 @@ describe(commands.APP_ADD, () => { (command as any).manifest = basicManifest; await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', manifest: JSON.stringify(basicManifest), uri: 'api://caf406b91cd4.ngrok.io/_appId_', @@ -7913,7 +7918,7 @@ describe(commands.APP_ADD, () => { scopeAdminConsentDescription: 'Access as a user', scopeAdminConsentDisplayName: 'Access as a user', scopeConsentBy: 'adminsAndUsers' - } + }) }); assert(loggerLogSpy.calledWith({ @@ -8002,10 +8007,10 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My AAD app', allowPublicClientFlows: true - } + }) }); assert(loggerLogSpy.calledWith({ appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', @@ -8101,11 +8106,11 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', platform: 'apple', bundleId: 'com.contoso.app' - } + }) }); assert(loggerLogSpy.calledWith({ appId: '1ce0287c-9ccc-457e-a0cf-3ec5b734c092', @@ -8199,12 +8204,12 @@ describe(commands.APP_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My Microsoft Entra app', platform: 'android', bundleId: 'com.contoso.app', signatureHash: '2pmj9i4rSx0yEb/viWBYkE/ZQrk=' - } + }) }); assert(loggerLogSpy.calledWith({ appId: '1ce0287c-9ccc-457e-a0cf-3ec5b734c092', diff --git a/src/m365/entra/commands/app/app-add.ts b/src/m365/entra/commands/app/app-add.ts index cbc4ab86049..7eabcafe37d 100644 --- a/src/m365/entra/commands/app/app-add.ts +++ b/src/m365/entra/commands/app/app-add.ts @@ -1,35 +1,55 @@ import fs from 'fs'; import { v4 } from 'uuid'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { accessToken } from '../../../../utils/accessToken.js'; import { AppCreationOptions, AppInfo, entraApp } from '../../../../utils/entraApp.js'; +import { optionsUtils } from '../../../../utils/optionsUtils.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import { M365RcJson } from '../../../base/M365RcJson.js'; import commands from '../../commands.js'; -import { optionsUtils } from '../../../../utils/optionsUtils.js'; + +const entraApplicationPlatform = ['spa', 'web', 'publicClient', 'apple', 'android'] as const; +const entraAppScopeConsentBy = ['admins', 'adminsAndUsers'] as const; + +const options = globalOptionsZod + .extend({ + name: zod.alias('n', z.string().optional()), + multitenant: z.boolean().optional(), + redirectUris: zod.alias('r', z.string().optional()), + platform: zod.alias('p', z.enum(entraApplicationPlatform).optional()), + implicitFlow: z.boolean().optional(), + withSecret: zod.alias('s', z.boolean().optional()), + apisDelegated: z.string().optional(), + apisApplication: z.string().optional(), + uri: zod.alias('u', z.string().optional()), + scopeName: z.string().optional(), + scopeConsentBy: z.enum(entraAppScopeConsentBy).optional(), + scopeAdminConsentDisplayName: z.string().optional(), + scopeAdminConsentDescription: z.string().optional(), + certificateFile: z.string().optional(), + certificateBase64Encoded: z.string().optional(), + certificateDisplayName: z.string().optional(), + manifest: z.string().optional(), + save: z.boolean().optional(), + grantAdminConsent: z.boolean().optional(), + allowPublicClientFlows: z.boolean().optional(), + bundleId: z.string().optional(), + signatureHash: z.string().optional() + }) + .passthrough(); + +declare type Options = z.infer & AppCreationOptions; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions, AppCreationOptions { - grantAdminConsent?: boolean; - manifest?: string; - save?: boolean; - scopeAdminConsentDescription?: string; - scopeAdminConsentDisplayName?: string; - scopeConsentBy?: string; - scopeName?: string; - uri?: string; - withSecret: boolean; -} - class EntraAppAddCommand extends GraphCommand { - private static entraApplicationPlatform: string[] = ['spa', 'web', 'publicClient', 'apple', 'android']; - private static entraAppScopeConsentBy: string[] = ['admins', 'adminsAndUsers']; private manifest: any; private appName: string = ''; @@ -45,189 +65,105 @@ class EntraAppAddCommand extends GraphCommand { return true; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - apis: typeof args.options.apisDelegated !== 'undefined', - implicitFlow: args.options.implicitFlow, - multitenant: args.options.multitenant, - platform: args.options.platform, - redirectUris: typeof args.options.redirectUris !== 'undefined', - scopeAdminConsentDescription: typeof args.options.scopeAdminConsentDescription !== 'undefined', - scopeAdminConsentDisplayName: typeof args.options.scopeAdminConsentDisplayName !== 'undefined', - scopeConsentBy: args.options.scopeConsentBy, - scopeName: typeof args.options.scopeName !== 'undefined', - uri: typeof args.options.uri !== 'undefined', - withSecret: args.options.withSecret, - certificateFile: typeof args.options.certificateFile !== 'undefined', - certificateBase64Encoded: typeof args.options.certificateBase64Encoded !== 'undefined', - certificateDisplayName: typeof args.options.certificateDisplayName !== 'undefined', - grantAdminConsent: typeof args.options.grantAdminConsent !== 'undefined', - allowPublicClientFlows: typeof args.options.allowPublicClientFlows !== 'undefined', - bundleId: typeof args.options.bundleId !== 'undefined', - signatureHash: typeof args.options.signatureHash !== 'undefined' - }); - }); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: '-n, --name [name]' - }, - { - option: '--multitenant' - }, - { - option: '-r, --redirectUris [redirectUris]' - }, - { - option: '-p, --platform [platform]', - autocomplete: EntraAppAddCommand.entraApplicationPlatform - }, - { - option: '--implicitFlow' - }, - { - option: '-s, --withSecret' - }, - { - option: '--apisDelegated [apisDelegated]' - }, - { - option: '--apisApplication [apisApplication]' - }, - { - option: '-u, --uri [uri]' - }, - { - option: '--scopeName [scopeName]' - }, - { - option: '--scopeConsentBy [scopeConsentBy]', - autocomplete: EntraAppAddCommand.entraAppScopeConsentBy - }, - { - option: '--scopeAdminConsentDisplayName [scopeAdminConsentDisplayName]' - }, - { - option: '--scopeAdminConsentDescription [scopeAdminConsentDescription]' - }, - { - option: '--certificateFile [certificateFile]' - }, - { - option: '--certificateBase64Encoded [certificateBase64Encoded]' - }, - { - option: '--certificateDisplayName [certificateDisplayName]' - }, - { - option: '--manifest [manifest]' - }, - { - option: '--bundleId [bundleId]' - }, - { - option: '--signatureHash [signatureHash]' - }, - { - option: '--save' - }, - { - option: '--grantAdminConsent' - }, - { - option: '--allowPublicClientFlows' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.platform && - EntraAppAddCommand.entraApplicationPlatform.indexOf(args.options.platform) < 0) { - return `${args.options.platform} is not a valid value for platform. Allowed values are ${EntraAppAddCommand.entraApplicationPlatform.join(', ')}`; - } - - if (args.options.redirectUris && !args.options.platform) { - return `When you specify redirectUris you also need to specify platform`; + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => { + if (options.redirectUris && !options.platform) { + return false; } - - if (args.options.platform && ['spa', 'web', 'publicClient'].indexOf(args.options.platform) > -1 && !args.options.redirectUris) { - return `When you use platform spa, web or publicClient, you'll need to specify redirectUris`; + return true; + }, { + message: 'When you specify redirectUris you also need to specify platform' + }) + .refine(options => { + if (options.platform && ['spa', 'web', 'publicClient'].includes(options.platform) && !options.redirectUris) { + return false; } - - if (args.options.certificateFile && args.options.certificateBase64Encoded) { - return 'Specify either certificateFile or certificateBase64Encoded but not both'; + return true; + }, { + message: 'When you use platform spa, web or publicClient, you\'ll need to specify redirectUris' + }) + .refine(options => { + if (options.certificateFile && options.certificateBase64Encoded) { + return false; } - - if (args.options.certificateDisplayName && !args.options.certificateFile && !args.options.certificateBase64Encoded) { - return 'When you specify certificateDisplayName you also need to specify certificateFile or certificateBase64Encoded'; + return true; + }, { + message: 'Specify either certificateFile or certificateBase64Encoded but not both' + }) + .refine(options => { + if (options.certificateDisplayName && !options.certificateFile && !options.certificateBase64Encoded) { + return false; } - - if (args.options.certificateFile && !fs.existsSync(args.options.certificateFile as string)) { - return 'Certificate file not found'; + return true; + }, { + message: 'When you specify certificateDisplayName you also need to specify certificateFile or certificateBase64Encoded' + }) + .refine(options => { + if (options.certificateFile && !fs.existsSync(options.certificateFile)) { + return false; } - - if (args.options.scopeName) { - if (!args.options.uri) { - return `When you specify scopeName you also need to specify uri`; + return true; + }, { + message: 'Certificate file not found' + }) + .refine(options => { + if (options.scopeName) { + if (!options.uri) { + return false; } - - if (!args.options.scopeAdminConsentDescription) { - return `When you specify scopeName you also need to specify scopeAdminConsentDescription`; + if (!options.scopeAdminConsentDescription) { + return false; } - - if (!args.options.scopeAdminConsentDisplayName) { - return `When you specify scopeName you also need to specify scopeAdminConsentDisplayName`; + if (!options.scopeAdminConsentDisplayName) { + return false; } } - - if (args.options.scopeConsentBy && - EntraAppAddCommand.entraAppScopeConsentBy.indexOf(args.options.scopeConsentBy) < 0) { - return `${args.options.scopeConsentBy} is not a valid value for scopeConsentBy. Allowed values are ${EntraAppAddCommand.entraAppScopeConsentBy.join(', ')}`; - } - - if (args.options.manifest) { + return true; + }, { + message: 'When you specify scopeName you also need to specify uri, scopeAdminConsentDescription, and scopeAdminConsentDisplayName' + }) + .refine(options => { + if (options.manifest) { try { - this.manifest = JSON.parse(args.options.manifest); - if (!args.options.name && !this.manifest.name) { - return `Specify the name of the app to create either through the 'name' option or the 'name' property in the manifest`; + const manifest = JSON.parse(options.manifest); + if (!options.name && !manifest.name) { + return false; } + this.manifest = manifest; + return true; } catch (e) { - return `Error while parsing the specified manifest: ${e}`; + return false; } } - - if (args.options.platform === 'apple' && !args.options.bundleId) { - return `When you use platform apple, you'll need to specify bundleId`; + return true; + }, { + message: 'Specify the name of the app to create either through the \'name\' option or the \'name\' property in the manifest' + }) + .refine(options => options.name || options.manifest, { + message: 'Specify either name or manifest' + }) + .refine(options => { + if (options.platform === 'apple' && !options.bundleId) { + return false; } - - if (args.options.platform === 'android' && (!args.options.bundleId || !args.options.signatureHash)) { - return `When you use platform android, you'll need to specify bundleId and signatureHash`; + return true; + }, { + message: 'When you use platform apple, you\'ll need to specify bundleId' + }) + .refine(options => { + if (options.platform === 'android' && (!options.bundleId || !options.signatureHash)) { + return false; } - return true; - }, - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['name', 'manifest'] } - ); + }, { + message: 'When you use platform android, you\'ll need to specify bundleId and signatureHash' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -246,7 +182,7 @@ class EntraAppAddCommand extends GraphCommand { }); let appInfo: any = await entraApp.createAppRegistration({ options: args.options, - unknownOptions: optionsUtils.getUnknownOptions(args.options, this.options), + unknownOptions: optionsUtils.getUnknownOptions(args.options, zod.schemaToOptions(this.schema!)), apis, logger, verbose: this.verbose, diff --git a/src/m365/entra/commands/app/app-get.spec.ts b/src/m365/entra/commands/app/app-get.spec.ts index b063a9a671f..00b45877bb2 100644 --- a/src/m365/entra/commands/app/app-get.spec.ts +++ b/src/m365/entra/commands/app/app-get.spec.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -8,19 +9,19 @@ 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 { settingsNames } from '../../../../settingsNames.js'; -import { entraApp } from '../../../../utils/entraApp.js'; describe(commands.APP_GET, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; //#region Mocked Responses const appResponse = { @@ -41,6 +42,7 @@ describe(commands.APP_GET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -85,14 +87,78 @@ describe(commands.APP_GET, () => { assert.notStrictEqual(command.description, null); }); + it('fails validation when neither appId, objectId, nor name are specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when appId and objectId are both specified', () => { + const actual = commandOptionsSchema.safeParse({ + appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', + objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when appId and name are both specified', () => { + const actual = commandOptionsSchema.safeParse({ + appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', + name: 'My app' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when objectId and name are both specified', () => { + const actual = commandOptionsSchema.safeParse({ + objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', + name: 'My app' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when appId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + appId: 'abc' + }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation when objectId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + objectId: 'abc' + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation when appId is specified', () => { + const actual = commandOptionsSchema.safeParse({ + appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation when objectId is specified', () => { + const actual = commandOptionsSchema.safeParse({ + objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation when name is specified', () => { + const actual = commandOptionsSchema.safeParse({ + name: 'My app' + }); + assert.strictEqual(actual.success, true); + }); + it('handles error when the app specified with the appId not found', async () => { const error = `App with appId '9b1b1e42-794b-4c71-93ac-5ed92488b67f' not found in Microsoft Entra ID`; 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`)); }); @@ -101,9 +167,9 @@ describe(commands.APP_GET, () => { sinon.stub(entraApp, 'getAppRegistrationByAppName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My app' - } + }) }), new CommandError(`App with name 'My app' not found in Microsoft Entra ID`)); }); @@ -112,97 +178,69 @@ describe(commands.APP_GET, () => { sinon.stub(entraApp, 'getAppRegistrationByAppName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My app' - } + }) }), new CommandError(error)); }); - it('handles error when retrieving information about app through name failed', async () => { - sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - - await assert.rejects(command.action(logger, { - options: { - name: 'My app' - } - } as any), new CommandError('An error has occurred')); - }); - - it('fails validation if appId and objectId specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; + it('handles selecting single result when multiple apps with the specified name found and cli is set to prompt', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/applications?$filter=displayName eq 'My%20App'`) { + return { + value: [ + { + appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', + createdDateTime: '2019-10-29T17:46:55Z', + description: null, + displayName: 'My App', + id: '340a4aa3-1af6-43ac-87d8-189819003952' + }, + { + appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', + createdDateTime: '2019-10-29T17:46:55Z', + description: null, + displayName: 'My App', + id: '340a4aa3-1af6-43ac-87d8-189819003952' + }] + }; } - return defaultValue; + throw `Invalid request ${JSON.stringify(opts)}`; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8' } }, commandInfo); - assert.notStrictEqual(actual, true); - }); - - it('fails validation if appId and name specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; + sinon.stub(cli, 'handleMultipleResultsFound').resolves({ + appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', + createdDateTime: '2019-10-29T17:46:55Z', + description: null, + displayName: 'My App', + id: '340a4aa3-1af6-43ac-87d8-189819003952' }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' } }, commandInfo); - assert.notStrictEqual(actual, true); - }); - - it('fails validation if objectId and name specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; + await command.action(logger, { + options: commandOptionsSchema.parse({ + name: 'My App', + debug: true + }) }); - - const actual = await command.validate({ options: { objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' } }, commandInfo); - assert.notStrictEqual(actual, true); - }); - - it('fails validation if neither appId, objectId, nor name specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; + const call: sinon.SinonSpyCall = loggerLogSpy.lastCall; + assert.deepEqual(call.args[0], { + "id": "340a4aa3-1af6-43ac-87d8-189819003952", + "appId": "9b1b1e42-794b-4c71-93ac-5ed92488b67f", + "createdDateTime": "2019-10-29T17:46:55Z", + "displayName": "My App", + "description": null }); - - const actual = await command.validate({ options: {} }, commandInfo); - assert.notStrictEqual(actual, true); - }); - - it('fails validation if the objectId is not a valid guid', async () => { - const actual = await command.validate({ options: { objectId: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); }); - 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('passes validation if required options specified (appId)', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.strictEqual(actual, true); - }); - - it('passes validation if required options specified (objectId)', async () => { - const actual = await command.validate({ options: { objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.strictEqual(actual, true); - }); + it('handles error when retrieving information about app through name failed', async () => { + sinon.stub(request, 'get').rejects(new Error('An error has occurred')); - it('passes validation if required options specified (name)', async () => { - const actual = await command.validate({ options: { name: 'My app' } }, commandInfo); - assert.strictEqual(actual, true); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + name: 'My app' + }) + }), new CommandError('An error has occurred')); }); it(`should get an Microsoft Entra app registration by its app (client) ID. Doesn't save the app info if not requested`, async () => { @@ -211,11 +249,11 @@ describe(commands.APP_GET, () => { const fsWriteFileSyncSpy = sinon.spy(fs, 'writeFileSync'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', properties: 'id,appId,displayName', verbose: true - } + }) }); const call: sinon.SinonSpyCall = loggerLogSpy.lastCall; assert.strictEqual(call.args[0].id, '340a4aa3-1af6-43ac-87d8-189819003952'); @@ -229,10 +267,10 @@ describe(commands.APP_GET, () => { const fsWriteFileSyncSpy = sinon.spy(fs, 'writeFileSync'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My App', verbose: true - } + }) }); const call: sinon.SinonSpyCall = loggerLogSpy.lastCall; assert.strictEqual(call.args[0].id, '340a4aa3-1af6-43ac-87d8-189819003952'); @@ -246,11 +284,11 @@ describe(commands.APP_GET, () => { const fsWriteFileSyncSpy = sinon.spy(fs, 'writeFileSync'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ objectId: '340a4aa3-1af6-43ac-87d8-189819003952', properties: 'id,appId,displayName', verbose: true - } + }) }); const call: sinon.SinonSpyCall = loggerLogSpy.lastCall; assert.strictEqual(call.args[0].id, '340a4aa3-1af6-43ac-87d8-189819003952'); @@ -272,10 +310,10 @@ describe(commands.APP_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', save: true - } + }) }); const call: sinon.SinonSpyCall = loggerLogSpy.lastCall; assert.strictEqual(call.args[0].id, '340a4aa3-1af6-43ac-87d8-189819003952'); @@ -304,10 +342,10 @@ describe(commands.APP_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', save: true - } + }) }); const call: sinon.SinonSpyCall = loggerLogSpy.lastCall; assert.strictEqual(call.args[0].id, '340a4aa3-1af6-43ac-87d8-189819003952'); @@ -342,10 +380,10 @@ describe(commands.APP_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', save: true - } + }) }); const call: sinon.SinonSpyCall = loggerLogSpy.lastCall; assert.strictEqual(call.args[0].id, '340a4aa3-1af6-43ac-87d8-189819003952'); @@ -385,11 +423,11 @@ describe(commands.APP_GET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', save: true - } + }) }); const call: sinon.SinonSpyCall = loggerLogSpy.lastCall; assert.strictEqual(call.args[0].id, '340a4aa3-1af6-43ac-87d8-189819003952'); @@ -416,10 +454,10 @@ describe(commands.APP_GET, () => { const fsWriteFileSyncSpy = sinon.spy(fs, 'writeFileSync'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', save: true - } + }) }); assert(fsWriteFileSyncSpy.notCalled); }); @@ -431,10 +469,10 @@ describe(commands.APP_GET, () => { const fsWriteFileSyncSpy = sinon.spy(fs, 'writeFileSync'); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', save: true - } + }) }); assert(fsWriteFileSyncSpy.notCalled); }); @@ -446,10 +484,10 @@ describe(commands.APP_GET, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', save: true - } + }) }); }); }); diff --git a/src/m365/entra/commands/app/app-get.ts b/src/m365/entra/commands/app/app-get.ts index 8320dfd5ee1..af92acc79b2 100644 --- a/src/m365/entra/commands/app/app-get.ts +++ b/src/m365/entra/commands/app/app-get.ts @@ -1,25 +1,31 @@ import { Application } from '@microsoft/microsoft-graph-types'; import fs from 'fs'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import { M365RcJson } from '../../../base/M365RcJson.js'; import commands from '../../commands.js'; import { entraApp } from '../../../../utils/entraApp.js'; +const options = globalOptionsZod + .extend({ + appId: z.string().optional(), + objectId: z.string().optional(), + name: z.string().optional(), + save: z.boolean().optional(), + properties: zod.alias('p', z.string().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { - appId?: string; - objectId?: string; - name?: string; - save?: boolean; - properties?: string; -} - class EntraAppGetCommand extends GraphCommand { public get name(): string { return commands.APP_GET; @@ -29,54 +35,21 @@ class EntraAppGetCommand extends GraphCommand { return 'Gets an Entra app registration'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - appId: typeof args.options.appId !== 'undefined', - objectId: typeof args.options.objectId !== 'undefined', - name: typeof args.options.name !== 'undefined', - properties: typeof args.options.properties !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.appId, options.objectId, options.name].filter(Boolean).length === 1, { + message: 'Specify either appId, objectId, or name but not multiple' + }) + .refine(options => !options.appId || validation.isValidGuid(options.appId), { + message: 'The appId is not a valid GUID' + }) + .refine(options => !options.objectId || validation.isValidGuid(options.objectId), { + message: 'The objectId is not a valid GUID' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '--appId [appId]' }, - { option: '--objectId [objectId]' }, - { option: '--name [name]' }, - { option: '--save' }, - { option: '-p, --properties [properties]' } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.appId && !validation.isValidGuid(args.options.appId as string)) { - return `${args.options.appId} is not a valid GUID`; - } - - if (args.options.objectId && !validation.isValidGuid(args.options.objectId as string)) { - return `${args.options.objectId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['appId', 'objectId', 'name'] }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/app/app-list.spec.ts b/src/m365/entra/commands/app/app-list.spec.ts index 39861101b2b..a0e42348c71 100644 --- a/src/m365/entra/commands/app/app-list.spec.ts +++ b/src/m365/entra/commands/app/app-list.spec.ts @@ -1,6 +1,9 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -15,6 +18,8 @@ describe(commands.APP_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -22,6 +27,8 @@ describe(commands.APP_LIST, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -63,6 +70,16 @@ describe(commands.APP_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['appId', 'id', 'displayName', 'signInAudience']); }); + it('passes validation when no properties are specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, true); + }); + + it('passes validation when properties are specified', () => { + const actual = commandOptionsSchema.safeParse({ properties: 'id,displayName' }); + assert.strictEqual(actual.success, true); + }); + it(`should get a list of Microsoft Entra app registrations`, async () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/applications`) { @@ -90,7 +107,7 @@ describe(commands.APP_LIST, () => { }); await command.action(logger, { - options: {} + options: commandOptionsSchema.parse({}) }); assert( @@ -134,7 +151,7 @@ describe(commands.APP_LIST, () => { }); await command.action(logger, { - options: { properties: 'id,displayName' } + options: commandOptionsSchema.parse({ properties: 'id,displayName' }) }); assert( @@ -160,7 +177,9 @@ describe(commands.APP_LIST, () => { }); await assert.rejects( - command.action(logger, { options: {} } as any), + command.action(logger, { + options: commandOptionsSchema.parse({}) + }), new CommandError('An error has occurred') ); }); diff --git a/src/m365/entra/commands/app/app-list.ts b/src/m365/entra/commands/app/app-list.ts index 9d906d481d6..71eb7b79469 100644 --- a/src/m365/entra/commands/app/app-list.ts +++ b/src/m365/entra/commands/app/app-list.ts @@ -1,18 +1,24 @@ import { Application } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; import { odata } from "../../../../utils/odata.js"; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; + +const options = globalOptionsZod + .extend({ + properties: zod.alias('p', z.string().optional()) + }) + .strict(); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -export interface Options extends GlobalOptions { - properties?: string; -} - class EntraAppListCommand extends GraphCommand { public get name(): string { return commands.APP_LIST; @@ -22,31 +28,14 @@ class EntraAppListCommand extends GraphCommand { return 'Retrieves a list of Entra app registrations'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); + public get schema(): z.ZodTypeAny | undefined { + return options; } public defaultProperties(): string[] | undefined { return ['appId', 'id', 'displayName', "signInAudience"]; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - properties: typeof args.options.properties !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-p, --properties [properties]' } - ); - } - public async commandAction(logger: Logger, args: CommandArgs): Promise { const queryParameters: string[] = []; diff --git a/src/m365/entra/commands/app/app-permission-add.spec.ts b/src/m365/entra/commands/app/app-permission-add.spec.ts index 6549e3aef3f..b5448f7d0b7 100644 --- a/src/m365/entra/commands/app/app-permission-add.spec.ts +++ b/src/m365/entra/commands/app/app-permission-add.spec.ts @@ -1,12 +1,14 @@ import { Application, ServicePrincipal } from '@microsoft/microsoft-graph-types'; import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { CommandError } from '../../../../Command.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.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'; @@ -14,7 +16,6 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './app-permission-add.js'; -import { settingsNames } from '../../../../settingsNames.js'; import { entraApp } from "../../../../utils/entraApp.js"; describe(commands.APP_PERMISSION_ADD, () => { @@ -24,12 +25,14 @@ describe(commands.APP_PERMISSION_ADD, () => { const servicePrincipalId = '7c330108-8825-4b6c-b280-8d1d68da6bd7'; const servicePrincipals: ServicePrincipal[] = [{ "appId": appId, 'id': servicePrincipalId, "servicePrincipalNames": [] }, { "appId": "00000003-0000-0000-c000-000000000000", "id": "fb4be1df-eaa6-4bd0-a068-71f9b2cbe2be", "servicePrincipalNames": ["https://canary.graph.microsoft.com/", "https://graph.microsoft.us/", "https://dod-graph.microsoft.us/", "00000003-0000-0000-c000-000000000000/ags.windows.net", "00000003-0000-0000-c000-000000000000", "https://canary.graph.microsoft.com", "https://graph.microsoft.com", "https://ags.windows.net", "https://graph.microsoft.us", "https://graph.microsoft.com/", "https://dod-graph.microsoft.us"], "appRoles": [{ "allowedMemberTypes": ["Application"], "description": "Allows the app to read and update user profiles without a signed in user.", "displayName": "Read and write all users' full profiles", "id": "741f803b-c850-494e-b5df-cde7c675a1ca", "isEnabled": true, "origin": "Application", "value": "User.ReadWrite.All" }, { "allowedMemberTypes": ["Application"], "description": "Allows the app to read user profiles without a signed in user.", "displayName": "Read all users' full profiles", "id": "df021288-bdef-4463-88db-98f22de89214", "isEnabled": true, "origin": "Application", "value": "User.Read.All" }, { "allowedMemberTypes": ["Application"], "description": "Allows the app to read and query your audit log activities, without a signed-in user.", "displayName": "Read all audit log data", "id": "b0afded3-3588-46d8-8b3d-9842eff778da", "isEnabled": true, "origin": "Application", "value": "AuditLog.Read.All" }], "oauth2PermissionScopes": [{ "adminConsentDescription": "Allows the app to see and update the data you gave it access to, even when users are not currently using the app. This does not give the app any additional permissions.", "adminConsentDisplayName": "Maintain access to data you have given it access to", "id": "7427e0e9-2fba-42fe-b0c0-848c9e6a8182", "isEnabled": true, "type": "User", "userConsentDescription": "Allows the app to see and update the data you gave it access to, even when you are not currently using the app. This does not give the app any additional permissions.", "userConsentDisplayName": "Maintain access to data you have given it access to", "value": "offline_access" }, { "adminConsentDescription": "Allows the app to read the available Teams templates, on behalf of the signed-in user.", "adminConsentDisplayName": "Read available Teams templates", "id": "cd87405c-5792-4f15-92f7-debc0db6d1d6", "isEnabled": true, "type": "User", "userConsentDescription": "Read available Teams templates, on your behalf.", "userConsentDisplayName": "Read available Teams templates", "value": "TeamTemplates.Read" }] }]; const applications: Application[] = [{ 'id': appObjectId, 'appId': appId, 'requiredResourceAccess': [] }]; + const multipleApplications: Application[] = [{ 'id': appObjectId, 'appId': appId, 'requiredResourceAccess': [] }, { 'id': '2aaf2d9e-815e-4a3e-bb80-9b3d9c79078c', 'appId': '9c79078b-815e-4a3e-bb80-2aaf2d9e9b3e', 'requiredResourceAccess': [] }]; const applicationPermissions = 'https://graph.microsoft.com/User.ReadWrite.All https://graph.microsoft.com/User.Read.All'; const delegatedPermissions = 'https://graph.microsoft.com/offline_access'; let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -38,6 +41,7 @@ describe(commands.APP_PERMISSION_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { if (settingName === 'prompt') { return false; @@ -106,7 +110,7 @@ describe(commands.APP_PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { appObjectId: appObjectId, applicationPermissions: applicationPermissions, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: appObjectId, applicationPermissions: applicationPermissions, verbose: true }) }); assert(patchStub.called); }); @@ -128,7 +132,7 @@ describe(commands.APP_PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { appName: appName, applicationPermissions: applicationPermissions, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appName: appName, applicationPermissions: applicationPermissions, verbose: true }) }); assert(patchStub.called); }); @@ -150,7 +154,7 @@ describe(commands.APP_PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { appId: appId, applicationPermissions: applicationPermissions, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: applicationPermissions, verbose: true }) }); assert(patchStub.called); }); @@ -181,7 +185,7 @@ describe(commands.APP_PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { appId: appId, applicationPermissions: applicationPermissions, grantAdminConsent: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: applicationPermissions, grantAdminConsent: true, verbose: true }) }); assert.strictEqual(amountOfPostCalls, 2); }); @@ -217,7 +221,7 @@ describe(commands.APP_PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { appId: appId, applicationPermissions: applicationPermissions, grantAdminConsent: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: applicationPermissions, grantAdminConsent: true, verbose: true }) }); assert.strictEqual(numberOfPostCalls, 3); }); @@ -253,10 +257,35 @@ describe(commands.APP_PERMISSION_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { appId: appId, delegatedPermissions: delegatedPermissions, applicationPermissions: applicationPermissions, grantAdminConsent: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, delegatedPermissions: delegatedPermissions, applicationPermissions: applicationPermissions, grantAdminConsent: true, verbose: true }) }); assert.strictEqual(amountOfPostCalls, 3); }); + it('throws an error when application specified by appId is not found', async () => { + sinon.stub(odata, 'getAllItems').callsFake(async (url: string) => { + switch (url) { + case 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId,appRoles,id,oauth2PermissionScopes,servicePrincipalNames': + return servicePrincipals; + case `https://graph.microsoft.com/v1.0/applications?$filter=appId eq '${appId}'&$select=id,appId,requiredResourceAccess`: + return []; + default: + throw 'Invalid request'; + } + }); + + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: applicationPermissions, verbose: true }) }), + new CommandError(`App with appId '${appId}' not found in Microsoft Entra ID`)); + }); + + it('throws an error when application specified by appObjectId is not found', async () => { + sinon.stub(entraApp, 'getAppRegistrationByObjectId').callsFake(async (objectId: string) => { + throw `App with objectId '${objectId}' not found in Microsoft Entra ID`; + }); + + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: appObjectId, applicationPermissions: applicationPermissions, verbose: true }) }), + new CommandError(`App with objectId '${appObjectId}' not found in Microsoft Entra ID`)); + }); + it('throws an error when service principal is not found', async () => { const api = 'https://grax.microsoft.com/User.ReadWrite.All'; const pos: number = api.lastIndexOf('/'); @@ -272,7 +301,7 @@ describe(commands.APP_PERMISSION_ADD, () => { } }); - await assert.rejects(command.action(logger, { options: { appId: appId, applicationPermissions: api, verbose: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: api, verbose: true }) }), new CommandError(`Service principal ${servicePrincipalName} not found`)); }); @@ -292,23 +321,92 @@ describe(commands.APP_PERMISSION_ADD, () => { } }); - await assert.rejects(command.action(logger, { options: { appId: appId, applicationPermissions: api, verbose: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: api, verbose: true }) }), new CommandError(`Permission ${permissionName} for service principal ${servicePrincipalName} not found`)); }); + it('handles error when multiple apps with the specified name found', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + sinon.stub(odata, 'getAllItems').callsFake(async (url: string) => { + switch (url) { + case 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId,appRoles,id,oauth2PermissionScopes,servicePrincipalNames': + return servicePrincipals; + case `https://graph.microsoft.com/v1.0/applications?$filter=displayName eq 'My%20App'&$select=id,appId,requiredResourceAccess`: + return [{ 'id': '9b1b1e42-794b-4c71-93ac-5ed92488b67f', 'appId': appId, 'requiredResourceAccess': [] }, { 'id': '9b1b1e42-794b-4c71-93ac-5ed92488b67g', 'appId': appId, 'requiredResourceAccess': [] }]; + default: + throw 'Invalid request'; + } + }); + + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + appName: appName, + applicationPermissions: applicationPermissions + }) + }), new CommandError(`Multiple apps with name 'My App' found in Microsoft Entra ID. Found: 9b1b1e42-794b-4c71-93ac-5ed92488b67f, 9b1b1e42-794b-4c71-93ac-5ed92488b67g.`)); + }); + + it('handles a non-existent app by appName', async () => { + sinon.stub(odata, 'getAllItems').callsFake(async (url: string) => { + switch (url) { + case 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId,appRoles,id,oauth2PermissionScopes,servicePrincipalNames': + return servicePrincipals; + case `https://graph.microsoft.com/v1.0/applications?$filter=displayName eq 'My%20App'&$select=id,appId,requiredResourceAccess`: + return []; + default: + throw 'Invalid request'; + } + }); + + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ appName: appName, applicationPermissions: applicationPermissions }) }), + new CommandError(`App with name '${appName}' not found in Microsoft Entra ID`)); + }); + + it('handles selecting single result when multiple apps with the specified name found and cli is set to prompt', async () => { + sinon.stub(odata, 'getAllItems').callsFake(async (url: string) => { + switch (url) { + case 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId,appRoles,id,oauth2PermissionScopes,servicePrincipalNames': + return servicePrincipals; + case `https://graph.microsoft.com/v1.0/applications?$filter=displayName eq 'My%20App'&$select=id,appId,requiredResourceAccess`: + return multipleApplications; + default: + throw 'Invalid request'; + } + }); + + sinon.stub(cli, 'handleMultipleResultsFound').resolves(multipleApplications[0]); + + const patchStub = sinon.stub(request, 'patch').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/applications/${multipleApplications[0].id}`) { + return; + } + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ appName: appName, applicationPermissions: applicationPermissions, verbose: true }) }); + assert(patchStub.called); + }); + it('passes validation if applicationPermission is passed', async () => { - const actual = await command.validate({ options: { appId: appId, applicationPermissions: applicationPermissions } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: appId, applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, true); }); it('passes validation if delegatedPermission is passed', async () => { - const actual = await command.validate({ options: { appId: appId, delegatedPermissions: delegatedPermissions } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: appId, delegatedPermissions: delegatedPermissions }); + assert.strictEqual(actual.success, true); }); it('passes validation if both applicationPermission or delegatedPermission are passed', async () => { - const actual = await command.validate({ options: { appId: appId, applicationPermissions: applicationPermissions, delegatedPermissions: delegatedPermissions } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: appId, applicationPermissions: applicationPermissions, delegatedPermissions: delegatedPermissions }); + assert.strictEqual(actual.success, true); }); it('fails validation if both applicationPermission or delegatedPermission is not passed', async () => { @@ -320,28 +418,28 @@ describe(commands.APP_PERMISSION_ADD, () => { return defaultValue; }); - const actual = await command.validate({ options: {} }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, false); }); it('fails validation if the appId is not a valid GUID', async () => { - const actual = await command.validate({ options: { appId: '123', applicationPermissions: applicationPermissions } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '123', applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, false); }); it('passes validation if the appId is a valid GUID', async () => { - const actual = await command.validate({ options: { appId: appId, applicationPermissions: applicationPermissions } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: appId, applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, true); }); it('fails validation if the appObjectId is not a valid GUID', async () => { - const actual = await command.validate({ options: { appObjectId: '123', applicationPermissions: applicationPermissions } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: '123', applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, false); }); it('passes validation if the appObjectId is a valid GUID', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId, applicationPermissions: applicationPermissions } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId, applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, true); }); it('fails validation if neither the appId nor the appObjectId are provided.', async () => { @@ -353,12 +451,10 @@ describe(commands.APP_PERMISSION_ADD, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - applicationPermissions: applicationPermissions - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + applicationPermissions: applicationPermissions + }); + assert.strictEqual(actual.success, false); }); it('fails validation when both appId and appObjectId are specified', async () => { @@ -370,13 +466,11 @@ describe(commands.APP_PERMISSION_ADD, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - appId: appId, - appObjectId: appObjectId, - applicationPermissions: applicationPermissions - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + appId: appId, + appObjectId: appObjectId, + applicationPermissions: applicationPermissions + }); + assert.strictEqual(actual.success, false); }); }); \ No newline at end of file diff --git a/src/m365/entra/commands/app/app-permission-add.ts b/src/m365/entra/commands/app/app-permission-add.ts index 0401853807b..f8f2db70968 100644 --- a/src/m365/entra/commands/app/app-permission-add.ts +++ b/src/m365/entra/commands/app/app-permission-add.ts @@ -1,27 +1,32 @@ import { AppRole, Application, PermissionScope, RequiredResourceAccess, ResourceAccess, ServicePrincipal } 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 { odata } from "../../../../utils/odata.js"; +import { zod } from "../../../../utils/zod.js"; import GraphCommand from "../../../base/GraphCommand.js"; import commands from "../../commands.js"; -import request, { CliRequestOptions } from "../../../../request.js"; -import { Logger } from "../../../../cli/Logger.js"; -import { validation } from "../../../../utils/validation.js"; import { entraApp } from "../../../../utils/entraApp.js"; import { entraServicePrincipal } from "../../../../utils/entraServicePrincipal.js"; +const options = globalOptionsZod + .extend({ + appId: zod.alias('i', z.string().uuid().optional()), + appName: zod.alias('n', z.string().optional()), + appObjectId: z.string().uuid().optional(), + applicationPermissions: zod.alias('a', z.string().optional()), + delegatedPermissions: zod.alias('d', z.string().optional()), + grantAdminConsent: z.boolean().optional() + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appId?: string; - appName?: string; - appObjectId?: string; - applicationPermissions?: string; - delegatedPermissions?: string; - grantAdminConsent?: boolean; -} - interface AppPermission { resourceId: string; resourceAccess: ResourceAccess[]; @@ -42,61 +47,18 @@ class EntraAppPermissionAddCommand extends GraphCommand { return 'Adds the specified application and/or delegated permissions to a specified Microsoft Entra app'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - appId: typeof args.options.appId !== 'undefined', - appName: typeof args.options.appName !== 'undefined', - appObjectId: typeof args.options.appObjectId !== '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.appId, options.appName, options.appObjectId].filter(Boolean).length === 1, { + message: 'Specify either appId, appName, or appObjectId' + }) + .refine(options => options.applicationPermissions || options.delegatedPermissions, { + message: 'Specify either applicationPermissions or delegatedPermissions' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-i, --appId [appId]' }, - { option: '-n, --appName [appName]' }, - { option: '--appObjectId [appObjectId]' }, - { option: '-a, --applicationPermissions [applicationPermissions]' }, - { option: '-d, --delegatedPermissions [delegatedPermissions]' }, - { option: '--grantAdminConsent' } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.appId && !validation.isValidGuid(args.options.appId as string)) { - return `${args.options.appId} is not a valid GUID`; - } - - if (args.options.appObjectId && !validation.isValidGuid(args.options.appObjectId as string)) { - return `${args.options.appObjectId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['appId', 'appName', 'appObjectId'] }); - 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/entra/commands/app/app-permission-list.spec.ts b/src/m365/entra/commands/app/app-permission-list.spec.ts index d0253c4cf6c..83516edba2f 100644 --- a/src/m365/entra/commands/app/app-permission-list.spec.ts +++ b/src/m365/entra/commands/app/app-permission-list.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -35,6 +36,7 @@ describe(commands.APP_PERMISSION_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -43,6 +45,7 @@ describe(commands.APP_PERMISSION_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => { if (settingName === 'prompt') { return false; @@ -92,23 +95,23 @@ describe(commands.APP_PERMISSION_LIST, () => { }); it('fails validation if the appId is not a valid GUID', async () => { - const actual = await command.validate({ options: { appId: '123' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '123' }); + assert.strictEqual(actual.success, false); }); it('passes validation if the appId is a valid GUID', async () => { - const actual = await command.validate({ options: { appId: appId } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: appId }); + assert.strictEqual(actual.success, true); }); it('fails validation if the appObjectId is not a valid GUID', async () => { - const actual = await command.validate({ options: { appObjectId: '123' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: '123' }); + assert.strictEqual(actual.success, false); }); it('passes validation if the appObjectId is a valid GUID', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId }); + assert.strictEqual(actual.success, true); }); it('fails validation if neither the appId, appName, nor appObjectId are provided.', async () => { @@ -120,12 +123,8 @@ describe(commands.APP_PERMISSION_LIST, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - type: 'all' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ type: 'all' }); + assert.strictEqual(actual.success, false); }); it('fails validation when appId, appName, and appObjectId are specified', async () => { @@ -137,19 +136,17 @@ describe(commands.APP_PERMISSION_LIST, () => { return defaultValue; }); - const actual = await command.validate({ - options: { - appId: appId, - appName: appName, - appObjectId: appObjectId - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + appId: appId, + appName: appName, + appObjectId: appObjectId + }); + assert.strictEqual(actual.success, false); }); it('fails validation if the type is not a valid permission type', async () => { - const actual = await command.validate({ options: { appId: appId, type: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: appId, type: 'invalid' }); + assert.strictEqual(actual.success, false); }); it('lists the permissions of an app registration when using objectId', async () => { @@ -202,7 +199,7 @@ describe(commands.APP_PERMISSION_LIST, () => { }); - await command.action(logger, { options: { appObjectId: appObjectId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: appObjectId, verbose: true }) }); assert(loggerLogSpy.calledWith(allPermissionsResponse)); }); @@ -247,7 +244,7 @@ describe(commands.APP_PERMISSION_LIST, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await command.action(logger, { options: { appObjectId: appObjectId, type: 'application' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: appObjectId, type: 'application' }) }); assert(loggerLogSpy.calledWith(applicationPermissionsResponse)); }); @@ -298,7 +295,7 @@ describe(commands.APP_PERMISSION_LIST, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await command.action(logger, { options: { appId: appId, type: 'delegated', debug: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, type: 'delegated', debug: true }) }); assert(loggerLogSpy.calledWith(delegatedPermissionsResponse)); }); @@ -344,7 +341,7 @@ describe(commands.APP_PERMISSION_LIST, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await command.action(logger, { options: { appName: appName, type: 'delegated', verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appName: appName, type: 'delegated', verbose: true }) }); assert(loggerLogSpy.calledWith(delegatedPermissionsResponse)); }); @@ -353,9 +350,9 @@ describe(commands.APP_PERMISSION_LIST, () => { sinon.stub(entraApp, 'getAppRegistrationByAppName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: appName - } + }) }), new CommandError(error)); }); @@ -363,7 +360,7 @@ describe(commands.APP_PERMISSION_LIST, () => { const error = `App with appId '${appId}' not found in Microsoft Entra ID`; sinon.stub(entraApp, 'getAppRegistrationByAppId').rejects(new Error(error)); - await assert.rejects(command.action(logger, { options: { appId: appId } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ appId: appId }) }), new CommandError(error)); }); @@ -371,7 +368,7 @@ describe(commands.APP_PERMISSION_LIST, () => { const error = `App with name 'My app' not found in Microsoft Entra ID`; sinon.stub(entraApp, 'getAppRegistrationByAppName').rejects(new Error(error)); - await assert.rejects(command.action(logger, { options: { appName: appName } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ appName: appName }) }), new CommandError(error)); }); @@ -384,7 +381,7 @@ describe(commands.APP_PERMISSION_LIST, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await command.action(logger, { options: { appObjectId: appObjectId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: appObjectId }) }); assert(loggerLogSpy.calledWith([])); }); @@ -437,7 +434,7 @@ describe(commands.APP_PERMISSION_LIST, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await command.action(logger, { options: { appObjectId: appObjectId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: appObjectId }) }); assert(loggerLogSpy.calledWith(allUnknownPermissionsResponse)); }); @@ -462,7 +459,7 @@ describe(commands.APP_PERMISSION_LIST, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await command.action(logger, { options: { appObjectId: appObjectId } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: appObjectId }) }); assert(loggerLogSpy.calledWith(allUnkownServicePrincipalPermissionsResponse)); }); @@ -483,16 +480,16 @@ describe(commands.APP_PERMISSION_LIST, () => { throw `Invalid request ${JSON.stringify(opts)}`; }); - await assert.rejects(command.action(logger, { options: { appObjectId: appObjectId } }), new CommandError(`An error has occurred`)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: appObjectId }) }), new CommandError(`An error has occurred`)); }); it('handles error when retrieving Entra app registration using name', async () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred')); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'My app' - } + }) } as any), new CommandError('An error has occurred')); }); }); \ No newline at end of file diff --git a/src/m365/entra/commands/app/app-permission-list.ts b/src/m365/entra/commands/app/app-permission-list.ts index 287a72b0f32..562ca97c2e8 100644 --- a/src/m365/entra/commands/app/app-permission-list.ts +++ b/src/m365/entra/commands/app/app-permission-list.ts @@ -1,23 +1,30 @@ import { AppRole, Application, PermissionScope, RequiredResourceAccess, ResourceAccess, ServicePrincipal } from "@microsoft/microsoft-graph-types"; -import GlobalOptions from "../../../../GlobalOptions.js"; -import GraphCommand from "../../../base/GraphCommand.js"; -import commands from "../../commands.js"; -import request, { CliRequestOptions } from "../../../../request.js"; +import { z } from 'zod'; import { Logger } from "../../../../cli/Logger.js"; -import { validation } from "../../../../utils/validation.js"; +import { globalOptionsZod } from "../../../../Command.js"; +import request, { CliRequestOptions } from "../../../../request.js"; import { entraApp } from "../../../../utils/entraApp.js"; +import { zod } from "../../../../utils/zod.js"; +import GraphCommand from "../../../base/GraphCommand.js"; +import commands from "../../commands.js"; + +const allowedTypes = ['delegated', 'application', 'all'] as const; + +const options = globalOptionsZod + .extend({ + appId: zod.alias('i', z.string().uuid().optional()), + appName: zod.alias('n', z.string().optional()), + appObjectId: z.string().uuid().optional(), + type: z.enum(allowedTypes).optional() + }) + .strict(); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appId?: string; - appName?: string; - appObjectId?: string; - type?: string; -} - interface ApiPermission { resource: string; resourceId?: string; @@ -31,8 +38,6 @@ interface ServicePrincipalInfo { } class EntraAppPermissionListCommand extends GraphCommand { - private allowedTypes: string[] = ['delegated', 'application', 'all']; - public get name(): string { return commands.APP_PERMISSION_LIST; } @@ -41,57 +46,15 @@ class EntraAppPermissionListCommand extends GraphCommand { return 'Lists the application and delegated permissions for a specified Entra Application Registration'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - appId: typeof args.options.appId !== 'undefined', - appName: typeof args.options.appName !== 'undefined', - appObjectId: typeof args.options.appObjectId !== 'undefined', - type: typeof args.options.type !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.appId, options.appName, options.appObjectId].filter(Boolean).length === 1, { + message: 'Specify either appId, appName, or appObjectId' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-i, --appId [appId]' }, - { option: '-n, --appName [appName]' }, - { option: '--appObjectId [appObjectId]' }, - { option: '--type [type]', autocomplete: this.allowedTypes } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.appId && !validation.isValidGuid(args.options.appId as string)) { - return `${args.options.appId} is not a valid GUID`; - } - - if (args.options.appObjectId && !validation.isValidGuid(args.options.appObjectId as string)) { - return `${args.options.appObjectId} is not a valid GUID`; - } - - if (args.options.type && this.allowedTypes.indexOf(args.options.type.toLowerCase()) === -1) { - return `${args.options.type} is not a valid type. Allowed types are ${this.allowedTypes.join(', ')}`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['appId', 'appName', 'appObjectId'] }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/app/app-permission-remove.spec.ts b/src/m365/entra/commands/app/app-permission-remove.spec.ts index d36ac4d9db3..bfab4a5e7ec 100644 --- a/src/m365/entra/commands/app/app-permission-remove.spec.ts +++ b/src/m365/entra/commands/app/app-permission-remove.spec.ts @@ -1,6 +1,7 @@ import { Application, ServicePrincipal } from '@microsoft/microsoft-graph-types'; import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { CommandError } from '../../../../Command.js'; import { cli } from '../../../../cli/cli.js'; @@ -27,11 +28,13 @@ describe(commands.APP_PERMISSION_REMOVE, () => { const applications: Application[] = [{ id: appObjectId, appId: appId, requiredResourceAccess: [{ resourceAppId: "00000003-0000-0000-c000-000000000000", resourceAccess: [{ id: "e4aa47b9-9a69-4109-82ed-36ec70d85ff1", type: "Scope" }, { id: "7427e0e9-2fba-42fe-b0c0-848c9e6a8182", type: "Scope" }, { id: "332a536c-c7ef-4017-ab91-336970924f0d", type: "Role" }] }] }]; const applicationPermissions = 'https://graph.microsoft.com/User.ReadWrite.All https://graph.microsoft.com/User.Read.All'; const delegatedPermissions = 'https://graph.microsoft.com/offline_access'; + const selectProperties = '$select=id,appId,requiredResourceAccess'; let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; let promptIssued: boolean = false; before(() => { @@ -41,6 +44,7 @@ describe(commands.APP_PERMISSION_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -104,7 +108,7 @@ describe(commands.APP_PERMISSION_REMOVE, () => { }); it('prompts before removing the app when force option not passed', async () => { - await command.action(logger, { options: { appId: appId, applicationPermissions: applicationPermissions } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: applicationPermissions }) }); assert(promptIssued); }); @@ -112,7 +116,7 @@ describe(commands.APP_PERMISSION_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { appId: appId, applicationPermissions: applicationPermissions, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: applicationPermissions, verbose: true }) }); assert(loggerLogSpy.notCalled); }); @@ -191,7 +195,7 @@ describe(commands.APP_PERMISSION_REMOVE, () => { } }); - await command.action(logger, { options: { appName: appName, applicationPermissions: applicationPermissions, revokeAdminConsent: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appName: appName, applicationPermissions: applicationPermissions, revokeAdminConsent: true, verbose: true }) }); assert(deleteStub.calledTwice); }); @@ -251,7 +255,7 @@ describe(commands.APP_PERMISSION_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { appObjectId: appObjectId, delegatedPermissions: delegatedPermissions, revokeAdminConsent: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: appObjectId, delegatedPermissions: delegatedPermissions, revokeAdminConsent: true, verbose: true }) }); assert(patchStub.lastCall.args[0].data.scope === 'AgreementAcceptance.Read'); }); @@ -297,7 +301,7 @@ describe(commands.APP_PERMISSION_REMOVE, () => { } }); - await command.action(logger, { options: { appObjectId: appObjectId, delegatedPermissions: delegatedPermissions, revokeAdminConsent: true, debug: true, force: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: appObjectId, delegatedPermissions: delegatedPermissions, revokeAdminConsent: true, debug: true, force: true }) }); assert(patchStub.calledOnce); assert(!patchStub.lastCall.args[0].url!.includes('oauth2PermissionGrants')); }); @@ -342,7 +346,7 @@ describe(commands.APP_PERMISSION_REMOVE, () => { sinon.stub(cli, 'handleMultipleResultsFound').resolves({ id: appObjectId }); - await command.action(logger, { options: { appId: appId, delegatedPermissions: delegatedPermissions, force: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, delegatedPermissions: delegatedPermissions, force: true, verbose: true }) }); assert(patchStub.calledOnce); }); @@ -373,7 +377,7 @@ describe(commands.APP_PERMISSION_REMOVE, () => { sinon.stub(cli, 'handleMultipleResultsFound').resolves({ id: appObjectId }); - await command.action(logger, { options: { appId: appId, delegatedPermissions: 'https://graph.microsoft.com/TeamTemplates.Read https://graph.microsoft.com/offline_access', force: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, delegatedPermissions: 'https://graph.microsoft.com/TeamTemplates.Read https://graph.microsoft.com/offline_access', force: true, verbose: true }) }); assert(patchStub.calledOnce); }); @@ -404,10 +408,34 @@ describe(commands.APP_PERMISSION_REMOVE, () => { sinon.stub(cli, 'handleMultipleResultsFound').resolves({ id: appObjectId }); - await command.action(logger, { options: { appId: appId, applicationPermissions: 'https://graph.microsoft.com/User.ReadWrite.All', force: true, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: 'https://graph.microsoft.com/User.ReadWrite.All', force: true, verbose: true }) }); assert(patchStub.calledOnce); }); + it('throws error if application specified by name cannot be found', async () => { + sinon.stub(odata, 'getAllItems').callsFake(async (url) => { + if (url === `https://graph.microsoft.com/v1.0/applications?$filter=displayName eq '${encodeURIComponent(appName)}'&${selectProperties}`) { + return []; + } + + throw `Invalid request ${url}`; + }); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, appName: appName, delegatedPermissions: delegatedPermissions, force: true }) }), + new CommandError(`App with name '${appName}' not found in Microsoft Entra ID`)); + }); + + it('throws error if application specified by appId cannot be found', async () => { + sinon.stub(odata, 'getAllItems').callsFake(async (url) => { + if (url === `https://graph.microsoft.com/v1.0/applications?$filter=appId eq '${appId}'&${selectProperties}`) { + return []; + } + + throw `Invalid request ${url}`; + }); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, appId: appId, delegatedPermissions: delegatedPermissions, force: true }) }), + new CommandError(`App with appId '${appId}' not found in Microsoft Entra ID`)); + }); + it('throws an error when service principal is not found', async () => { sinon.stub(entraApp, 'getAppRegistrationByAppId').resolves(applications[0]); const applicationPermission = 'https://grax.microsoft.com/User.ReadWrite.All'; @@ -422,7 +450,7 @@ describe(commands.APP_PERMISSION_REMOVE, () => { } }); - await assert.rejects(command.action(logger, { options: { appId: appId, applicationPermissions: applicationPermission, verbose: true, force: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: applicationPermission, verbose: true, force: true }) }), new CommandError(`Service principal ${servicePrincipalName} not found`)); }); @@ -441,67 +469,67 @@ describe(commands.APP_PERMISSION_REMOVE, () => { } }); - await assert.rejects(command.action(logger, { options: { appId: appId, applicationPermissions: applicationPermission, verbose: true, force: true } }), + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ appId: appId, applicationPermissions: applicationPermission, verbose: true, force: true }) }), new CommandError(`Permission ${permissionName} for service principal ${servicePrincipalName} not found`)); }); it('fails validation if the appId is not a valid GUID', async () => { - const actual = await command.validate({ options: { appId: 'invalid', applicationPermissions: applicationPermissions } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: 'invalid', applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, false); }); it('passes validation if the appId is a valid GUID', async () => { - const actual = await command.validate({ options: { appId: appId, applicationPermissions: applicationPermissions } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: appId, applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, true); }); it('fails validation if the appObjectId is not a valid GUID', async () => { - const actual = await command.validate({ options: { appObjectId: 'invalid', applicationPermissions: applicationPermissions } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: 'invalid', applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, false); }); it('passes validation if the appObjectId is a valid GUID', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId, applicationPermissions: applicationPermissions } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId, applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, true); }); it('passes validation if the one scope in delegatedPermissions is fully-qualified', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId, delegatedPermissions: delegatedPermissions } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId, delegatedPermissions: delegatedPermissions }); + assert.strictEqual(actual.success, true); }); it('fails validation if the one scope in delegatedPermissions is not fully-qualified', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId, delegatedPermissions: 'User.Read' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId, delegatedPermissions: 'User.Read' }); + assert.strictEqual(actual.success, false); }); it('passes validation if all scopes in delegatedPermissions are fully-qualified', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId, delegatedPermissions: 'https://graph.microsoft.com/User.Read https://graph.microsoft.com/User.ReadWrite' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId, delegatedPermissions: 'https://graph.microsoft.com/User.Read https://graph.microsoft.com/User.ReadWrite' }); + assert.strictEqual(actual.success, true); }); it('fails validation if one scope in delegatedPermissions is fully-qualified and the other is not', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId, delegatedPermissions: 'https://graph.microsoft.com/User.Read User.ReadWrite' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId, delegatedPermissions: 'https://graph.microsoft.com/User.Read User.ReadWrite' }); + assert.strictEqual(actual.success, false); }); it('passes validation if the one scope in applicationPermissions is fully-qualified', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId, applicationPermissions: applicationPermissions } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId, applicationPermissions: applicationPermissions }); + assert.strictEqual(actual.success, true); }); it('fails validation if the one scope in applicationPermissions is not fully-qualified', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId, applicationPermissions: 'User.Read.All' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId, applicationPermissions: 'User.Read.All' }); + assert.strictEqual(actual.success, false); }); it('passes validation if all scopes in applicationPermissions are fully-qualified', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId, applicationPermissions: 'https://graph.microsoft.com/User.Read.All https://graph.microsoft.com/User.ReadWrite.All' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId, applicationPermissions: 'https://graph.microsoft.com/User.Read.All https://graph.microsoft.com/User.ReadWrite.All' }); + assert.strictEqual(actual.success, true); }); it('fails validation if one scope in applicationPermissions is fully-qualified and the other is not', async () => { - const actual = await command.validate({ options: { appObjectId: appObjectId, applicationPermissions: 'https://graph.microsoft.com/User.Read.All User.ReadWrite.All' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: appObjectId, applicationPermissions: 'https://graph.microsoft.com/User.Read.All User.ReadWrite.All' }); + assert.strictEqual(actual.success, false); }); }); \ No newline at end of file diff --git a/src/m365/entra/commands/app/app-permission-remove.ts b/src/m365/entra/commands/app/app-permission-remove.ts index 1f1e4293895..47cdabe9a68 100644 --- a/src/m365/entra/commands/app/app-permission-remove.ts +++ b/src/m365/entra/commands/app/app-permission-remove.ts @@ -1,28 +1,34 @@ import { AppRole, AppRoleAssignment, Application, OAuth2PermissionGrant, PermissionScope, RequiredResourceAccess, ResourceAccess, ServicePrincipal } from "@microsoft/microsoft-graph-types"; -import GlobalOptions from "../../../../GlobalOptions.js"; -import { odata } from "../../../../utils/odata.js"; -import GraphCommand from "../../../base/GraphCommand.js"; -import commands from "../../commands.js"; -import request, { CliRequestOptions } from "../../../../request.js"; +import { z } from 'zod'; import { Logger } from "../../../../cli/Logger.js"; -import { validation } from "../../../../utils/validation.js"; +import { globalOptionsZod } from "../../../../Command.js"; +import request, { CliRequestOptions } from "../../../../request.js"; import { cli } from "../../../../cli/cli.js"; import { entraApp } from "../../../../utils/entraApp.js"; +import { odata } from "../../../../utils/odata.js"; +import { validation } from "../../../../utils/validation.js"; +import { zod } from "../../../../utils/zod.js"; +import GraphCommand from "../../../base/GraphCommand.js"; +import commands from "../../commands.js"; + +const options = globalOptionsZod + .extend({ + appId: z.string().uuid().optional(), + appObjectId: z.string().uuid().optional(), + appName: z.string().optional(), + applicationPermissions: z.string().optional(), + delegatedPermissions: z.string().optional(), + revokeAdminConsent: z.boolean().optional(), + force: zod.alias('f', z.boolean().optional()) + }) + .strict(); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appId?: string; - appObjectId?: string; - appName?: string; - applicationPermissions?: string; - delegatedPermissions?: string; - revokeAdminConsent?: boolean; - force?: boolean; -} - interface AppPermission { resourceId: string; resourceAccess: ResourceAccess[]; @@ -43,101 +49,32 @@ class EntraAppPermissionRemoveCommand extends GraphCommand { return 'Removes the specified application and/or delegated permissions from a specified Microsoft Entra app'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - appId: typeof args.options.appId !== 'undefined', - appObjectId: typeof args.options.appObjectId !== 'undefined', - appName: typeof args.options.appName !== 'undefined', - applicationPermissions: typeof args.options.applicationPermissions !== 'undefined', - delegatedPermissions: typeof args.options.delegatedPermissions !== 'undefined', - revokeAdminConsent: !!args.options.revokeAdminConsent, - force: !!args.options.force - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --appId [appId]' - }, - { - option: '--appObjectId [appObjectId]' - }, - { - option: '-n, --appName [appName]' - }, - { - option: '-a, --applicationPermissions [applicationPermissions]' - }, - { - option: '-d, --delegatedPermissions [delegatedPermissions]' - }, - { - option: '--revokeAdminConsent' - }, - { - option: '--force' - } - ); - } - - #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`; - } - - if (args.options.appObjectId && !validation.isValidGuid(args.options.appObjectId)) { - return `${args.options.appObjectId} is not a valid GUID`; - } - - if (args.options.delegatedPermissions) { - const invalidPermissions = validation.isValidPermission(args.options.delegatedPermissions); - if (Array.isArray(invalidPermissions)) { - return `Delegated permission(s) ${invalidPermissions.join(', ')} are not fully-qualified`; - } - } - - if (args.options.applicationPermissions) { - const invalidPermissions = validation.isValidPermission(args.options.applicationPermissions); - if (Array.isArray(invalidPermissions)) { - return `Application permission(s) ${invalidPermissions.join(', ')} are not fully-qualified`; - } - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { - options: ['appId', 'appObjectId', 'appName'] - }, - { - options: ['applicationPermissions', 'delegatedPermissions'], - runsWhen: (args) => args.options.delegatedPermissions === undefined && args.options.applicationPermissions === undefined - } - ); + public get schema(): z.ZodTypeAny { + return options; } - #initTypes(): void { - this.types.string.push('appId', 'appObjectId', 'appName', 'applicationPermissions', 'delegatedPermissions'); - this.types.boolean.push('revokeAdminConsent'); + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine( + options => [options.appId, options.appObjectId, options.appName].filter(Boolean).length === 1, + 'Specify either appId, appObjectId, or appName' + ) + .refine( + options => options.applicationPermissions || options.delegatedPermissions, + 'Specify either applicationPermissions or delegatedPermissions' + ) + .refine( + options => !options.delegatedPermissions || !Array.isArray(validation.isValidPermission(options.delegatedPermissions)), + options => ({ + message: `Delegated permission(s) ${(validation.isValidPermission(options.delegatedPermissions!) as string[]).join(', ')} are not fully-qualified` + }) + ) + .refine( + options => !options.applicationPermissions || !Array.isArray(validation.isValidPermission(options.applicationPermissions)), + options => ({ + message: `Application permission(s) ${(validation.isValidPermission(options.applicationPermissions!) as string[]).join(', ')} are not fully-qualified` + }) + ); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/app/app-remove.spec.ts b/src/m365/entra/commands/app/app-remove.spec.ts index eca140ad553..a3c572fd06a 100644 --- a/src/m365/entra/commands/app/app-remove.spec.ts +++ b/src/m365/entra/commands/app/app-remove.spec.ts @@ -1,24 +1,26 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; +import { settingsNames } from '../../../../settingsNames.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-remove.js'; -import { settingsNames } from '../../../../settingsNames.js'; -import { entraApp } from '../../../../utils/entraApp.js'; describe(commands.APP_REMOVE, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; let promptIssued: boolean = false; let deleteRequestStub: sinon.SinonStub; @@ -39,6 +41,7 @@ describe(commands.APP_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -98,75 +101,51 @@ describe(commands.APP_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if appId and name specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if appId and name specified', () => { + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if objectId and name specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if objectId and name specified', () => { + const actual = commandOptionsSchema.safeParse({ objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' }); + assert.strictEqual(actual.success, false); }); - it('fails validation if neither appId, objectId, nor name 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 appId, objectId, nor name specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, false); }); - it('fails validation if the objectId is not a valid guid', async () => { - const actual = await command.validate({ options: { objectId: 'abc' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the objectId is not a valid guid', () => { + const actual = commandOptionsSchema.safeParse({ objectId: 'abc' }); + assert.strictEqual(actual.success, false); }); - 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 required options specified (appId)', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (appId)', () => { + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if required options specified (objectId)', async () => { - const actual = await command.validate({ options: { objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (objectId)', () => { + const actual = commandOptionsSchema.safeParse({ objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }); + assert.strictEqual(actual.success, true); }); - it('passes validation if required options specified (name)', async () => { - const actual = await command.validate({ options: { name: 'My app' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required options specified (name)', () => { + const actual = commandOptionsSchema.safeParse({ name: 'My app' }); + assert.strictEqual(actual.success, true); }); it('prompts before removing the app when force option not passed', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: 'd75be2e1-0204-4f95-857d-51a37cf40be8' - } + }) }); assert(promptIssued); @@ -177,9 +156,9 @@ describe(commands.APP_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(false); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: 'd75be2e1-0204-4f95-857d-51a37cf40be8' - } + }) }); assert(deleteRequestStub.notCalled); }); @@ -189,40 +168,40 @@ describe(commands.APP_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appId: 'd75be2e1-0204-4f95-857d-51a37cf40be8' - } + }) }); assert(deleteRequestStub.called); }); it('deletes app with specified app (client) ID', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: 'd75be2e1-0204-4f95-857d-51a37cf40be8', force: true - } + }) }); assert(deleteRequestStub.called); }); it('deletes app with specified object ID', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ objectId: 'd75be2e1-0204-4f95-857d-51a37cf40be8', force: true - } + }) }); assert(deleteRequestStub.called); }); it('deletes app with specified name', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'myapp', force: true - } + }) }); assert(deleteRequestStub.called); }); @@ -232,7 +211,7 @@ describe(commands.APP_REMOVE, () => { const error = `App with appId 'd75be2e1-0204-4f95-857d-51a37cf40be8' not found in Microsoft Entra ID`; sinon.stub(entraApp, 'getAppRegistrationByAppId').rejects(new Error(error)); - await assert.rejects(command.action(logger, { options: { debug: true, appId: 'd75be2e1-0204-4f95-857d-51a37cf40be8', force: true } } as any), new CommandError(error)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, appId: 'd75be2e1-0204-4f95-857d-51a37cf40be8', force: true }) }), new CommandError(error)); }); it('fails to get app by name when app does not exists', async () => { @@ -240,7 +219,7 @@ describe(commands.APP_REMOVE, () => { const error = `App with name 'myapp' not found in Microsoft Entra ID`; sinon.stub(entraApp, 'getAppRegistrationByAppName').rejects(new Error(error)); - await assert.rejects(command.action(logger, { options: { debug: true, name: 'myapp', force: true } } as any), new CommandError(error)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true, name: 'myapp', force: true }) }), new CommandError(error)); }); it('fails when multiple apps with same name exists', async () => { @@ -256,11 +235,38 @@ describe(commands.APP_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, name: 'myapp', force: true - } + }) }), new CommandError(error)); }); + + it('handles selecting single result when multiple apps with the specified name found and cli is set to prompt', async () => { + sinonUtil.restore(request.get); + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/myorganization/applications?$filter=displayName eq 'myapp'&$select=id`) { + return { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#applications", + "value": [ + { "id": "d75be2e1-0204-4f95-857d-51a37cf40be8" }, + { "id": "340a4aa3-1af6-43ac-87d8-189819003952" } + ] + }; + } + + throw "Multiple Microsoft Entra application registration with name 'myapp' found."; + }); + + sinon.stub(cli, 'handleMultipleResultsFound').resolves({ id: 'd75be2e1-0204-4f95-857d-51a37cf40be8' }); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + name: 'myapp', + force: true + }) + }); + assert(deleteRequestStub.called); + }); }); diff --git a/src/m365/entra/commands/app/app-remove.ts b/src/m365/entra/commands/app/app-remove.ts index 10a8720d63a..e731d6686cb 100644 --- a/src/m365/entra/commands/app/app-remove.ts +++ b/src/m365/entra/commands/app/app-remove.ts @@ -1,23 +1,28 @@ +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 { entraApp } from '../../../../utils/entraApp.js'; -import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +const options = globalOptionsZod + .extend({ + appId: z.string().uuid().optional(), + objectId: z.string().uuid().optional(), + name: z.string().optional(), + force: zod.alias('f', z.boolean().optional()) + }) + .strict(); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appId?: string; - objectId?: string; - name?: string; - force?: boolean; -} - class EntraAppRemoveCommand extends GraphCommand { public get name(): string { return commands.APP_REMOVE; @@ -27,53 +32,15 @@ class EntraAppRemoveCommand extends GraphCommand { return 'Removes an Entra app registration'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - appId: typeof args.options.appId !== 'undefined', - objectId: typeof args.options.objectId !== 'undefined', - name: typeof args.options.name !== 'undefined', - force: (!(!args.options.force)).toString() + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.appId, options.objectId, options.name].filter(Boolean).length === 1, { + message: 'Specify either appId, objectId, or name' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '--appId [appId]' }, - { option: '--objectId [objectId]' }, - { option: '--name [name]' }, - { option: '-f, --force' } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.appId && !validation.isValidGuid(args.options.appId as string)) { - return `${args.options.appId} is not a valid GUID`; - } - - if (args.options.objectId && !validation.isValidGuid(args.options.objectId as string)) { - return `${args.options.objectId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['appId', 'objectId', 'name'] }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/app/app-role-add.spec.ts b/src/m365/entra/commands/app/app-role-add.spec.ts index d67b497f9d1..d793522c2d7 100644 --- a/src/m365/entra/commands/app/app-role-add.spec.ts +++ b/src/m365/entra/commands/app/app-role-add.spec.ts @@ -1,8 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; -import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -14,11 +14,13 @@ import commands from '../../commands.js'; import command from './app-role-add.js'; import { settingsNames } from '../../../../settingsNames.js'; import { entraApp } from '../../../../utils/entraApp.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; describe(commands.APP_ROLE_ADD, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -27,6 +29,7 @@ describe(commands.APP_ROLE_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -92,14 +95,14 @@ describe(commands.APP_ROLE_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' - } + }) }); }); @@ -147,14 +150,14 @@ describe(commands.APP_ROLE_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', name: 'Role', description: 'Custom role', allowedMembers: 'applications', claim: 'Custom.Role', verbose: true - } + }) }); }); @@ -203,7 +206,7 @@ describe(commands.APP_ROLE_ADD, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appName: 'My app', name: 'Role', @@ -211,7 +214,7 @@ describe(commands.APP_ROLE_ADD, () => { allowedMembers: 'both', claim: 'Custom.Role', verbose: true - } + }) }); }); @@ -230,13 +233,13 @@ describe(commands.APP_ROLE_ADD, () => { sinon.stub(request, 'patch').rejects('PATCH request executed'); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' - } + }) }), new CommandError(`Resource '5b31c38c-2584-42f0-aa47-657fb3a84230' does not exist or one of its queried reference-property objects are not present.`)); }); @@ -247,13 +250,13 @@ describe(commands.APP_ROLE_ADD, () => { sinon.stub(request, 'patch').rejects('PATCH request executed'); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' - } + }) }), new CommandError(`App with appId '9b1b1e42-794b-4c71-93ac-5ed92488b67f' not found in Microsoft Entra ID`)); }); @@ -263,13 +266,13 @@ describe(commands.APP_ROLE_ADD, () => { sinon.stub(request, 'patch').rejects('PATCH request executed'); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'My app', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' - } + }) }), new CommandError(error)); }); @@ -280,13 +283,13 @@ describe(commands.APP_ROLE_ADD, () => { sinon.stub(request, 'patch').rejects('PATCH request executed'); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'My app', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' - } + }) }), new CommandError(error)); }); @@ -295,13 +298,13 @@ describe(commands.APP_ROLE_ADD, () => { sinon.stub(request, 'patch').rejects('PATCH request executed'); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'My app', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' - } + }) } as any), new CommandError('An error has occurred')); }); @@ -320,13 +323,13 @@ describe(commands.APP_ROLE_ADD, () => { sinon.stub(request, 'patch').rejects('PATCH request executed'); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' - } + }) } as any), new CommandError(`Resource '5b31c38c-2584-42f0-aa47-657fb3a84230' does not exist or one of its queried reference-property objects are not present.`)); }); @@ -348,13 +351,13 @@ describe(commands.APP_ROLE_ADD, () => { sinon.stub(request, 'patch').rejects(new Error('An error has occurred')); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' - } + }) } as any), new CommandError('An error has occurred')); }); @@ -367,8 +370,9 @@ describe(commands.APP_ROLE_ADD, () => { return defaultValue; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appObjectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8', name: 'Managers', description: 'Managers', allowedMembers: 'userGroups', claim: 'managers' } }, commandInfo); - assert.notStrictEqual(actual, true); + + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appObjectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8', name: 'Managers', description: 'Managers', allowedMembers: 'userGroups', claim: 'managers' }); + assert.strictEqual(actual.success, false); }); it('fails validation if appId and appName specified', async () => { @@ -380,8 +384,8 @@ describe(commands.APP_ROLE_ADD, () => { return defaultValue; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app', name: 'Managers', description: 'Managers', allowedMembers: 'userGroups', claim: 'managers' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app', name: 'Managers', description: 'Managers', allowedMembers: 'userGroups', claim: 'managers' }); + assert.strictEqual(actual.success, false); }); it('fails validation if appObjectId and appName specified', async () => { @@ -393,8 +397,8 @@ describe(commands.APP_ROLE_ADD, () => { return defaultValue; }); - const actual = await command.validate({ options: { appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app', name: 'Managers', description: 'Managers', allowedMembers: 'userGroups', claim: 'managers' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app', name: 'Managers', description: 'Managers', allowedMembers: 'userGroups', claim: 'managers' }); + assert.strictEqual(actual.success, false); }); it('fails validation if neither appId, appObjectId nor appName specified', async () => { @@ -406,47 +410,42 @@ describe(commands.APP_ROLE_ADD, () => { return defaultValue; }); - const actual = await command.validate({ options: { name: 'Managers', description: 'Managers', allowedMembers: 'userGroups', claim: 'managers' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ name: 'Managers', description: 'Managers', allowedMembers: 'userGroups', claim: 'managers' }); + assert.strictEqual(actual.success, false); }); it('fails validation if invalid allowedMembers specified', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowedMembers: 'invalid', name: 'Managers', description: 'Managers', claim: 'managers' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowedMembers: 'invalid', name: 'Managers', description: 'Managers', claim: 'managers' }); + assert.strictEqual(actual.success, false); }); it('fails validation if claim length exceeds 120 chars', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowedMembers: 'usersGroups', claim: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras ullamcorper, arcu vel finibus facilisis, orci velit lectus.', name: 'Managers', description: 'Managers' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowedMembers: 'usersGroups', claim: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras ullamcorper, arcu vel finibus facilisis, orci velit lectus.', name: 'Managers', description: 'Managers' }); + assert.strictEqual(actual.success, false); }); it('fails validation if claim starts with a .', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowedMembers: 'usersGroups', claim: '.claim', name: 'Managers', description: 'Managers' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowedMembers: 'usersGroups', claim: '.claim', name: 'Managers', description: 'Managers' }); + assert.strictEqual(actual.success, false); }); it('fails validation if claim contains invalid characters', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowedMembers: 'usersGroups', claim: 'cláim', name: 'Managers', description: 'Managers' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowedMembers: 'usersGroups', claim: 'cláim', name: 'Managers', description: 'Managers' }); + assert.strictEqual(actual.success, false); }); it('passes validation if required options specified (appId)', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified (appObjectId)', async () => { - const actual = await command.validate({ options: { appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified (appName)', async () => { - const actual = await command.validate({ options: { appName: 'My app', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' } }, commandInfo); - assert.strictEqual(actual, true); - }); - - it('returns an empty array for an invalid member type', () => { - const actual = (command as any).getAllowedMemberTypes({ options: { allowedMembers: 'foo' } }); - assert.deepStrictEqual(actual, []); + const actual = commandOptionsSchema.safeParse({ appName: 'My app', name: 'Role', description: 'Custom role', allowedMembers: 'usersGroups', claim: 'Custom.Role' }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/entra/commands/app/app-role-add.ts b/src/m365/entra/commands/app/app-role-add.ts index a888820e5c9..7c7f4d93a88 100644 --- a/src/m365/entra/commands/app/app-role-add.ts +++ b/src/m365/entra/commands/app/app-role-add.ts @@ -1,28 +1,35 @@ import { Application } from '@microsoft/microsoft-graph-types'; import { v4 } from 'uuid'; +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 { entraApp } from '../../../../utils/entraApp.js'; +import { zod } from '../../../../utils/zod.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; -import { entraApp } from '../../../../utils/entraApp.js'; + +const allowedMembers = ['usersGroups', 'applications', 'both'] as const; + +const options = globalOptionsZod + .extend({ + allowedMembers: zod.alias('m', z.enum(allowedMembers)), + appId: z.string().uuid().optional(), + appObjectId: z.string().uuid().optional(), + appName: z.string().optional(), + claim: zod.alias('c', z.string()), + name: zod.alias('n', z.string()), + description: zod.alias('d', z.string()) + }) + .strict(); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - allowedMembers: string; - appId?: string; - appObjectId?: string; - appName?: string; - claim: string; - name: string; - description: string; -} class EntraAppRoleAddCommand extends GraphCommand { - private static readonly allowedMembers: string[] = ['usersGroups', 'applications', 'both']; - public get name(): string { return commands.APP_ROLE_ADD; } @@ -31,67 +38,24 @@ class EntraAppRoleAddCommand extends GraphCommand { return 'Adds role to the specified Entra app registration'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - appId: typeof args.options.appId !== 'undefined', - appObjectId: typeof args.options.appObjectId !== 'undefined', - appName: typeof args.options.appName !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.appId, options.appObjectId, options.appName].filter(Boolean).length === 1, { + message: 'Specify either appId, appObjectId, or appName but not multiple' + }) + .refine(options => options.claim.length <= 120, { + message: 'Claim must not be longer than 120 characters' + }) + .refine(options => !options.claim.startsWith('.'), { + message: 'Claim must not begin with .' + }) + .refine(options => /^[\w:!#$%&'()*+,-.\/:;<=>?@\[\]^+_`{|}~]+$/.test(options.claim), { + message: 'Claim can contain only the following characters a-z, A-Z, 0-9, :!#$%&\'()*+,-./:;<=>?@[]^+_`{|}~]+' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '--appId [appId]' }, - { option: '--appObjectId [appObjectId]' }, - { option: '--appName [appName]' }, - { option: '-n, --name ' }, - { option: '-d, --description ' }, - { - option: '-m, --allowedMembers ', autocomplete: EntraAppRoleAddCommand.allowedMembers - }, - { option: '-c, --claim ' } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const { allowedMembers, claim } = args.options; - - if (EntraAppRoleAddCommand.allowedMembers.indexOf(allowedMembers) < 0) { - return `${allowedMembers} is not a valid value for allowedMembers. Valid values are ${EntraAppRoleAddCommand.allowedMembers.join(', ')}`; - } - - if (claim.length > 120) { - return `Claim must not be longer than 120 characters`; - } - - if (claim.startsWith('.')) { - return 'Claim must not begin with .'; - } - - if (!/^[\w:!#$%&'()*+,-.\/:;<=>?@\[\]^+_`{|}~]+$/.test(claim)) { - return `Claim can contain only the following characters a-z, A-Z, 0-9, :!#$%&'()*+,-./:;<=>?@[]^+_\`{|}~]+`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['appId', 'appObjectId', 'appName'] }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -133,9 +97,8 @@ class EntraAppRoleAddCommand extends GraphCommand { case 'applications': return ['Application']; case 'both': - return ['User', 'Application']; default: - return []; + return ['User', 'Application']; } } @@ -143,7 +106,7 @@ class EntraAppRoleAddCommand extends GraphCommand { const { appObjectId, appId, appName } = args.options; if (this.verbose) { - await logger.logToStderr(`Retrieving information about Microsoft Entra app ${appObjectId ? appObjectId : (appId ? appId : appName) }...`); + await logger.logToStderr(`Retrieving information about Microsoft Entra app ${appObjectId ? appObjectId : (appId ? appId : appName)}...`); } if (appObjectId) { diff --git a/src/m365/entra/commands/app/app-role-list.spec.ts b/src/m365/entra/commands/app/app-role-list.spec.ts index 3b921e1d14a..f759594fcf1 100644 --- a/src/m365/entra/commands/app/app-role-list.spec.ts +++ b/src/m365/entra/commands/app/app-role-list.spec.ts @@ -1,8 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; -import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -14,12 +14,14 @@ import commands from '../../commands.js'; import command from './app-role-list.js'; import { settingsNames } from '../../../../settingsNames.js'; import { entraApp } from '../../../../utils/entraApp.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; describe(commands.APP_ROLE_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; //#region Mocked Responses const appResponse = { @@ -38,6 +40,7 @@ describe(commands.APP_ROLE_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -120,7 +123,7 @@ describe(commands.APP_ROLE_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8' }) }); assert(loggerLogSpy.calledWith([ { "allowedMemberTypes": [ @@ -182,7 +185,7 @@ describe(commands.APP_ROLE_LIST, () => { throw `Invalid request ${opts.url}`; }); - await command.action(logger, { options: { debug: true, appName: 'My app' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, appName: 'My app' }) }); assert(loggerLogSpy.calledWith([ { "allowedMemberTypes": [ @@ -243,7 +246,7 @@ describe(commands.APP_ROLE_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230' }) }); assert(loggerLogSpy.calledWith([ { "allowedMemberTypes": [ @@ -279,7 +282,7 @@ describe(commands.APP_ROLE_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230' }) }); assert(loggerLogSpy.calledWith([])); }); @@ -303,9 +306,9 @@ describe(commands.APP_ROLE_LIST, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230' - } + }) }), new CommandError(`Resource '5b31c38c-2584-42f0-aa47-657fb3a84230' does not exist or one of its queried reference-property objects are not present.`)); }); @@ -314,9 +317,9 @@ describe(commands.APP_ROLE_LIST, () => { 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`)); }); @@ -325,9 +328,9 @@ describe(commands.APP_ROLE_LIST, () => { sinon.stub(entraApp, 'getAppRegistrationByAppName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'My app' - } + }) }), new CommandError(error)); }); @@ -336,9 +339,9 @@ describe(commands.APP_ROLE_LIST, () => { sinon.stub(entraApp, 'getAppRegistrationByAppName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'My app' - } + }) }), new CommandError(error)); }); @@ -346,9 +349,9 @@ describe(commands.APP_ROLE_LIST, () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred')); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'My app' - } + }) } as any), new CommandError('An error has occurred')); }); @@ -361,8 +364,8 @@ describe(commands.APP_ROLE_LIST, () => { return defaultValue; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appObjectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appObjectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8' }); + assert.strictEqual(actual.success, false); }); it('fails validation if appId and appName specified', async () => { @@ -374,8 +377,8 @@ describe(commands.APP_ROLE_LIST, () => { return defaultValue; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app' }); + assert.strictEqual(actual.success, false); }); it('fails validation if appObjectId and appName specified', async () => { @@ -387,8 +390,8 @@ describe(commands.APP_ROLE_LIST, () => { return defaultValue; }); - const actual = await command.validate({ options: { appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app' }); + assert.strictEqual(actual.success, false); }); it('fails validation if neither appId, appObjectId nor appName specified', async () => { @@ -400,22 +403,22 @@ describe(commands.APP_ROLE_LIST, () => { return defaultValue; }); - const actual = await command.validate({ options: {} }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, false); }); it('passes validation if appId specified', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }); + assert.strictEqual(actual.success, true); }); it('passes validation if appObjectId specified', async () => { - const actual = await command.validate({ options: { appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }); + assert.strictEqual(actual.success, true); }); it('passes validation if appName specified', async () => { - const actual = await command.validate({ options: { appName: 'My app' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appName: 'My app' }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/entra/commands/app/app-role-list.ts b/src/m365/entra/commands/app/app-role-list.ts index 81635e9f623..1396edf0e43 100644 --- a/src/m365/entra/commands/app/app-role-list.ts +++ b/src/m365/entra/commands/app/app-role-list.ts @@ -1,21 +1,26 @@ import { AppRole } 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 { entraApp } from '../../../../utils/entraApp.js'; import { odata } from '../../../../utils/odata.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; -import { entraApp } from '../../../../utils/entraApp.js'; + +const options = globalOptionsZod + .extend({ + appId: z.string().uuid().optional(), + appObjectId: z.string().uuid().optional(), + appName: z.string().optional() + }) + .strict(); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appId?: string; - appObjectId?: string; - appName?: string; -} - class EntraAppRoleListCommand extends GraphCommand { public get name(): string { return commands.APP_ROLE_LIST; @@ -25,34 +30,15 @@ class EntraAppRoleListCommand extends GraphCommand { return 'Gets Entra app registration roles'; } - 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', - appObjectId: typeof args.options.appObjectId !== 'undefined', - appName: typeof args.options.appName !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.appId, options.appObjectId, options.appName].filter(Boolean).length === 1, { + message: 'Specify either appId, appObjectId, or appName but not multiple' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '--appId [appId]' }, - { option: '--appObjectId [appObjectId]' }, - { option: '--appName [appName]' } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['appId', 'appObjectId', 'appName'] }); } public defaultProperties(): string[] | undefined { diff --git a/src/m365/entra/commands/app/app-role-remove.spec.ts b/src/m365/entra/commands/app/app-role-remove.spec.ts index b9b9dc72521..6fc11e87ff9 100644 --- a/src/m365/entra/commands/app/app-role-remove.spec.ts +++ b/src/m365/entra/commands/app/app-role-remove.spec.ts @@ -1,8 +1,8 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; -import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -14,12 +14,14 @@ import commands from '../../commands.js'; import command from './app-role-remove.js'; import { settingsNames } from '../../../../settingsNames.js'; import { entraApp } from '../../../../utils/entraApp.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; describe(commands.APP_ROLE_REMOVE, () => { let log: string[]; let logger: Logger; - let commandInfo: CommandInfo; let promptIssued: boolean = false; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -28,6 +30,7 @@ describe(commands.APP_ROLE_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -136,11 +139,11 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', claim: 'Product.Read', force: true - } + }) }); }); @@ -203,11 +206,11 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', name: 'ProductRead', force: true - } + }) }); }); @@ -270,11 +273,11 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', id: 'c4352a0a-494f-46f9-b843-479855c173a7', force: true - } + }) }); }); @@ -336,11 +339,11 @@ describe(commands.APP_ROLE_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '53788d97-dc06-460c-8bd6-5cfbc7e3b0f7', claim: 'Product.Read', force: true - } + }) }); }); @@ -403,11 +406,11 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '53788d97-dc06-460c-8bd6-5cfbc7e3b0f7', name: 'ProductRead', force: true - } + }) }); }); @@ -470,11 +473,11 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '53788d97-dc06-460c-8bd6-5cfbc7e3b0f7', id: 'c4352a0a-494f-46f9-b843-479855c173a7', force: true - } + }) }); }); @@ -537,12 +540,12 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', claim: 'Product.Read', force: true - } + }) }); }); @@ -605,12 +608,12 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appId: '53788d97-dc06-460c-8bd6-5cfbc7e3b0f7', name: 'ProductRead', force: true - } + }) }); }); @@ -673,12 +676,12 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appId: '53788d97-dc06-460c-8bd6-5cfbc7e3b0f7', id: 'c4352a0a-494f-46f9-b843-479855c173a7', force: true - } + }) }); }); @@ -729,11 +732,11 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'App-Name', id: 'c4352a0a-494f-46f9-b843-479855c173a7', force: true - } + }) }); }); @@ -784,11 +787,11 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'App-Name', claim: 'Product.Read', force: true - } + }) }); }); @@ -839,11 +842,11 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'App-Name', name: 'ProductRead', force: true - } + }) }); }); @@ -893,12 +896,12 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appName: 'App-Name', id: 'c4352a0a-494f-46f9-b843-479855c173a7', force: true - } + }) }); }); @@ -949,12 +952,12 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appName: 'App-Name', claim: 'Product.Read', force: true - } + }) }); }); @@ -1005,12 +1008,12 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appName: 'App-Name', name: 'ProductRead', force: true - } + }) }); }); @@ -1019,11 +1022,11 @@ describe(commands.APP_ROLE_REMOVE, () => { sinon.stub(entraApp, 'getAppRegistrationByAppName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'App-Name', claim: 'Product.Read', force: true - } + }) }), new CommandError(error)); }); @@ -1075,11 +1078,11 @@ describe(commands.APP_ROLE_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'App-Name', name: 'ProductRead', force: true - } + }) }), new CommandError(`Multiple roles with name 'ProductRead' found. Found: c4352a0a-494f-46f9-b843-479855c173a7, 9267ab18-8d09-408d-8c94-834662ed16d1.`)); }); @@ -1132,12 +1135,12 @@ describe(commands.APP_ROLE_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appName: 'App-Name', name: 'ProductRead', force: true - } + }) }); assert(removeRequestIssued); }); @@ -1149,11 +1152,11 @@ describe(commands.APP_ROLE_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'App-Name', name: 'ProductRead', force: true - } + }) }), new CommandError(`No app role with name 'ProductRead' found.`)); }); @@ -1164,11 +1167,11 @@ describe(commands.APP_ROLE_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'App-Name', claim: 'Product.Read', force: true - } + }) }), new CommandError(`No app role with claim 'Product.Read' found.`)); }); @@ -1179,22 +1182,22 @@ describe(commands.APP_ROLE_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'App-Name', id: 'c4352a0a-494f-46f9-b843-479855c173a7', force: true - } + }) }), new CommandError(`No app role with id 'c4352a0a-494f-46f9-b843-479855c173a7' found.`)); }); it('prompts before removing the specified app role when force option not passed', async () => { - await command.action(logger, { options: { appName: 'App-Name', claim: 'Product.Read' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appName: 'App-Name', claim: 'Product.Read' }) }); assert(promptIssued); }); it('prompts before removing the specified app role when force option not passed (debug)', async () => { - await command.action(logger, { options: { debug: true, appName: 'App-Name', claim: 'Product.Read' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, appName: 'App-Name', claim: 'Product.Read' }) }); assert(promptIssued); }); @@ -1259,12 +1262,12 @@ describe(commands.APP_ROLE_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', claim: 'Product.Read', force: false - } + }) }); }); @@ -1329,11 +1332,11 @@ describe(commands.APP_ROLE_REMOVE, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '53788d97-dc06-460c-8bd6-5cfbc7e3b0f7', name: 'ProductRead', force: false - } + }) }); }); @@ -1399,12 +1402,12 @@ describe(commands.APP_ROLE_REMOVE, () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appId: '53788d97-dc06-460c-8bd6-5cfbc7e3b0f7', id: 'c4352a0a-494f-46f9-b843-479855c173a7', force: false - } + }) }); }); @@ -1414,7 +1417,7 @@ describe(commands.APP_ROLE_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { appName: 'App-Name', claim: 'Product.Read' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ appName: 'App-Name', claim: 'Product.Read' }) }); assert(patchStub.notCalled); }); @@ -1424,7 +1427,7 @@ describe(commands.APP_ROLE_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { debug: true, appName: 'App-Name', claim: 'Product.Read' } }); + await command.action(logger, { options: commandOptionsSchema.parse({ debug: true, appName: 'App-Name', claim: 'Product.Read' }) }); assert(patchStub.notCalled); }); @@ -1442,11 +1445,11 @@ describe(commands.APP_ROLE_REMOVE, () => { }); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appObjectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', name: 'App-Role', force: true - } + }) }), new CommandError(`Resource '5b31c38c-2584-42f0-aa47-657fb3a84230' does not exist or one of its queried reference-property objects are not present.`)); }); @@ -1455,11 +1458,11 @@ describe(commands.APP_ROLE_REMOVE, () => { sinon.stub(entraApp, 'getAppRegistrationByAppId').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'App-Role', force: true - } + }) }), new CommandError(`App with appId '9b1b1e42-794b-4c71-93ac-5ed92488b67f' not found in Microsoft Entra ID`)); }); @@ -1468,64 +1471,32 @@ describe(commands.APP_ROLE_REMOVE, () => { sinon.stub(entraApp, 'getAppRegistrationByAppName').rejects(new Error(error)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appName: 'My app', name: 'App-Role', force: true - } + }) }), new CommandError(error)); }); it('fails validation if appId and appObjectId specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appObjectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appObjectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8' }); + assert.strictEqual(actual.success, false); }); it('fails validation if appId and appName specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app' }); + assert.strictEqual(actual.success, false); }); it('fails validation if appObjectId and appName specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app' }); + assert.strictEqual(actual.success, false); }); it('fails validation if neither appId, appObjectId nor appName 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); + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, false); }); it('fails validation if role name and id is specified', async () => { @@ -1537,8 +1508,8 @@ describe(commands.APP_ROLE_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: "Product read", id: "c4352a0a-494f-46f9-b843-479855c173a7" } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: "Product read", id: "c4352a0a-494f-46f9-b843-479855c173a7" }); + assert.strictEqual(actual.success, false); }); it('fails validation role name and claim is specified', async () => { @@ -1550,8 +1521,8 @@ describe(commands.APP_ROLE_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: "Product read", claim: "Product.Read" } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: "Product read", claim: "Product.Read" }); + assert.strictEqual(actual.success, false); }); it('fails validation if role id and claim is specified', async () => { @@ -1563,8 +1534,8 @@ describe(commands.APP_ROLE_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', claim: "Product.Read", id: "c4352a0a-494f-46f9-b843-479855c173a7" } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', claim: "Product.Read", id: "c4352a0a-494f-46f9-b843-479855c173a7" }); + assert.strictEqual(actual.success, false); }); it('fails validation if neither role name, id or claim specified', async () => { @@ -1576,57 +1547,57 @@ describe(commands.APP_ROLE_REMOVE, () => { return defaultValue; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' }); + assert.strictEqual(actual.success, false); }); it('fails validation if specified role id is not a valid guid', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', id: '77355bee' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, false); }); it('passes validation if required options specified - appId,name', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'ProductRead' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'ProductRead' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified - appId,claim', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', claim: 'Product.Read' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', claim: 'Product.Read' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified - appId,id', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', id: '4e241a08-3a95-4c47-8c68-8c0df7d62ce2' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', id: '4e241a08-3a95-4c47-8c68-8c0df7d62ce2' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified - appObjectId,name', async () => { - const actual = await command.validate({ options: { appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'ProductRead' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'ProductRead' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified - appObjectId,claim', async () => { - const actual = await command.validate({ options: { appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', claim: 'Product.Read' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', claim: 'Product.Read' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified - appObjectId,id', async () => { - const actual = await command.validate({ options: { appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', id: '4e241a08-3a95-4c47-8c68-8c0df7d62ce2' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', id: '4e241a08-3a95-4c47-8c68-8c0df7d62ce2' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified - appName,name', async () => { - const actual = await command.validate({ options: { appName: 'My App', name: 'ProductRead' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appName: 'My App', name: 'ProductRead' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified - appName,claim', async () => { - const actual = await command.validate({ options: { appName: 'My App', claim: 'Product.Read' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appName: 'My App', claim: 'Product.Read' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified - appName,id', async () => { - const actual = await command.validate({ options: { appName: 'My App', id: '4e241a08-3a95-4c47-8c68-8c0df7d62ce2' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appName: 'My App', id: '4e241a08-3a95-4c47-8c68-8c0df7d62ce2' }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/entra/commands/app/app-role-remove.ts b/src/m365/entra/commands/app/app-role-remove.ts index 5fc7fa6004a..30a7af1c268 100644 --- a/src/m365/entra/commands/app/app-role-remove.ts +++ b/src/m365/entra/commands/app/app-role-remove.ts @@ -1,27 +1,33 @@ import { Application, AppRole } 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 { entraApp } from "../../../../utils/entraApp.js"; import { formatting } from "../../../../utils/formatting.js"; -import { validation } from '../../../../utils/validation.js'; +import { zod } from "../../../../utils/zod.js"; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; -import { entraApp } from "../../../../utils/entraApp.js"; + +const options = globalOptionsZod + .extend({ + appId: z.string().uuid().optional(), + appObjectId: z.string().uuid().optional(), + appName: z.string().optional(), + claim: zod.alias('c', z.string().optional()), + name: zod.alias('n', z.string().optional()), + id: zod.alias('i', z.string().optional()), + force: zod.alias('f', z.boolean().optional()) + }) + .strict(); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appId?: string; - appObjectId?: string; - appName?: string; - claim?: string; - name?: string; - id?: string; -} - class EntraAppRoleRemoveCommand extends GraphCommand { public get name(): string { return commands.APP_ROLE_REMOVE; @@ -31,59 +37,18 @@ class EntraAppRoleRemoveCommand extends GraphCommand { return 'Removes role from the specified Entra app registration'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - appId: typeof args.options.appId !== 'undefined', - appObjectId: typeof args.options.appObjectId !== 'undefined', - appName: typeof args.options.appName !== 'undefined', - claim: typeof args.options.claim !== 'undefined', - name: typeof args.options.name !== 'undefined', - id: typeof args.options.id !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.appId, options.appObjectId, options.appName].filter(Boolean).length === 1, { + message: 'Specify either appId, appObjectId, or appName' + }) + .refine(options => [options.name, options.claim, options.id].filter(Boolean).length === 1, { + message: 'Specify either name, claim, or id' }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '--appId [appId]' }, - { option: '--appObjectId [appObjectId]' }, - { option: '--appName [appName]' }, - { option: '-n, --name [name]' }, - { option: '-i, --id [id]' }, - { option: '-c, --claim [claim]' }, - { option: '-f, --force' } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.id) { - if (!validation.isValidGuid(args.options.id)) { - return `${args.options.id} is not a valid GUID`; - } - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['appId', 'appObjectId', 'appName'] }, - { options: ['name', 'claim', 'id'] } - ); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/app/app-set.spec.ts b/src/m365/entra/commands/app/app-set.spec.ts index e1f14d25430..5bfe8efac50 100644 --- a/src/m365/entra/commands/app/app-set.spec.ts +++ b/src/m365/entra/commands/app/app-set.spec.ts @@ -1,9 +1,9 @@ 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'; @@ -15,6 +15,7 @@ import commands from '../../commands.js'; import command from './app-set.js'; import { settingsNames } from '../../../../settingsNames.js'; import { entraApp } from '../../../../utils/entraApp.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; describe(commands.APP_SET, () => { @@ -33,6 +34,7 @@ describe(commands.APP_SET, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -41,6 +43,7 @@ describe(commands.APP_SET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -102,11 +105,11 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' - } + }) }); }); @@ -132,12 +135,12 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8', extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' - } + }) }); }); @@ -156,11 +159,11 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8,api://testapi' - } + }) }); }); @@ -176,10 +179,10 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ objectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' - } + }) }); }); @@ -196,10 +199,10 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ objectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8,api://testapi' - } + }) }); }); @@ -216,11 +219,11 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, name: 'My app', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' - } + }) }); }); @@ -238,19 +241,19 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, name: 'My app', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8,api://testapi' - } + }) }); }); it('skips updating uris if no uris specified', async () => { await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ objectId: '5b31c38c-2584-42f0-aa47-657fb3a84230' - } + }) }); }); @@ -386,12 +389,12 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, objectId: 'e4528262-097a-42eb-98e1-19f073dbee45', redirectUris: 'https://24c4-2001-1c00-80c-d00-e5da-977c-7c52-5194.ngrok.io/auth', platform: 'spa' - } + }) }); }); @@ -533,12 +536,12 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, objectId: '95cfe30d-ed44-4f9d-b73d-c66560f72e83', redirectUris: 'https://foo.com', platform: 'web' - } + }) }); }); @@ -684,12 +687,12 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, objectId: '95cfe30d-ed44-4f9d-b73d-c66560f72e83', redirectUris: 'https://foo1.com', platform: 'publicClient' - } + }) }); }); @@ -829,13 +832,13 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, objectId: '95cfe30d-ed44-4f9d-b73d-c66560f72e83', redirectUris: 'https://244e-2001-1c00-80c-d00-e5da-977c-7c52-5194.ngrok.io/auth', platform: 'spa', redirectUrisToRemove: 'https://244e-2001-1c00-80c-d00-e5da-977c-7c52-5193.ngrok.io/auth' - } + }) }); }); @@ -870,12 +873,12 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, objectId: '95cfe30d-ed44-4f9d-b73d-c66560f72e83', certificateDisplayName: 'some certificate', certificateBase64Encoded: 'somecertificatebase64string' - } + }) }); }); @@ -910,12 +913,12 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, objectId: '95cfe30d-ed44-4f9d-b73d-c66560f72e83', certificateDisplayName: 'some certificate', certificateBase64Encoded: 'somecertificatebase64string' - } + }) }); }); @@ -952,12 +955,12 @@ describe(commands.APP_SET, () => { sinon.stub(fs, 'readFileSync').returns("somecertificatebase64string"); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, objectId: '95cfe30d-ed44-4f9d-b73d-c66560f72e83', certificateDisplayName: 'some certificate', certificateFile: 'C:\\temp\\some-certificate.cer' - } + }) }); }); @@ -975,11 +978,11 @@ describe(commands.APP_SET, () => { }); await command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', allowPublicClientFlows: true - } + }) }); }); @@ -996,12 +999,12 @@ describe(commands.APP_SET, () => { sinon.stub(fs, 'readFileSync').throws(new Error("An error has occurred")); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ debug: true, objectId: '95cfe30d-ed44-4f9d-b73d-c66560f72e83', certificateDisplayName: 'some certificate', certificateFile: 'C:\\temp\\some-certificate.cer' - } + }) }), new CommandError(`Error reading certificate file: Error: An error has occurred. Please add the certificate using base64 option '--certificateBase64Encoded'.`)); }); @@ -1009,10 +1012,10 @@ describe(commands.APP_SET, () => { sinon.stub(request, 'patch').rejects(new Error(`Resource '5b31c38c-2584-42f0-aa47-657fb3a84230' does not exist or one of its queried reference-property objects are not present.`)); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ objectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' - } + }) }), new CommandError(`Resource '5b31c38c-2584-42f0-aa47-657fb3a84230' does not exist or one of its queried reference-property objects are not present.`)); }); @@ -1023,10 +1026,10 @@ describe(commands.APP_SET, () => { sinon.stub(request, 'patch').rejects('PATCH request executed'); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' - } + }) }), new CommandError(`App with appId '9b1b1e42-794b-4c71-93ac-5ed92488b67f' not found in Microsoft Entra ID`)); }); @@ -1036,10 +1039,10 @@ describe(commands.APP_SET, () => { sinon.stub(request, 'patch').rejects('PATCH request executed'); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My app', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' - } + }) }), new CommandError(error)); }); @@ -1049,10 +1052,10 @@ describe(commands.APP_SET, () => { sinon.stub(request, 'patch').rejects('PATCH request executed'); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My app', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' - } + }) }), new CommandError(error)); }); @@ -1060,10 +1063,10 @@ describe(commands.APP_SET, () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred')); await assert.rejects(command.action(logger, { - options: { + options: commandOptionsSchema.parse({ name: 'My app', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' - } + }) }), new CommandError(`An error has occurred`)); }); @@ -1076,8 +1079,8 @@ describe(commands.APP_SET, () => { return defaultValue; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8' }); + assert.strictEqual(actual.success, false); }); it('fails validation if appId and name specified', async () => { @@ -1089,8 +1092,8 @@ describe(commands.APP_SET, () => { return defaultValue; }); - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' }); + assert.strictEqual(actual.success, false); }); it('fails validation if objectId and name specified', async () => { @@ -1102,8 +1105,8 @@ describe(commands.APP_SET, () => { return defaultValue; }); - const actual = await command.validate({ options: { objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' }); + assert.strictEqual(actual.success, false); }); it('fails validation if neither appId, objectId nor name specified', async () => { @@ -1115,87 +1118,86 @@ describe(commands.APP_SET, () => { return defaultValue; }); - const actual = await command.validate({ options: {} }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, false); }); it('fails validation if redirectUris specified without platform', async () => { - const actual = await command.validate({ options: { objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8', redirectUris: 'https://foo.com' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8', redirectUris: 'https://foo.com' }); + assert.strictEqual(actual.success, false); }); it('fails validation if invalid platform specified', async () => { - const actual = await command.validate({ options: { objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8', redirectUris: 'https://foo.com', platform: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8', redirectUris: 'https://foo.com', platform: 'invalid' }); + assert.strictEqual(actual.success, false); }); it('fails validation if certificateDisplayName is specified without certificate', async () => { - const actual = await command.validate({ options: { objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8', certificateDisplayName: 'Some certificate' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8', certificateDisplayName: 'Some certificate' }); + assert.strictEqual(actual.success, false); }); it('fails validation if both certificateBase64Encoded and certificateFile are specified', async () => { - const actual = await command.validate({ options: { objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8', certificateFile: 'c:\\temp\\some-certificate.cer', certificateBase64Encoded: 'somebase64string' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ objectId: 'c75be2e1-0204-4f95-857d-51a37cf40be8', certificateFile: 'c:\\temp\\some-certificate.cer', certificateBase64Encoded: 'somebase64string' }); + assert.strictEqual(actual.success, false); }); it('passes validation if certificateFile specified with certificateDisplayName', async () => { sinon.stub(fs, 'existsSync').callsFake(_ => true); - const actual = await command.validate({ options: { name: 'My Microsoft Entra app', certificateDisplayName: 'Some certificate', certificateFile: 'c:\\temp\\some-certificate.cer' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ name: 'My Microsoft Entra app', certificateDisplayName: 'Some certificate', certificateFile: 'c:\\temp\\some-certificate.cer' }); + assert.strictEqual(actual.success, true); }); it('fails validation when certificate file is not found', async () => { sinon.stub(fs, 'existsSync').callsFake(_ => false); - const actual = await command.validate({ options: { debug: true, objectId: '95cfe30d-ed44-4f9d-b73d-c66560f72e83', certificateDisplayName: 'some certificate', certificateFile: 'C:\\temp\\some-certificate.cer' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ debug: true, objectId: '95cfe30d-ed44-4f9d-b73d-c66560f72e83', certificateDisplayName: 'some certificate', certificateFile: 'C:\\temp\\some-certificate.cer' }); + assert.strictEqual(actual.success, false); }); it('passes validation if required options specified (appId)', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified (objectId)', async () => { - const actual = await command.validate({ options: { objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' }); + assert.strictEqual(actual.success, true); }); it('passes validation if required options specified (name)', async () => { - const actual = await command.validate({ options: { name: 'My app', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ name: 'My app', uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8' }); + assert.strictEqual(actual.success, true); }); it('passes validation when redirectUris specified with spa', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', redirectUris: 'https://foo.com', platform: 'spa' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', redirectUris: 'https://foo.com', platform: 'spa' }); + assert.strictEqual(actual.success, true); }); it('passes validation when redirectUris specified with publicClient', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', redirectUris: 'https://foo.com', platform: 'publicClient' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', redirectUris: 'https://foo.com', platform: 'publicClient' }); + assert.strictEqual(actual.success, true); }); it('passes validation when redirectUris specified with web', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', redirectUris: 'https://foo.com', platform: 'web' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', redirectUris: 'https://foo.com', platform: 'web' }); + assert.strictEqual(actual.success, true); }); it('passes validation when allowPublicClientFlows is specified as true', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowPublicClientFlows: true } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowPublicClientFlows: true }); + assert.strictEqual(actual.success, true); }); it('passes validation when allowPublicClientFlows is specified as false', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowPublicClientFlows: false } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowPublicClientFlows: false }); + assert.strictEqual(actual.success, true); }); - it('passes validation when allowPublicClientFlows is not correct boolean value', async () => { - const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowPublicClientFlows: 'foo' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation when allowPublicClientFlows is not correct boolean value', async () => { + const actual = commandOptionsSchema.safeParse({ appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowPublicClientFlows: 'foo' }); + assert.strictEqual(actual.success, false); }); - -}); +}); \ No newline at end of file diff --git a/src/m365/entra/commands/app/app-set.ts b/src/m365/entra/commands/app/app-set.ts index 59f50aad70a..a6cfc1ed98c 100644 --- a/src/m365/entra/commands/app/app-set.ts +++ b/src/m365/entra/commands/app/app-set.ts @@ -1,34 +1,40 @@ import { Application, KeyCredential, PublicClientApplication, SpaApplication, WebApplication } from '@microsoft/microsoft-graph-types'; import fs from 'fs'; -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 { entraApp } from '../../../../utils/entraApp.js'; +import { optionsUtils } from '../../../../utils/optionsUtils.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; -import { optionsUtils } from '../../../../utils/optionsUtils.js'; -import { entraApp } from '../../../../utils/entraApp.js'; +import { zod } from '../../../../utils/zod.js'; + +const entraIDApplicationPlatform = ['spa', 'web', 'publicClient'] as const; + +const options = globalOptionsZod + .extend({ + appId: z.string().uuid().optional(), + objectId: z.string().uuid().optional(), + name: z.string().optional(), + platform: z.enum(entraIDApplicationPlatform).optional(), + redirectUris: z.string().optional(), + redirectUrisToRemove: z.string().optional(), + uris: z.string().optional(), + certificateFile: z.string().optional(), + certificateBase64Encoded: z.string().optional(), + certificateDisplayName: z.string().optional(), + allowPublicClientFlows: z.boolean().optional() + }) + .passthrough(); + +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - appId?: string; - objectId?: string; - name?: string; - platform?: string; - redirectUris?: string; - redirectUrisToRemove?: string; - uris?: string; - certificateFile?: string; - certificateBase64Encoded?: string; - certificateDisplayName?: string; - allowPublicClientFlows?: boolean; -} - class EntraAppSetCommand extends GraphCommand { - private static aadApplicationPlatform: string[] = ['spa', 'web', 'publicClient']; - public get name(): string { return commands.APP_SET; } @@ -43,90 +49,39 @@ class EntraAppSetCommand extends GraphCommand { constructor() { super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - appId: typeof args.options.appId !== 'undefined', - objectId: typeof args.options.objectId !== 'undefined', - name: typeof args.options.name !== 'undefined', - platform: typeof args.options.platform !== 'undefined', - redirectUris: typeof args.options.redirectUris !== 'undefined', - redirectUrisToRemove: typeof args.options.redirectUrisToRemove !== 'undefined', - uris: typeof args.options.uris !== 'undefined', - certificateFile: typeof args.options.certificateFile !== 'undefined', - certificateBase64Encoded: typeof args.options.certificateBase64Encoded !== 'undefined', - certificateDisplayName: typeof args.options.certificateDisplayName !== 'undefined', - allowPublicClientFlows: typeof args.options.allowPublicClientFlows !== 'undefined' - }); - this.trackUnknownOptions(this.telemetryProperties, args.options); - }); + public get schema(): z.ZodTypeAny | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { option: '--appId [appId]' }, - { option: '--objectId [objectId]' }, - { option: '-n, --name [name]' }, - { option: '-u, --uris [uris]' }, - { option: '-r, --redirectUris [redirectUris]' }, - { option: '--certificateFile [certificateFile]' }, - { option: '--certificateBase64Encoded [certificateBase64Encoded]' }, - { option: '--certificateDisplayName [certificateDisplayName]' }, - { - option: '--platform [platform]', - autocomplete: EntraAppSetCommand.aadApplicationPlatform - }, - { option: '--redirectUrisToRemove [redirectUrisToRemove]' }, - { - option: '--allowPublicClientFlows [allowPublicClientFlows]', - autocomplete: ['true', 'false'] - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.certificateFile && args.options.certificateBase64Encoded) { - return 'Specify either certificateFile or certificateBase64Encoded but not both'; - } - - if (args.options.certificateDisplayName && !args.options.certificateFile && !args.options.certificateBase64Encoded) { - return 'When you specify certificateDisplayName you also need to specify certificateFile or certificateBase64Encoded'; - } - - if (args.options.certificateFile && !fs.existsSync(args.options.certificateFile as string)) { - return 'Certificate file not found'; + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.appId, options.objectId, options.name].filter(Boolean).length === 1, { + message: 'Specify either appId, objectId, or name but not multiple' + }) + .refine(options => !options.redirectUris || !!options.platform, { + message: 'When you specify redirectUris you also need to specify platform' + }) + .refine(options => !(options.certificateFile && options.certificateBase64Encoded), { + message: 'Specify either certificateFile or certificateBase64Encoded but not both' + }) + .refine(options => { + if (options.certificateDisplayName && !options.certificateFile && !options.certificateBase64Encoded) { + return false; } - - if (args.options.redirectUris && !args.options.platform) { - return `When you specify redirectUris you also need to specify platform`; - } - - if (args.options.platform && - EntraAppSetCommand.aadApplicationPlatform.indexOf(args.options.platform) < 0) { - return `${args.options.platform} is not a valid value for platform. Allowed values are ${EntraAppSetCommand.aadApplicationPlatform.join(', ')}`; + return true; + }, { + message: 'When you specify certificateDisplayName you also need to specify certificateFile or certificateBase64Encoded' + }) + .refine(options => { + if (options.certificateFile && !fs.existsSync(options.certificateFile)) { + return false; } - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['appId', 'objectId', 'name'] }); - } - - #initTypes(): void { - this.types.boolean.push('allowPublicClientFlows'); + }, { + message: 'Certificate file not found' + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -165,7 +120,7 @@ class EntraAppSetCommand extends GraphCommand { } private async updateUnknownOptions(args: CommandArgs, objectId: string): Promise { - const unknownOptions = optionsUtils.getUnknownOptions(args.options, this.options); + const unknownOptions = optionsUtils.getUnknownOptions(args.options, zod.schemaToOptions(this.schema!)); if (Object.keys(unknownOptions).length > 0) { const requestBody = {}; diff --git a/src/m365/graph/commands/openextension/openextension-add.ts b/src/m365/graph/commands/openextension/openextension-add.ts index c02c4ed8c8c..66b7fbd2c48 100644 --- a/src/m365/graph/commands/openextension/openextension-add.ts +++ b/src/m365/graph/commands/openextension/openextension-add.ts @@ -15,7 +15,8 @@ const options = globalOptionsZod resourceId: zod.alias('i', z.string()), resourceType: zod.alias('t', z.enum(['user', 'group', 'device', 'organization'])) }) - .and(z.any()); + .and(z.unknown()); + declare type Options = z.infer; interface CommandArgs { diff --git a/src/m365/graph/commands/openextension/openextension-set.ts b/src/m365/graph/commands/openextension/openextension-set.ts index 151090cc99a..8d3afc535fb 100644 --- a/src/m365/graph/commands/openextension/openextension-set.ts +++ b/src/m365/graph/commands/openextension/openextension-set.ts @@ -16,7 +16,8 @@ const options = globalOptionsZod resourceType: zod.alias('t', z.enum(['user', 'group', 'device', 'organization'])), keepUnchangedProperties: zod.alias('k', z.boolean().optional()) }) - .and(z.any()); + .and(z.unknown()); + declare type Options = z.infer; interface CommandArgs {