From a128082597241437018e04ed4b9e0635f04f0d5b Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 28 May 2026 15:34:54 +0200 Subject: [PATCH] Migrate graph commands to Zod Migrates all remaining graph commands from legacy options/validators pattern to Zod schema validation: - schemaextension-get, -add, -remove, -list, -set - subscription-add - changelog-list Replaces #initOptions(), #initValidators(), #initTelemetry() with Zod schemas using z.strictObject(), z.enum(), .refine(), and getRefinedSchema(). Updates all corresponding spec files to use commandOptionsSchema.safeParse() pattern. Closes pnp/cli-microsoft365#7305 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../commands/changelog/changelog-list.spec.ts | 138 +++--- .../commands/changelog/changelog-list.ts | 157 +++---- .../schemaextension-add.spec.ts | 268 +++++------ .../schemaextension/schemaextension-add.ts | 71 ++- .../schemaextension/schemaextension-get.ts | 28 +- .../schemaextension-list.spec.ts | 56 ++- .../schemaextension/schemaextension-list.ts | 92 ++-- .../schemaextension-remove.spec.ts | 10 - .../schemaextension/schemaextension-remove.ts | 42 +- .../schemaextension-set.spec.ts | 276 ++++++------ .../schemaextension/schemaextension-set.ts | 105 ++--- .../subscription/subscription-add.spec.ts | 416 ++++++++---------- .../commands/subscription/subscription-add.ts | 188 +++----- 13 files changed, 759 insertions(+), 1088 deletions(-) diff --git a/src/m365/graph/commands/changelog/changelog-list.spec.ts b/src/m365/graph/commands/changelog/changelog-list.spec.ts index c1e041ef83b..f12e2564dcf 100644 --- a/src/m365/graph/commands/changelog/changelog-list.spec.ts +++ b/src/m365/graph/commands/changelog/changelog-list.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import commands from '../../commands.js'; import { sinonUtil } from './../../../../utils/sinonUtil.js'; -import command from './changelog-list.js'; +import command, { options } from './changelog-list.js'; describe(commands.CHANGELOG_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const validVersions = 'beta,v1.0'; const validChangeType = 'Addition'; const validServices = 'Groups,Security'; @@ -94,6 +95,7 @@ describe(commands.CHANGELOG_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -135,104 +137,82 @@ describe(commands.CHANGELOG_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['category', 'title', 'description']); }); - it('fails validation if versions contains an invalid value.', async () => { - const actual = command.validate({ - options: { - versions: 'invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if versions contains an invalid value.', () => { + const actual = commandOptionsSchema.safeParse({ + versions: 'invalid' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if changeType is an invalid value.', async () => { - const actual = command.validate({ - options: { - changeType: 'invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if changeType is an invalid value.', () => { + const actual = commandOptionsSchema.safeParse({ + changeType: 'invalid' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if services contains an invalid value.', async () => { - const actual = command.validate({ - options: { - services: 'invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if services contains an invalid value.', () => { + const actual = commandOptionsSchema.safeParse({ + services: 'invalid' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if startDate is invalid ISO date.', async () => { - const actual = command.validate({ - options: { - startDate: 'invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if startDate is invalid ISO date.', () => { + const actual = commandOptionsSchema.safeParse({ + startDate: 'invalid' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if endDate is invalid ISO date.', async () => { - const actual = command.validate({ - options: { - endDate: 'invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if endDate is invalid ISO date.', () => { + const actual = commandOptionsSchema.safeParse({ + endDate: 'invalid' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if endDate is earlier than startDate.', async () => { - const actual = command.validate({ - options: { - endDate: '2018-11-01', - startDate: '2018-12-01' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if endDate is earlier than startDate.', () => { + const actual = commandOptionsSchema.safeParse({ + endDate: '2018-11-01', + startDate: '2018-12-01' + }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation when valid versions specified', async () => { - const actual = await command.validate({ - options: { - versions: validVersions - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when valid versions specified', () => { + const actual = commandOptionsSchema.safeParse({ + versions: validVersions + }); + assert.strictEqual(actual.success, true); }); - it('passes validation when valid changeType specified', async () => { - const actual = await command.validate({ - options: { - changeType: validChangeType - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when valid changeType specified', () => { + const actual = commandOptionsSchema.safeParse({ + changeType: validChangeType + }); + assert.strictEqual(actual.success, true); }); - it('passes validation when valid services specified', async () => { - const actual = await command.validate({ - options: { - services: validServices - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when valid services specified', () => { + const actual = commandOptionsSchema.safeParse({ + services: validServices + }); + assert.strictEqual(actual.success, true); }); - it('passes validation when valid startDate specified', async () => { - const actual = await command.validate({ - options: { - startDate: validStartDate - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when valid startDate specified', () => { + const actual = commandOptionsSchema.safeParse({ + startDate: validStartDate + }); + assert.strictEqual(actual.success, true); }); - it('passes validation when valid endDate specified', async () => { - const actual = await command.validate({ - options: { - endDate: validEndDate - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when valid endDate specified', () => { + const actual = commandOptionsSchema.safeParse({ + endDate: validEndDate + }); + assert.strictEqual(actual.success, true); }); it('retrieves changelog list', async () => { diff --git a/src/m365/graph/commands/changelog/changelog-list.ts b/src/m365/graph/commands/changelog/changelog-list.ts index 24486a1f393..da6b7cc27b0 100644 --- a/src/m365/graph/commands/changelog/changelog-list.ts +++ b/src/m365/graph/commands/changelog/changelog-list.ts @@ -1,7 +1,8 @@ import { DOMParser } from '@xmldom/xmldom'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { md } from '../../../../utils/md.js'; import { validation } from '../../../../utils/validation.js'; @@ -9,31 +10,34 @@ import AnonymousCommand from '../../../base/AnonymousCommand.js'; import { Changelog, ChangelogItem } from '../../Changelog.js'; import commands from '../../commands.js'; +const allowedVersions = ['beta', 'v1.0']; +const allowedChangeTypes = ['Addition', 'Change', 'Deletion', 'Deprecation']; +const allowedServices = [ + 'Applications', 'Calendar', 'Change notifications', 'Cloud communications', + 'Compliance', 'Cross-device experiences', 'Customer booking', 'Device and app management', + 'Education', 'Files', 'Financials', 'Groups', + 'Identity and access', 'Mail', 'Notes', 'Notifications', + 'People and workplace intelligence', 'Personal contacts', 'Reports', 'Search', + 'Security', 'Sites and lists', 'Tasks and plans', 'Teamwork', + 'To-do tasks', 'Users', 'Workbooks and charts' +]; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + versions: z.string().optional().alias('v'), + changeType: z.string().optional().alias('c'), + services: z.string().optional().alias('s'), + startDate: z.string().optional(), + endDate: z.string().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - versions?: string; - changeType?: string; - services?: string; - startDate?: string; - endDate?: string; -} - class GraphChangelogListCommand extends AnonymousCommand { - private allowedVersions: string[] = ['beta', 'v1.0']; - private allowedChangeTypes: string[] = ['Addition', 'Change', 'Deletion', 'Deprecation']; - private allowedServices: string[] = [ - 'Applications', 'Calendar', 'Change notifications', 'Cloud communications', - 'Compliance', 'Cross-device experiences', 'Customer booking', 'Device and app management', - 'Education', 'Files', 'Financials', 'Groups', - 'Identity and access', 'Mail', 'Notes', 'Notifications', - 'People and workplace intelligence', 'Personal contacts', 'Reports', 'Search', - 'Security', 'Sites and lists', 'Tasks and plans', 'Teamwork', - 'To-do tasks', 'Users', 'Workbooks and charts' - ]; - public get name(): string { return commands.CHANGELOG_LIST; } @@ -46,77 +50,56 @@ class GraphChangelogListCommand extends AnonymousCommand { return ['category', 'title', 'description']; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - versions: typeof args.options.versions !== 'undefined', - changeType: typeof args.options.changeType !== 'undefined', - services: typeof args.options.services !== 'undefined', - startDate: typeof args.options.startDate !== 'undefined', - endDate: typeof args.options.endDate !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '-v, --versions [versions]', autocomplete: this.allowedVersions }, - { option: "-c, --changeType [changeType]", autocomplete: this.allowedChangeTypes }, - { option: "-s, --services [services]", autocomplete: this.allowedServices }, - { option: "--startDate [startDate]" }, - { option: "--endDate [endDate]" } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if ( - args.options.versions && - args.options.versions.toLocaleLowerCase().split(',').some(x => !this.allowedVersions.map(y => y.toLocaleLowerCase()).includes(x))) { - return `The verions contains an invalid value. Specify either ${this.allowedVersions.join(', ')} as properties`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => { + if (!options.versions) { + return true; } - - if ( - args.options.changeType && - !this.allowedChangeTypes.map(x => x.toLocaleLowerCase()).includes(args.options.changeType.toLocaleLowerCase())) { - return `The change type contain an invalid value. Specify either ${this.allowedChangeTypes.join(', ')} as properties`; - } - - if ( - args.options.services && - args.options.services.toLocaleLowerCase().split(',').some(x => !this.allowedServices.map(y => y.toLocaleLowerCase()).includes(x))) { - return `The services contains invalid value. Specify either ${this.allowedServices.join(', ')} as properties`; - } - - if (args.options.startDate && !validation.isValidISODate(args.options.startDate)) { - return 'The startDate is not a valid ISO date string'; + return !options.versions.toLocaleLowerCase().split(',').some(x => !allowedVersions.map(y => y.toLocaleLowerCase()).includes(x)); + }, { + error: `The verions contains an invalid value. Specify either ${allowedVersions.join(', ')} as properties`, + path: ['versions'] + }) + .refine(options => { + if (!options.changeType) { + return true; } - - if (args.options.endDate && !validation.isValidISODate(args.options.endDate)) { - return 'The endDate is not a valid ISO date string'; - } - - if (args.options.endDate && args.options.startDate && new Date(args.options.endDate) < new Date(args.options.startDate)) { - return 'The endDate should be later than startDate'; + return allowedChangeTypes.map(x => x.toLocaleLowerCase()).includes(options.changeType.toLocaleLowerCase()); + }, { + error: `The change type contain an invalid value. Specify either ${allowedChangeTypes.join(', ')} as properties`, + path: ['changeType'] + }) + .refine(options => { + if (!options.services) { + return true; } - - return true; - } - ); + return !options.services.toLocaleLowerCase().split(',').some(x => !allowedServices.map(y => y.toLocaleLowerCase()).includes(x)); + }, { + error: `The services contains invalid value. Specify either ${allowedServices.join(', ')} as properties`, + path: ['services'] + }) + .refine(options => !options.startDate || validation.isValidISODate(options.startDate), { + error: 'The startDate is not a valid ISO date string', + path: ['startDate'] + }) + .refine(options => !options.endDate || validation.isValidISODate(options.endDate), { + error: 'The endDate is not a valid ISO date string', + path: ['endDate'] + }) + .refine(options => !(options.endDate && options.startDate && new Date(options.endDate) < new Date(options.startDate)), { + error: 'The endDate should be later than startDate', + path: ['endDate'] + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { try { - const allowedChangeType = args.options.changeType && this.allowedChangeTypes.find(x => x.toLocaleLowerCase() === args.options.changeType!.toLocaleLowerCase()); + const allowedChangeType = args.options.changeType && allowedChangeTypes.find(x => x.toLocaleLowerCase() === args.options.changeType!.toLocaleLowerCase()); const searchParam = args.options.changeType ? `/?filterBy=${allowedChangeType}` : ''; const requestOptions: CliRequestOptions = { @@ -144,17 +127,17 @@ class GraphChangelogListCommand extends AnonymousCommand { let items: ChangelogItem[] = changelog.items; if (options.services) { - const allowedServices: string[] = this.allowedServices + const matchedServices: string[] = allowedServices .filter(allowedService => options.services!.toLocaleLowerCase().split(',').includes(allowedService.toLocaleLowerCase())); - items = changelog.items.filter(item => allowedServices.includes(item.title)); + items = changelog.items.filter(item => matchedServices.includes(item.title)); } if (options.versions) { - const allowedVersions: string[] = this.allowedVersions + const matchedVersions: string[] = allowedVersions .filter(allowedVersion => options.versions!.toLocaleLowerCase().split(',').includes(allowedVersion.toLocaleLowerCase())); - items = items.filter(item => allowedVersions.includes(item.category)); + items = items.filter(item => matchedVersions.includes(item.category)); } if (options.startDate) { diff --git a/src/m365/graph/commands/schemaextension/schemaextension-add.spec.ts b/src/m365/graph/commands/schemaextension/schemaextension-add.spec.ts index f06f1b0e449..599259ca449 100644 --- a/src/m365/graph/commands/schemaextension/schemaextension-add.spec.ts +++ b/src/m365/graph/commands/schemaextension/schemaextension-add.spec.ts @@ -1,8 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; -import { cli } from '../../../../cli/cli.js'; -import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -11,13 +9,13 @@ 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 './schemaextension-add.js'; +import command, { options } from './schemaextension-add.js'; describe(commands.SCHEMAEXTENSION_ADD, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; - let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -25,7 +23,7 @@ describe(commands.SCHEMAEXTENSION_ADD, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; - commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -195,172 +193,146 @@ describe(commands.SCHEMAEXTENSION_ADD, () => { } as any), new CommandError('An error has occurred')); }); - it('fails validation if the owner is not a valid GUID', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'invalid', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Integer"},{"name":"MyString","type":"String"}]' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the owner is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'invalid', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Integer"},{"name":"MyString","type":"String"}]' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if properties is not valid JSON string', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: 'foobar' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if properties is not valid JSON string', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: 'foobar' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if properties have no valid type', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Foo"},{"name":"MyString","type":"String"}]' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if properties have no valid type', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Foo"},{"name":"MyString","type":"String"}]' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if a specified property has missing type', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt"},{"name":"MyString","type":"String"}]' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if a specified property has missing type', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt"},{"name":"MyString","type":"String"}]' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if a specified property has missing name', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"type":"Integer"},{"name":"MyString","type":"String"}]' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if a specified property has missing name', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"type":"Integer"},{"name":"MyString","type":"String"}]' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if properties JSON string is not an array', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '{}' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if properties JSON string is not an array', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '{}' + }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if the owner is a valid GUID', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Integer"},{"name":"MyString","type":"String"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the owner is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Integer"},{"name":"MyString","type":"String"}]' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the optional description is missing', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Integer"},{"name":"MyString","type":"String"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the optional description is missing', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Integer"},{"name":"MyString","type":"String"}]' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the property type is Binary', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Binary"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the property type is Binary', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Binary"}]' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the property type is Boolean', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Boolean"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the property type is Boolean', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Boolean"}]' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the property type is DateTime', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"DateTime"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the property type is DateTime', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"DateTime"}]' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the property type is Integer', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Integer"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the property type is Integer', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Integer"}]' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the property type is String', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"String"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the property type is String', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"String"}]' + }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/graph/commands/schemaextension/schemaextension-add.ts b/src/m365/graph/commands/schemaextension/schemaextension-add.ts index af070e62e40..083f0fa8b02 100644 --- a/src/m365/graph/commands/schemaextension/schemaextension-add.ts +++ b/src/m365/graph/commands/schemaextension/schemaextension-add.ts @@ -1,22 +1,26 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i'), + description: z.string().optional().alias('d'), + owner: z.string(), + targetTypes: z.string().alias('t'), + properties: z.string().alias('p') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - description: string; - id: string; - owner: string; - properties: string; - targetTypes: string; -} - class GraphSchemaExtensionAddCommand extends GraphCommand { public get name(): string { return commands.SCHEMAEXTENSION_ADD; @@ -26,43 +30,22 @@ class GraphSchemaExtensionAddCommand extends GraphCommand { return 'Creates a Microsoft Graph schema extension'; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); + public get schema(): z.ZodType | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id ' - }, - { - option: '-d, --description [description]' - }, - { - option: '--owner ' - }, - { - option: '-t, --targetTypes ' - }, - { - option: '-p, --properties ' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.owner && !validation.isValidGuid(args.options.owner)) { - return `The specified owner '${args.options.owner}' is not a valid App Id`; - } - - return this.validateProperties(args.options.properties); - } - ); + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => validation.isValidGuid(options.owner), { + error: e => `The specified owner '${(e.input as Options).owner}' is not a valid App Id`, + path: ['owner'] + }) + .refine(options => { + return this.validateProperties(options.properties) === true; + }, { + error: e => `${this.validateProperties((e.input as Options).properties)}`, + path: ['properties'] + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/graph/commands/schemaextension/schemaextension-get.ts b/src/m365/graph/commands/schemaextension/schemaextension-get.ts index 61d089d1cc3..75d589a356d 100644 --- a/src/m365/graph/commands/schemaextension/schemaextension-get.ts +++ b/src/m365/graph/commands/schemaextension/schemaextension-get.ts @@ -1,17 +1,21 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; -} - class GraphSchemaExtensionGetCommand extends GraphCommand { public get name(): string { return commands.SCHEMAEXTENSION_GET; @@ -21,18 +25,8 @@ class GraphSchemaExtensionGetCommand extends GraphCommand { return 'Gets the properties of the specified schema extension definition'; } - constructor() { - super(); - - this.#initOptions(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id ' - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/graph/commands/schemaextension/schemaextension-list.spec.ts b/src/m365/graph/commands/schemaextension/schemaextension-list.spec.ts index f34f4b0267d..85ce953b2ff 100644 --- a/src/m365/graph/commands/schemaextension/schemaextension-list.spec.ts +++ b/src/m365/graph/commands/schemaextension/schemaextension-list.spec.ts @@ -1,8 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; -import { cli } from '../../../../cli/cli.js'; -import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -11,13 +9,13 @@ 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 './schemaextension-list.js'; +import command, { options } from './schemaextension-list.js'; describe(commands.SCHEMAEXTENSION_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; - let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -25,7 +23,7 @@ describe(commands.SCHEMAEXTENSION_LIST, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; - commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -481,36 +479,36 @@ describe(commands.SCHEMAEXTENSION_LIST, () => { await assert.rejects(command.action(logger, { options: {} }), new CommandError(errorMessage)); }); - it('passes validation if the owner is a valid GUID', async () => { - const actual = await command.validate({ options: { owner: '68be84bf-a585-4776-80b3-30aa5207aa22' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the owner is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ owner: '68be84bf-a585-4776-80b3-30aa5207aa22' }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the owner is not a valid GUID', async () => { - const actual = await command.validate({ options: { owner: '123' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the owner is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ owner: '123' }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if the status is not a valid status', async () => { - const actual = await command.validate({ options: { status: 'test' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the status is not a valid status', () => { + const actual = commandOptionsSchema.safeParse({ status: 'test' }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if the status is a valid status', async () => { - const actual = await command.validate({ options: { status: 'InDevelopment' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the status is a valid status', () => { + const actual = commandOptionsSchema.safeParse({ status: 'InDevelopment' }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the pageNumber is not positive number', async () => { - const actual = await command.validate({ options: { pageNumber: '-1' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the pageNumber is not positive number', () => { + const actual = commandOptionsSchema.safeParse({ pageNumber: '-1' }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if the pageNumber is a positive number', async () => { - const actual = await command.validate({ options: { pageNumber: '2' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the pageNumber is a positive number', () => { + const actual = commandOptionsSchema.safeParse({ pageNumber: '2' }); + assert.strictEqual(actual.success, true); }); - it('fails validation if the pageSize is not positive number', async () => { - const actual = await command.validate({ options: { pageSize: '-1' } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the pageSize is not positive number', () => { + const actual = commandOptionsSchema.safeParse({ pageSize: '-1' }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if the pageSize is a positive number', async () => { - const actual = await command.validate({ options: { pageSize: '2' } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the pageSize is a positive number', () => { + const actual = commandOptionsSchema.safeParse({ pageSize: '2' }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/graph/commands/schemaextension/schemaextension-list.ts b/src/m365/graph/commands/schemaextension/schemaextension-list.ts index 2e9cd3cce68..8266ff62132 100644 --- a/src/m365/graph/commands/schemaextension/schemaextension-list.ts +++ b/src/m365/graph/commands/schemaextension/schemaextension-list.ts @@ -1,21 +1,25 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + status: z.enum(['Available', 'InDevelopment', 'Deprecated']).optional().alias('s'), + owner: z.string().optional(), + pageSize: z.string().optional().alias('p'), + pageNumber: z.string().optional().alias('n') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - status?: string; - owner?: string; - pageNumber?: string; - pageSize?: string; -} - class GraphSchemaExtensionListCommand extends GraphCommand { public get name(): string { return commands.SCHEMAEXTENSION_LIST; @@ -25,66 +29,24 @@ class GraphSchemaExtensionListCommand extends GraphCommand { return 'Get a list of schemaExtension objects created in the current tenant, that can be InDevelopment, Available, or Deprecated.'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); + public get schema(): z.ZodType | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - status: typeof args.options.status !== 'undefined', - owner: typeof args.options.owner !== 'undefined', - pageNumber: typeof args.options.pageNumber !== 'undefined', - pageSize: typeof args.options.pageSize !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => !options.owner || validation.isValidGuid(options.owner), { + error: e => `${(e.input as Options).owner} is not a valid GUID`, + path: ['owner'] + }) + .refine(options => !options.pageNumber || parseInt(options.pageNumber) >= 1, { + error: 'pageNumber must be a positive number', + path: ['pageNumber'] + }) + .refine(options => !options.pageSize || parseInt(options.pageSize) >= 1, { + error: 'pageSize must be a positive number', + path: ['pageSize'] }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-s, --status [status]', - autocomplete: ['Available', 'InDevelopment', 'Deprecated'] - }, - { - option: '--owner [owner]' - }, - { - option: '-p, --pageSize [pageSize]' - }, - { - option: '-n, --pageNumber [pageNumber]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.owner && !validation.isValidGuid(args.options.owner)) { - return `${args.options.owner} is not a valid GUID`; - } - - if (args.options.pageNumber && parseInt(args.options.pageNumber) < 1) { - return 'pageNumber must be a positive number'; - } - - if (args.options.pageSize && parseInt(args.options.pageSize) < 1) { - return 'pageSize must be a positive number'; - } - - if (args.options.status && - ['Available', 'InDevelopment', 'Deprecated'].indexOf(args.options.status) === -1) { - return `${args.options.status} is not a valid status value. Allowed values are Available|InDevelopment|Deprecated`; - } - - return true; - } - ); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/graph/commands/schemaextension/schemaextension-remove.spec.ts b/src/m365/graph/commands/schemaextension/schemaextension-remove.spec.ts index 7f314111a59..e30817c48ed 100644 --- a/src/m365/graph/commands/schemaextension/schemaextension-remove.spec.ts +++ b/src/m365/graph/commands/schemaextension/schemaextension-remove.spec.ts @@ -140,14 +140,4 @@ describe(commands.SCHEMAEXTENSION_REMOVE, () => { new CommandError('An error has occurred')); }); - it('supports specifying id', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--id') > -1) { - containsOption = true; - } - }); - assert(containsOption); - }); }); diff --git a/src/m365/graph/commands/schemaextension/schemaextension-remove.ts b/src/m365/graph/commands/schemaextension/schemaextension-remove.ts index 8405161c532..08d66d219e8 100644 --- a/src/m365/graph/commands/schemaextension/schemaextension-remove.ts +++ b/src/m365/graph/commands/schemaextension/schemaextension-remove.ts @@ -1,19 +1,23 @@ +import { z } from 'zod'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i'), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; - force?: boolean; -} - class GraphSchemaExtensionRemoveCommand extends GraphCommand { public get name(): string { return commands.SCHEMAEXTENSION_REMOVE; @@ -23,30 +27,8 @@ class GraphSchemaExtensionRemoveCommand extends GraphCommand { return 'Removes specified Microsoft Graph schema extension'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - force: typeof args.options.force !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id ' - }, - { - option: '-f, --force' - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/graph/commands/schemaextension/schemaextension-set.spec.ts b/src/m365/graph/commands/schemaextension/schemaextension-set.spec.ts index d0b31e09889..d30a5adeb4f 100644 --- a/src/m365/graph/commands/schemaextension/schemaextension-set.spec.ts +++ b/src/m365/graph/commands/schemaextension/schemaextension-set.spec.ts @@ -1,8 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; -import { cli } from '../../../../cli/cli.js'; -import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -11,13 +9,13 @@ 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 './schemaextension-set.js'; +import command, { options } from './schemaextension-set.js'; describe(commands.SCHEMAEXTENSION_SET, () => { let log: string[]; let logger: Logger; let loggerLogToStderrSpy: sinon.SinonSpy; - let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -25,7 +23,7 @@ describe(commands.SCHEMAEXTENSION_SET, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; - commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -162,179 +160,151 @@ describe(commands.SCHEMAEXTENSION_SET, () => { } as any), new CommandError('An error has occurred')); }); - it('fails validation if the owner is not a valid GUID', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'invalid', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Integer"},{"name":"MyString","type":"String"}]' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the owner is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'invalid', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Integer"},{"name":"MyString","type":"String"}]' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if no update information is specified', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if no update information is specified', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if properties is not valid JSON string', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: 'foobar' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if properties is not valid JSON string', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: 'foobar' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if properties have no valid type', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Foo"},{"name":"MyString","type":"String"}]' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if properties have no valid type', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Foo"},{"name":"MyString","type":"String"}]' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if a specified property has missing type', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt"},{"name":"MyString","type":"String"}]' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if a specified property has missing type', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt"},{"name":"MyString","type":"String"}]' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if a specified property has missing name', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"type":"Integer"},{"name":"MyString","type":"String"}]' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if a specified property has missing name', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"type":"Integer"},{"name":"MyString","type":"String"}]' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if properties JSON string is not an array', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '{}' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if properties JSON string is not an array', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '{}' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if status is not valid', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: 'Test Description', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - status: 'invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if status is not valid', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + description: 'Test Description', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + status: 'invalid' + }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if required parameters are set and at least one property to update (description) is specified', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - description: 'test' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if required parameters are set and at least one property to update (description) is specified', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + description: 'test' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the property type is Binary', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Binary"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the property type is Binary', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Binary"}]' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the property type is Boolean', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Boolean"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the property type is Boolean', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Boolean"}]' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the property type is DateTime', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"DateTime"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the property type is DateTime', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"DateTime"}]' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the property type is Integer', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"Integer"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the property type is Integer', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"Integer"}]' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the property type is String', async () => { - const actual = await command.validate({ - options: { - id: 'TestSchemaExtension', - description: null, - owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', - targetTypes: 'Group', - properties: '[{"name":"MyInt","type":"String"}]' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the property type is String', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestSchemaExtension', + + owner: 'b07a45b3-f7b7-489b-9269-da6f3f93dff0', + targetTypes: 'Group', + properties: '[{"name":"MyInt","type":"String"}]' + }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/graph/commands/schemaextension/schemaextension-set.ts b/src/m365/graph/commands/schemaextension/schemaextension-set.ts index 4f222d726d9..434130f5206 100644 --- a/src/m365/graph/commands/schemaextension/schemaextension-set.ts +++ b/src/m365/graph/commands/schemaextension/schemaextension-set.ts @@ -1,23 +1,27 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i'), + owner: z.string(), + description: z.string().optional().alias('d'), + status: z.enum(['Available', 'Deprecated']).optional().alias('s'), + targetTypes: z.string().optional().alias('t'), + properties: z.string().optional().alias('p') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; - owner: string; - description?: string; - status?: string; - targetTypes?: string; - properties?: string; -} - class GraphSchemaExtensionSetCommand extends GraphCommand { public get name(): string { return commands.SCHEMAEXTENSION_SET; @@ -27,71 +31,28 @@ class GraphSchemaExtensionSetCommand extends GraphCommand { return 'Updates a Microsoft Graph schema extension'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); + public get schema(): z.ZodType | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - description: typeof args.options.description !== 'undefined', - properties: typeof args.options.properties !== 'undefined', - targetTypes: typeof args.options.targetTypes !== 'undefined', - status: args.options.status - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id ' - }, - { - option: '--owner ' - }, - { - option: '-d, --description [description]' - }, - { - option: '-s, --status [status]' - }, - { - option: '-t, --targetTypes [targetTypes]' - }, - { - option: '-p, --properties [properties]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (!validation.isValidGuid(args.options.owner)) { - return `The specified owner '${args.options.owner}' is not a valid App Id`; - } - - if (!args.options.status && !args.options.properties && !args.options.targetTypes && !args.options.description) { - return `No updates were specified. Please specify at least one argument among --status, --targetTypes, --description or --properties`; - } - - const validStatusValues = ['Available', 'Deprecated']; - if (args.options.status && validStatusValues.indexOf(args.options.status) < 0) { - return `Status option is invalid. Valid statuses are: Available or Deprecated`; - } - - if (args.options.properties) { - return this.validateProperties(args.options.properties); + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => validation.isValidGuid(options.owner), { + error: e => `The specified owner '${(e.input as Options).owner}' is not a valid App Id`, + path: ['owner'] + }) + .refine(options => options.status || options.properties || options.targetTypes || options.description, { + error: 'No updates were specified. Please specify at least one argument among --status, --targetTypes, --description or --properties' + }) + .refine(options => { + if (!options.properties) { + return true; } - - return true; - } - ); + return this.validateProperties(options.properties) === true; + }, { + error: e => `${this.validateProperties((e.input as Options).properties!)}`, + path: ['properties'] + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/graph/commands/subscription/subscription-add.spec.ts b/src/m365/graph/commands/subscription/subscription-add.spec.ts index d684c93c085..b2fb7ec8ad5 100644 --- a/src/m365/graph/commands/subscription/subscription-add.spec.ts +++ b/src/m365/graph/commands/subscription/subscription-add.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './subscription-add.js'; +import command, { options } from './subscription-add.js'; describe(commands.SUBSCRIPTION_ADD, () => { let log: string[]; let logger: Logger; let loggerLogToStderrSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const mockNowNumber = Date.parse("2019-01-01T00:00:00.000Z"); before(() => { @@ -27,6 +28,7 @@ describe(commands.SUBSCRIPTION_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -411,265 +413,217 @@ describe(commands.SUBSCRIPTION_ADD, () => { } as any), new CommandError('An error has occurred')); }); - it('fails validation if expirationDateTime is not valid', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - expirationDateTime: 'foo' - } - }, commandInfo); - assert.notStrictEqual(actual, true); - }); - - it('fails validation if notificationUrl is not valid', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "foo", - expirationDateTime: '2016-11-20T18:23:45.935Z' - } - }, commandInfo); - assert.notStrictEqual(actual, true); - }); - - it('fails validation if changeTypes is not valid', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'foo', - clientState: 'secretClientValue', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - expirationDateTime: '2016-11-20T18:23:45.935Z' - } - }, commandInfo); - assert.notStrictEqual(actual, true); - }); - - it('fails validation if the clientState exceeds maximum allowed length', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - expirationDateTime: null - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if expirationDateTime is not valid', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", + expirationDateTime: 'foo' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if the notificationUrlAppId is not a valid GUID', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - notificationUrlAppId: 'foo', - expirationDateTime: null - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if notificationUrl is not valid', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "foo", + expirationDateTime: '2016-11-20T18:23:45.935Z' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if latestTLSVersion is not valid', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - expirationDateTime: '2016-11-20T18:23:45.935Z', - latestTLSVersion: 'foo' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if changeTypes is not valid', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'foo', + clientState: 'secretClientValue', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", + expirationDateTime: '2016-11-20T18:23:45.935Z' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if lifecycleNotificationUrl is not valid', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: 'https://webhook.azurewebsites.net/api/send/myNotifyClient', - expirationDateTime: '2016-11-20T18:23:45.935Z', - lifecycleNotificationUrl: 'foo' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the clientState exceeds maximum allowed length', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient" + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if resource data should be included, but encryptionCertificate is not set', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: 'https://webhook.azurewebsites.net/api/send/myNotifyClient', - expirationDateTime: '2016-11-20T18:23:45.935Z', - withResourceData: true, - encryptionCertificateId: 'MyCert' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if the notificationUrlAppId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", + notificationUrlAppId: 'foo' + }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if resource data should be included, but encryptionCertificateId is not set', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: 'https://webhook.azurewebsites.net/api/send/myNotifyClient', - expirationDateTime: '2016-11-20T18:23:45.935Z', - withResourceData: true, - encryptionCertificate: 'Q0xJIGZvciBNaWNyb3NvZnQgMzY1' - } - }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if latestTLSVersion is not valid', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", + expirationDateTime: '2016-11-20T18:23:45.935Z', + latestTLSVersion: 'foo' + }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if the expirationDateTime is not specified', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - expirationDateTime: null - } - }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if lifecycleNotificationUrl is not valid', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: 'https://webhook.azurewebsites.net/api/send/myNotifyClient', + expirationDateTime: '2016-11-20T18:23:45.935Z', + lifecycleNotificationUrl: 'foo' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if resource data should be included, but encryptionCertificate is not set', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: 'https://webhook.azurewebsites.net/api/send/myNotifyClient', + expirationDateTime: '2016-11-20T18:23:45.935Z', + withResourceData: true, + encryptionCertificateId: 'MyCert' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if resource data should be included, but encryptionCertificateId is not set', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: 'https://webhook.azurewebsites.net/api/send/myNotifyClient', + expirationDateTime: '2016-11-20T18:23:45.935Z', + withResourceData: true, + encryptionCertificate: 'Q0xJIGZvciBNaWNyb3NvZnQgMzY1' + }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation if the notificationUrl points to valid https URL', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - expirationDateTime: null - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the expirationDateTime is not specified', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient" + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the notificationUrl points to valid Azure Event Hub location', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "EventHub:https://exchangenotifications.servicebus.windows.net/eventhubname/inboxmessages?tenantId=contoso.com", - expirationDateTime: null - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the notificationUrl points to valid https URL', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient" + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the notificationUrl points to valid Azure Event Grid Partner Topic', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "EventGrid:?azuresubscriptionid=b07a45b3-f7b7-489b-9269-da6f3f93dff0&resourcegroup=rg-graph-api&partnertopic=messages&location=germanywestcentral", - expirationDateTime: null - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the notificationUrl points to valid Azure Event Hub location', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "EventHub:https://exchangenotifications.servicebus.windows.net/eventhubname/inboxmessages?tenantId=contoso.com" + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the lifecycleNotificationUrl points to valid https URL', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - lifecycleNotificationUrl: "https://webhook.azurewebsites.net/api/lifecycleNotifications", - expirationDateTime: null - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the notificationUrl points to valid Azure Event Grid Partner Topic', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "EventGrid:?azuresubscriptionid=b07a45b3-f7b7-489b-9269-da6f3f93dff0&resourcegroup=rg-graph-api&partnertopic=messages&location=germanywestcentral" + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the lifecycleNotificationUrl points to valid Azure Event Hub location', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "EventHub:https://exchangenotifications.servicebus.windows.net/eventhubname/inboxmessages?tenantId=contoso.com", - lifecycleNotificationUrl: "EventHub:https://exchangenotifications.servicebus.windows.net/eventhubname/inboxmessages?tenantId=contoso.com", - expirationDateTime: null - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the lifecycleNotificationUrl points to valid https URL', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", + lifecycleNotificationUrl: "https://webhook.azurewebsites.net/api/lifecycleNotifications" + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the lifecycleNotificationUrl points to valid Azure Event Grid Partner Topic', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "EventGrid:?azuresubscriptionid=b07a45b3-f7b7-489b-9269-da6f3f93dff0&resourcegroup=rg-graph-api&partnertopic=messages&location=germanywestcentral", - lifecycleNotificationUrl: "EventGrid:?azuresubscriptionid=b07a45b3-f7b7-489b-9269-da6f3f93dff0&resourcegroup=rg-graph-api&partnertopic=messages&location=germanywestcentral", - expirationDateTime: null - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the lifecycleNotificationUrl points to valid Azure Event Hub location', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "EventHub:https://exchangenotifications.servicebus.windows.net/eventhubname/inboxmessages?tenantId=contoso.com", + lifecycleNotificationUrl: "EventHub:https://exchangenotifications.servicebus.windows.net/eventhubname/inboxmessages?tenantId=contoso.com" + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if the notificationUrlAppId is a valid GUID', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - notificationUrlAppId: '24d3b144-21ae-4080-943f-7067b395b913', - expirationDateTime: null - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the lifecycleNotificationUrl points to valid Azure Event Grid Partner Topic', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "EventGrid:?azuresubscriptionid=b07a45b3-f7b7-489b-9269-da6f3f93dff0&resourcegroup=rg-graph-api&partnertopic=messages&location=germanywestcentral", + lifecycleNotificationUrl: "EventGrid:?azuresubscriptionid=b07a45b3-f7b7-489b-9269-da6f3f93dff0&resourcegroup=rg-graph-api&partnertopic=messages&location=germanywestcentral" + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if latestTLSVersion is valid', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - expirationDateTime: '2016-11-20T18:23:45.935Z', - latestTLSVersion: 'v1_3' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if the notificationUrlAppId is a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", + notificationUrlAppId: '24d3b144-21ae-4080-943f-7067b395b913' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation if resource data should be included and encryptionCertificate is specified together with encryptionCertificateId', async () => { - const actual = await command.validate({ - options: { - resource: "me/mailFolders('Inbox')/messages", - changeTypes: 'updated', - clientState: 'secretClientValue', - notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", - expirationDateTime: '2016-11-20T18:23:45.935Z', - withResourceData: true, - encryptionCertificate: 'Q0xJIGZvciBNaWNyb3NvZnQgMzY1', - encryptionCertificateId: 'MyCert' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation if latestTLSVersion is valid', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", + expirationDateTime: '2016-11-20T18:23:45.935Z', + latestTLSVersion: 'v1_3' + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if resource data should be included and encryptionCertificate is specified together with encryptionCertificateId', () => { + const actual = commandOptionsSchema.safeParse({ + resource: "me/mailFolders('Inbox')/messages", + changeTypes: 'updated', + clientState: 'secretClientValue', + notificationUrl: "https://webhook.azurewebsites.net/api/send/myNotifyClient", + expirationDateTime: '2016-11-20T18:23:45.935Z', + withResourceData: true, + encryptionCertificate: 'Q0xJIGZvciBNaWNyb3NvZnQgMzY1', + encryptionCertificateId: 'MyCert' + }); + assert.strictEqual(actual.success, true); }); }); \ No newline at end of file diff --git a/src/m365/graph/commands/subscription/subscription-add.ts b/src/m365/graph/commands/subscription/subscription-add.ts index d6e11dff7cb..3d359c7d2ad 100644 --- a/src/m365/graph/commands/subscription/subscription-add.ts +++ b/src/m365/graph/commands/subscription/subscription-add.ts @@ -1,28 +1,34 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +const allowedTlsVersions = ['v1_0', 'v1_1', 'v1_2', 'v1_3'] as const; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + resource: z.string().alias('r'), + notificationUrl: z.string().alias('u'), + changeTypes: z.string().alias('c'), + expirationDateTime: z.string().optional().alias('e'), + clientState: z.string().optional().alias('s'), + lifecycleNotificationUrl: z.string().optional(), + notificationUrlAppId: z.string().optional(), + latestTLSVersion: z.enum(allowedTlsVersions).optional(), + withResourceData: z.boolean().optional(), + encryptionCertificate: z.string().optional(), + encryptionCertificateId: z.string().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - resource: string; - changeTypes: string; - notificationUrl: string; - expirationDateTime?: string; - clientState?: string; - lifecycleNotificationUrl?: string; - notificationUrlAppId?: string; - latestTLSVersion?: string; - withResourceData?: boolean; - encryptionCertificate?: string; - encryptionCertificateId?: string; -} - const DEFAULT_EXPIRATION_DELAY_IN_MINUTES_PER_RESOURCE_TYPE = { // User, group, other directory resources 4230 minutes (under 3 days) "users": 4230, @@ -44,7 +50,6 @@ const DEFAULT_EXPIRATION_DELAY_IN_MINUTES = 4230; const SAFE_MINUTES_DELTA = 1; class GraphSubscriptionAddCommand extends GraphCommand { - private allowedTlsVersions: string[] = ['v1_0', 'v1_1', 'v1_2', 'v1_3']; public get name(): string { return commands.SUBSCRIPTION_ADD; } @@ -53,116 +58,53 @@ class GraphSubscriptionAddCommand extends GraphCommand { return 'Creates a Microsoft Graph subscription'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - changeTypes: args.options.changeTypes, - expirationDateTime: typeof args.options.expirationDateTime !== 'undefined', - clientState: typeof args.options.clientState !== 'undefined', - lifecycleNotificationUrl: typeof args.options.lifecycleNotificationUrl !== 'undefined', - notificationUrlAppId: typeof args.options.notificationUrlAppId !== 'undefined', - latestTLSVersion: typeof args.options.latestTLSVersion !== 'undefined', - withResourceData: !!args.options.withResourceData, - encryptionCertificate: typeof args.options.encryptionCertificate !== 'undefined', - encryptionCertificateId: typeof args.options.encryptionCertificateId !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-r, --resource ' - }, - { - option: '-u, --notificationUrl ' - }, - { - option: '-c, --changeTypes ', - autocomplete: ['created', 'updated', 'deleted'] - }, - { - option: '-e, --expirationDateTime [expirationDateTime]' - }, - { - option: '-s, --clientState [clientState]' - }, - { - option: '--lifecycleNotificationUrl [lifecycleNotificationUrl]' - }, - { - option: '--notificationUrlAppId [notificationUrlAppId]' - }, - { - option: '--latestTLSVersion [latestTLSVersion]', - autocomplete: this.allowedTlsVersions - }, - { - option: '--withResourceData [withResourceData]' - }, - { - option: '--encryptionCertificate [encryptionCertificate]' - }, - { - option: '--encryptionCertificateId [encryptionCertificateId]' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (!args.options.notificationUrl.toLowerCase().startsWith('https://') - && !args.options.notificationUrl.toLowerCase().startsWith('eventhub:https://') - && !args.options.notificationUrl.toLowerCase().startsWith('eventgrid:?azuresubscriptionid=')) { - return `The specified notification URL '${args.options.notificationUrl}' does not start with either 'https://' or 'EventHub:https://' or 'EventGrid:?azuresubscriptionid='`; + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => { + const url = options.notificationUrl.toLowerCase(); + return url.startsWith('https://') || url.startsWith('eventhub:https://') || url.startsWith('eventgrid:?azuresubscriptionid='); + }, { + error: e => `The specified notification URL '${(e.input as Options).notificationUrl}' does not start with either 'https://' or 'EventHub:https://' or 'EventGrid:?azuresubscriptionid='`, + path: ['notificationUrl'] + }) + .refine(options => this.isValidChangeTypes(options.changeTypes), { + error: `The specified changeTypes are invalid. Valid options are 'created', 'updated' and 'deleted'`, + path: ['changeTypes'] + }) + .refine(options => !options.expirationDateTime || validation.isValidISODateTime(options.expirationDateTime), { + error: 'The expirationDateTime is not a valid ISO date string', + path: ['expirationDateTime'] + }) + .refine(options => !options.clientState || options.clientState.length <= 128, { + error: 'The clientState value exceeds the maximum length of 128 characters', + path: ['clientState'] + }) + .refine(options => { + if (!options.lifecycleNotificationUrl) { + return true; } - - if (!this.isValidChangeTypes(args.options.changeTypes)) { - return `The specified changeTypes are invalid. Valid options are 'created', 'updated' and 'deleted'`; - } - - if (args.options.expirationDateTime && !validation.isValidISODateTime(args.options.expirationDateTime)) { - return 'The expirationDateTime is not a valid ISO date string'; - } - - if (args.options.clientState && args.options.clientState.length > 128) { - return 'The clientState value exceeds the maximum length of 128 characters'; - } - - if (args.options.lifecycleNotificationUrl && !args.options.lifecycleNotificationUrl.toLowerCase().startsWith('https://') - && !args.options.lifecycleNotificationUrl.toLowerCase().startsWith('eventhub:https://') - && !args.options.lifecycleNotificationUrl.toLowerCase().startsWith('eventgrid:?azuresubscriptionid=')) { - return `The lifecycle notification URL '${args.options.lifecycleNotificationUrl}' does not start with either 'https://' or 'EventHub:https://' or 'EventGrid:?azuresubscriptionid='`; - } - - if (args.options.latestTLSVersion && this.allowedTlsVersions.map(x => x.toLowerCase()).indexOf(args.options.latestTLSVersion.toLowerCase()) === -1) { - return `${args.options.latestTLSVersion} is not a valid TLS version. Allowed values are ${this.allowedTlsVersions.join(', ')}`; - } - - if (args.options.withResourceData && !args.options.encryptionCertificate) { - return `The 'encryptionCertificate' options is required to include the changed resource data`; - } - - if (args.options.withResourceData && !args.options.encryptionCertificateId) { - return `The 'encryptionCertificateId' options is required to include the changed resource data`; - } - - if (args.options.notificationUrlAppId && !validation.isValidGuid(args.options.notificationUrlAppId)) { - return `${args.options.notificationUrlAppId} is not a valid GUID for the 'notificationUrlAppId'`; - } - - return true; - } - ); + const url = options.lifecycleNotificationUrl.toLowerCase(); + return url.startsWith('https://') || url.startsWith('eventhub:https://') || url.startsWith('eventgrid:?azuresubscriptionid='); + }, { + error: e => `The lifecycle notification URL '${(e.input as Options).lifecycleNotificationUrl}' does not start with either 'https://' or 'EventHub:https://' or 'EventGrid:?azuresubscriptionid='`, + path: ['lifecycleNotificationUrl'] + }) + .refine(options => !options.withResourceData || options.encryptionCertificate, { + error: `The 'encryptionCertificate' options is required to include the changed resource data`, + path: ['encryptionCertificate'] + }) + .refine(options => !options.withResourceData || options.encryptionCertificateId, { + error: `The 'encryptionCertificateId' options is required to include the changed resource data`, + path: ['encryptionCertificateId'] + }) + .refine(options => !options.notificationUrlAppId || validation.isValidGuid(options.notificationUrlAppId), { + error: e => `${(e.input as Options).notificationUrlAppId} is not a valid GUID for the 'notificationUrlAppId'`, + path: ['notificationUrlAppId'] + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise {