diff --git a/src/m365/external/commands/connection/connection-add.spec.ts b/src/m365/external/commands/connection/connection-add.spec.ts index 0b8d932f6ca..bb49c215bb3 100644 --- a/src/m365/external/commands/connection/connection-add.spec.ts +++ b/src/m365/external/commands/connection/connection-add.spec.ts @@ -12,12 +12,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 './connection-add.js'; +import command, { options } from './connection-add.js'; describe(commands.CONNECTION_ADD, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; const externalConnectionAddResponse: ExternalConnectors.ExternalConnection = { configuration: { @@ -48,6 +49,7 @@ describe(commands.CONNECTION_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -158,102 +160,67 @@ describe(commands.CONNECTION_ADD, () => { new CommandError(`An error has occurred`)); }); - it('fails validation if id is less than 3 characters', async () => { - const actual = await command.validate({ - options: { - id: 'T', - name: 'Test Connection for CLI', - description: 'Test connection' - } - }, commandInfo); - assert.notStrictEqual(actual, false); - }); - - it('fails validation if id is more than 32 characters', async () => { - const actual = await command.validate({ - options: { - id: 'TestConnectionForCLIXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', - name: 'Test Connection for CLI', - description: 'Test connection' - } - }, commandInfo); - assert.notStrictEqual(actual, false); - }); - - it('fails validation if id is not alphanumeric', async () => { - const actual = await command.validate({ - options: { - id: 'Test_Connection!', - name: 'Test Connection for CLI', - description: 'Test connection' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation if id is less than 3 characters', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'T', + name: 'Test Connection for CLI', + description: 'Test connection' + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if id starts with Microsoft', async () => { - const actual = await command.validate({ - options: { - id: 'MicrosoftTestConnectionForCLI', - name: 'Test Connection for CLI', - description: 'Test connection' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation if id is more than 32 characters', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'TestConnectionForCLIXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'Test Connection for CLI', + description: 'Test connection' + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if id is SharePoint', async () => { - const actual = await command.validate({ - options: { - id: 'SharePoint', - name: 'Test Connection for CLI', - description: 'Test connection' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation if id is not alphanumeric', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'Test_Connection!', + name: 'Test Connection for CLI', + description: 'Test connection' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation for a valid id', async () => { - const actual = await command.validate({ - options: { - id: 'myapp', - name: 'Test Connection for CLI', - description: 'Test connection' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if id starts with Microsoft', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'MicrosoftTestConnectionForCLI', + name: 'Test Connection for CLI', + description: 'Test connection' + }); + assert.strictEqual(actual.success, false); }); - it('supports specifying id', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--id') > -1) { - containsOption = true; - } + it('fails validation if id is SharePoint', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'SharePoint', + name: 'Test Connection for CLI', + description: 'Test connection' }); - assert(containsOption); + assert.strictEqual(actual.success, false); }); - it('supports specifying name', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--name') > -1) { - containsOption = true; - } + it('passes validation for a valid id', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'myapp', + name: 'Test Connection for CLI', + description: 'Test connection' }); - assert(containsOption); + assert.strictEqual(actual.success, true); }); - it('supports specifying description', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--description') > -1) { - containsOption = true; - } + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'myapp', + name: 'Test Connection for CLI', + description: 'Test connection', + unknownOption: 'value' }); - assert(containsOption); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/external/commands/connection/connection-add.ts b/src/m365/external/commands/connection/connection-add.ts index b744fe78485..7c6c0aea2c8 100644 --- a/src/m365/external/commands/connection/connection-add.ts +++ b/src/m365/external/commands/connection/connection-add.ts @@ -1,21 +1,55 @@ import { ExternalConnectors } from '@microsoft/microsoft-graph-types/microsoft-graph'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +const invalidIds: string[] = ['None', + 'Directory', + 'Exchange', + 'ExchangeArchive', + 'LinkedIn', + 'Mailbox', + 'OneDriveBusiness', + 'SharePoint', + 'Teams', + 'Yammer', + 'Connectors', + 'TaskFabric', + 'PowerBI', + 'Assistant', + 'TopicEngine', + 'MSFT_All_Connectors' +]; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string() + .min(3, 'ID must be between 3 and 32 characters in length.') + .max(32, 'ID must be between 3 and 32 characters in length.') + .refine(id => !/[^\w]|_/g.test(id), { + message: 'ID must only contain alphanumeric characters.' + }) + .refine(id => !(id.length > 9 && id.startsWith('Microsoft')), { + message: 'ID cannot begin with Microsoft' + }) + .refine(id => !invalidIds.includes(id), { + error: () => `ID cannot be one of the following values: ${invalidIds.join(', ')}.` + }) + .alias('i'), + name: z.string().alias('n'), + description: z.string().alias('d'), + authorizedAppIds: z.string().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; - name: string; - description: string; - authorizedAppIds?: string; -} - class ExternalConnectionAddCommand extends GraphCommand { public get name(): string { return commands.CONNECTION_ADD; @@ -29,83 +63,8 @@ class ExternalConnectionAddCommand extends GraphCommand { return [commands.EXTERNALCONNECTION_ADD]; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - authorizedAppIds: typeof args.options.authorizedAppIds !== 'undefined' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id ' - }, - { - option: '-n, --name ' - }, - { - option: '-d, --description ' - }, - { - option: '--authorizedAppIds [authorizedAppIds]' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const id = args.options.id; - if (id.length < 3 || id.length > 32) { - return 'ID must be between 3 and 32 characters in length.'; - } - - const alphaNumericRegEx = /[^\w]|_/g; - - if (alphaNumericRegEx.test(id)) { - return 'ID must only contain alphanumeric characters.'; - } - - if (id.length > 9 && - id.startsWith('Microsoft')) { - return 'ID cannot begin with Microsoft'; - } - - const invalidIds: string[] = ['None', - 'Directory', - 'Exchange', - 'ExchangeArchive', - 'LinkedIn', - 'Mailbox', - 'OneDriveBusiness', - 'SharePoint', - 'Teams', - 'Yammer', - 'Connectors', - 'TaskFabric', - 'PowerBI', - 'Assistant', - 'TopicEngine', - 'MSFT_All_Connectors' - ]; - - if (invalidIds.indexOf(id) > -1) { - return `ID cannot be one of the following values: ${invalidIds.join(', ')}.`; - } - - return true; - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/external/commands/connection/connection-doctor.spec.ts b/src/m365/external/commands/connection/connection-doctor.spec.ts index a49e2ce80ca..964a82d7b4c 100644 --- a/src/m365/external/commands/connection/connection-doctor.spec.ts +++ b/src/m365/external/commands/connection/connection-doctor.spec.ts @@ -12,7 +12,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './connection-doctor.js'; +import command, { options } from './connection-doctor.js'; describe(commands.CONNECTION_DOCTOR, () => { const logger: Logger = { @@ -22,6 +22,7 @@ describe(commands.CONNECTION_DOCTOR, () => { }; let commandInfo: CommandInfo; let loggerLogSpy: sinon.SinonSpy; + let commandOptionsSchema: typeof options; const externalConnection: ExternalConnectors.ExternalConnection = { "id": "msgraphdocs", @@ -237,6 +238,7 @@ describe(commands.CONNECTION_DOCTOR, () => { auth.connection.active = true; commandInfo = cli.getCommandInfo(command); loggerLogSpy = sinon.spy(logger, 'log'); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); afterEach(() => { @@ -2256,63 +2258,50 @@ describe(commands.CONNECTION_DOCTOR, () => { assert(output.indexOf('Check|Type|Status|Error message') > -1); }); - it('fails validation if an invalid ux is specified', async () => { - const actual = await command.validate({ - options: { - id: 'msgraphdocs', - ux: 'invalid' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation if an invalid ux is specified', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'msgraphdocs', + ux: 'invalid' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation for ux copilot', async () => { - const actual = await command.validate({ - options: { - id: 'msgraphdocs', - ux: 'copilot' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for ux copilot', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'msgraphdocs', + ux: 'copilot' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation for ux search', async () => { - const actual = await command.validate({ - options: { - id: 'msgraphdocs', - ux: 'search' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for ux search', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'msgraphdocs', + ux: 'search' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation for ux all', async () => { - const actual = await command.validate({ - options: { - id: 'msgraphdocs', - ux: 'all' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for ux all', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'msgraphdocs', + ux: 'all' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation when no ux specified', async () => { - const actual = await command.validate({ - options: { - id: 'msgraphdocs' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when no ux specified', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'msgraphdocs' + }); + assert.strictEqual(actual.success, true); }); - it('supports specifying ux', () => { - const options = command.options; - let containsOption = false; - options.forEach(o => { - if (o.option.indexOf('--ux') > -1) { - containsOption = true; - } + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'msgraphdocs', + unknownOption: 'value' }); - assert(containsOption); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/external/commands/connection/connection-doctor.ts b/src/m365/external/commands/connection/connection-doctor.ts index 5eae3b272d7..214d344fc2c 100644 --- a/src/m365/external/commands/connection/connection-doctor.ts +++ b/src/m365/external/commands/connection/connection-doctor.ts @@ -1,6 +1,7 @@ import { ExternalConnectors, SearchResponse } from '@microsoft/microsoft-graph-types'; import os from 'os'; -import Command from '../../../../Command.js'; +import { z } from 'zod'; +import Command, { globalOptionsZod } from '../../../../Command.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import { Logger } from '../../../../cli/Logger.js'; import request, { CliRequestOptions } from '../../../../request.js'; @@ -9,15 +10,20 @@ import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { CheckStatus, formatting } from '../../../../utils/formatting.js'; +const supportedUx = ['copilot', 'search', 'all'] as const; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i'), + ux: z.enum(supportedUx).optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; - ux?: string; -} - /** * Defines a check that can be run by the doctor command */ @@ -49,7 +55,6 @@ interface CheckResult { class ExternalConnectionDoctorCommand extends GraphCommand { private checksStatus: CheckResult[] = []; - private static readonly supportedUx: string[] = ['copilot', 'search', 'all']; public get name(): string { return commands.CONNECTION_DOCTOR; @@ -59,37 +64,8 @@ class ExternalConnectionDoctorCommand extends GraphCommand { return 'Checks if the external connection is correctly configured for use with the specified Microsoft 365 experience'; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id ' - }, - { - option: '--ux [ux]', - autocomplete: ExternalConnectionDoctorCommand.supportedUx - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.ux) { - if (!ExternalConnectionDoctorCommand.supportedUx.find(u => u === args.options.ux)) { - return `${args.options.ux} is not a valid UX. Allowed values are ${ExternalConnectionDoctorCommand.supportedUx.join(', ')}`; - } - } - - return true; - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/external/commands/connection/connection-get.spec.ts b/src/m365/external/commands/connection/connection-get.spec.ts index 46f115009f6..529aa3bd2df 100644 --- a/src/m365/external/commands/connection/connection-get.spec.ts +++ b/src/m365/external/commands/connection/connection-get.spec.ts @@ -1,7 +1,10 @@ import { ExternalConnectors } from '@microsoft/microsoft-graph-types'; import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; @@ -16,6 +19,8 @@ describe(commands.CONNECTION_GET, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: ReturnType; const externalConnection: ExternalConnectors.ExternalConnection = { "id": "contosohr", @@ -35,6 +40,8 @@ describe(commands.CONNECTION_GET, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse(); }); beforeEach(() => { @@ -155,4 +162,29 @@ describe(commands.CONNECTION_GET, () => { } }), new CommandError(`External connection with name 'Contoso HR' not found`)); }); + + it('passes validation with id specified', () => { + const actual = (commandOptionsSchema as z.ZodType).safeParse({ id: 'contosohr' }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with name specified', () => { + const actual = (commandOptionsSchema as z.ZodType).safeParse({ name: 'Contoso HR' }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with both id and name specified', () => { + const actual = (commandOptionsSchema as z.ZodType).safeParse({ id: 'contosohr', name: 'Contoso HR' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with neither id nor name specified', () => { + const actual = (commandOptionsSchema as z.ZodType).safeParse({}); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = (commandOptionsSchema as z.ZodType).safeParse({ id: 'contosohr', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); + }); }); diff --git a/src/m365/external/commands/connection/connection-get.ts b/src/m365/external/commands/connection/connection-get.ts index 7b5d53898dd..1af5740410f 100644 --- a/src/m365/external/commands/connection/connection-get.ts +++ b/src/m365/external/commands/connection/connection-get.ts @@ -1,19 +1,23 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().optional().alias('i'), + name: z.string().optional().alias('n') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - name?: string; -} - class ExternalConnectionGetCommand extends GraphCommand { public get name(): string { return commands.CONNECTION_GET; @@ -27,36 +31,19 @@ class ExternalConnectionGetCommand extends GraphCommand { return [commands.EXTERNALCONNECTION_GET]; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initOptionSets(); + public get schema(): z.ZodType | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - name: typeof args.options.name !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.id, options.name].filter(x => x !== undefined).length === 1, { + message: `Specify either 'id' or 'name', but not both.`, + params: { + customCode: 'optionSet', + options: ['id', 'name'] + } }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --id [id]' - }, - { - option: '-n, --name [name]' - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'name'] }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/external/commands/connection/connection-remove.spec.ts b/src/m365/external/commands/connection/connection-remove.spec.ts index 3dde5e0713f..1db7aea8e15 100644 --- a/src/m365/external/commands/connection/connection-remove.spec.ts +++ b/src/m365/external/commands/connection/connection-remove.spec.ts @@ -1,7 +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'; @@ -17,6 +19,8 @@ describe(commands.CONNECTION_REMOVE, () => { let log: string[]; let logger: Logger; let promptIssued: boolean = false; + let commandInfo: CommandInfo; + let commandOptionsSchema: ReturnType; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -24,6 +28,8 @@ describe(commands.CONNECTION_REMOVE, () => { sinon.stub(pid, 'getProcessName').returns(''); 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; @@ -266,4 +272,29 @@ describe(commands.CONNECTION_REMOVE, () => { await command.action(logger, { options: { name: "My HR", force: true } }); assert(removeRequestIssued); }); + + it('passes validation with id specified', () => { + const actual = (commandOptionsSchema as z.ZodType).safeParse({ id: 'contosohr' }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with name specified', () => { + const actual = (commandOptionsSchema as z.ZodType).safeParse({ name: 'Contoso HR' }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation with both id and name specified', () => { + const actual = (commandOptionsSchema as z.ZodType).safeParse({ id: 'contosohr', name: 'Contoso HR' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with neither id nor name specified', () => { + const actual = (commandOptionsSchema as z.ZodType).safeParse({}); + assert.strictEqual(actual.success, false); + }); + + it('fails validation with unknown options', () => { + const actual = (commandOptionsSchema as z.ZodType).safeParse({ id: 'contosohr', unknownOption: 'value' }); + assert.strictEqual(actual.success, false); + }); }); diff --git a/src/m365/external/commands/connection/connection-remove.ts b/src/m365/external/commands/connection/connection-remove.ts index 1f3fa40f6c5..a38d6041dba 100644 --- a/src/m365/external/commands/connection/connection-remove.ts +++ b/src/m365/external/commands/connection/connection-remove.ts @@ -1,21 +1,25 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().optional(), + name: z.string().optional(), + force: z.boolean().optional().alias('f') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id?: string; - name?: string; - force?: boolean; -} - class ExternalConnectionRemoveCommand extends GraphCommand { public get name(): string { return commands.CONNECTION_REMOVE; @@ -29,34 +33,19 @@ class ExternalConnectionRemoveCommand extends GraphCommand { return [commands.EXTERNALCONNECTION_REMOVE]; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initOptionSets(); + public get schema(): z.ZodType | undefined { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - id: typeof args.options.id !== 'undefined', - name: typeof args.options.name !== 'undefined', - force: (!(!args.options.force)).toString() + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.id, options.name].filter(x => x !== undefined).length === 1, { + message: `Specify either 'id' or 'name', but not both.`, + params: { + customCode: 'optionSet', + options: ['id', 'name'] + } }); - }); - } - - #initOptions(): void { - this.options.unshift( - { option: '--id [id]' }, - { option: '--name [name]' }, - { option: '-f, --force' } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'name'] }); } private async getExternalConnectionId(args: CommandArgs): Promise { diff --git a/src/m365/external/commands/connection/connection-schema-add.spec.ts b/src/m365/external/commands/connection/connection-schema-add.spec.ts index a040b462f39..f004b528063 100644 --- a/src/m365/external/commands/connection/connection-schema-add.spec.ts +++ b/src/m365/external/commands/connection/connection-schema-add.spec.ts @@ -12,7 +12,7 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './connection-schema-add.js'; +import command, { options } from './connection-schema-add.js'; describe(commands.CONNECTION_SCHEMA_ADD, () => { const externalConnectionId = 'TestConnectionForCLI'; @@ -21,6 +21,7 @@ describe(commands.CONNECTION_SCHEMA_ADD, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -29,6 +30,7 @@ describe(commands.CONNECTION_SCHEMA_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -184,67 +186,55 @@ describe(commands.CONNECTION_SCHEMA_ADD, () => { new CommandError(errorMessage)); }); - it('fails validation if id is less than 3 characters', async () => { - const actual = await command.validate({ - options: { - externalConnectionId: 'T', - schema: schema - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation if id is less than 3 characters', () => { + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: 'T', + schema: schema + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if id is more than 32 characters', async () => { - const actual = await command.validate({ - options: { - externalConnectionId: externalConnectionId + 'zzzzzzzzzzzzzzzzzz', - schema: schema - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation if id is more than 32 characters', () => { + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: externalConnectionId + 'zzzzzzzzzzzzzzzzzz', + schema: schema + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if id is not alphanumeric', async () => { - const actual = await command.validate({ - options: { - externalConnectionId: externalConnectionId + '!', - schema: schema - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation if id is not alphanumeric', () => { + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: externalConnectionId + '!', + schema: schema + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if id starts with Microsoft', async () => { - const actual = await command.validate({ - options: { - externalConnectionId: 'Microsoft' + externalConnectionId, - schema: schema - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation if id starts with Microsoft', () => { + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: 'Microsoft' + externalConnectionId, + schema: schema + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if schema does not contain baseType', async () => { - const actual = await command.validate({ - options: { - externalConnectionId: externalConnectionId, - schema: '{"properties": [{"name": "ticketTitle","type": "String"}]}' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation if schema does not contain baseType', () => { + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: externalConnectionId, + schema: '{"properties": [{"name": "ticketTitle","type": "String"}]}' + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if schema does not contain properties', async () => { - const actual = await command.validate({ - options: { - externalConnectionId: externalConnectionId, - schema: '{"baseType": "microsoft.graph.externalItem"}' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation if schema does not contain properties', () => { + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: externalConnectionId, + schema: '{"baseType": "microsoft.graph.externalItem"}' + }); + assert.strictEqual(actual.success, false); }); - it('fails validation if schema does contain more than 128 properties', async () => { + it('fails validation if schema does contain more than 128 properties', () => { const schemaObject = JSON.parse(schema); for (let i = 0; i < 128; i++) { schemaObject.properties.push({ @@ -252,22 +242,26 @@ describe(commands.CONNECTION_SCHEMA_ADD, () => { type: 'String' }); } - const actual = await command.validate({ - options: { - externalConnectionId: externalConnectionId, - schema: JSON.stringify(schemaObject) - } - }, commandInfo); - assert.notStrictEqual(actual, false); + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: externalConnectionId, + schema: JSON.stringify(schemaObject) + }); + assert.strictEqual(actual.success, false); }); - it('passes validation with a correct schema and external connection id', async () => { - const actual = await command.validate({ - options: { - externalConnectionId: externalConnectionId, - schema: schema - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation with a correct schema and external connection id', () => { + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: externalConnectionId, + schema: schema + }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation if schema is not valid JSON', () => { + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: externalConnectionId, + schema: 'not valid json' + }); + assert.strictEqual(actual.success, false); }); }); \ No newline at end of file diff --git a/src/m365/external/commands/connection/connection-schema-add.ts b/src/m365/external/commands/connection/connection-schema-add.ts index 3a8ad69fc64..132cd647168 100644 --- a/src/m365/external/commands/connection/connection-schema-add.ts +++ b/src/m365/external/commands/connection/connection-schema-add.ts @@ -1,35 +1,55 @@ import { ExternalConnectors, NullableOption } from '@microsoft/microsoft-graph-types'; import { AxiosResponse } from 'axios'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; import request, { CliRequestOptions } from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; -interface CommandArgs { - options: Options; -} - -interface Options extends GlobalOptions { - externalConnectionId: string; - schema: string; - wait: boolean; -} +export const options = z.strictObject({ + ...globalOptionsZod.shape, + externalConnectionId: z.string() + .min(3, 'externalConnectionId must be between 3 and 32 characters in length.') + .max(32, 'externalConnectionId must be between 3 and 32 characters in length.') + .refine(id => !/[^\w]|_/g.test(id), { + message: 'externalConnectionId must only contain alphanumeric characters.' + }) + .refine(id => !(id.length > 9 && id.startsWith('Microsoft')), { + message: 'ID cannot begin with Microsoft' + }) + .alias('i'), + schema: z.string() + .refine(val => { + try { + const obj = JSON.parse(val); + return obj.baseType === 'microsoft.graph.externalItem'; + } + catch { + return false; + } + }, { + message: `The schema needs a required property 'baseType' with value 'microsoft.graph.externalItem'` + }) + .refine(val => { + try { + const obj = JSON.parse(val); + return obj.properties && obj.properties.length > 0 && obj.properties.length <= 128; + } + catch { + return false; + } + }, { + message: 'We need at least one property and a maximum of 128 properties in the schema object' + }) + .alias('s'), + wait: z.boolean().optional() +}); -interface ExternalItem { - baseType: string; - properties: Property[]; -} +declare type Options = z.infer; -interface Property { - aliases?: string[]; - isQueryable?: boolean; - isRefinable?: boolean; - isRetrievable?: boolean; - isSearchable?: boolean; - labels?: string[]; - name: string; - type: string; +interface CommandArgs { + options: Options; } class ExternalConnectionSchemaAddCommand extends GraphCommand { @@ -45,57 +65,8 @@ class ExternalConnectionSchemaAddCommand extends GraphCommand { return [commands.EXTERNALCONNECTION_SCHEMA_ADD]; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-i, --externalConnectionId ' - }, - { - option: '-s, --schema ' - }, - { - option: '--wait' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.externalConnectionId.length < 3 || args.options.externalConnectionId.length > 32) { - return 'externalConnectionId must be between 3 and 32 characters in length.'; - } - - const alphaNumericRegEx = /[^\w]|_/g; - - if (alphaNumericRegEx.test(args.options.externalConnectionId)) { - return 'externalConnectionId must only contain alphanumeric characters.'; - } - - if (args.options.externalConnectionId.length > 9 && - args.options.externalConnectionId.startsWith('Microsoft')) { - return 'ID cannot begin with Microsoft'; - } - - const schemaObject: ExternalItem = JSON.parse(args.options.schema); - if (schemaObject.baseType === undefined || schemaObject.baseType !== 'microsoft.graph.externalItem') { - return `The schema needs a required property 'baseType' with value 'microsoft.graph.externalItem'`; - } - - if (!schemaObject.properties || schemaObject.properties.length > 128) { - return `We need at least one property and a maximum of 128 properties in the schema object`; - } - - return true; - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/external/commands/connection/connection-urltoitemresolver-add.spec.ts b/src/m365/external/commands/connection/connection-urltoitemresolver-add.spec.ts index eea9e06a6ca..53bd4f2b135 100644 --- a/src/m365/external/commands/connection/connection-urltoitemresolver-add.spec.ts +++ b/src/m365/external/commands/connection/connection-urltoitemresolver-add.spec.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import sinon from 'sinon'; import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; import { CommandError } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; import request from '../../../../request.js'; @@ -9,10 +10,11 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './connection-urltoitemresolver-add.js'; +import command, { options } from './connection-urltoitemresolver-add.js'; describe(commands.CONNECTION_URLTOITEMRESOLVER_ADD, () => { let logger: Logger; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -20,6 +22,8 @@ describe(commands.CONNECTION_URLTOITEMRESOLVER_ADD, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; + const commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -93,33 +97,26 @@ describe(commands.CONNECTION_URLTOITEMRESOLVER_ADD, () => { new CommandError(`An error has occurred`)); }); - it('supports specifying connection ID', () => { - const containsOption = !!command.options - .find(o => o.option.indexOf('--externalConnectionId') > -1); - assert(containsOption); - }); - - it('supports specifying base URLs', () => { - const containsOption = !!command.options - .find(o => o.option.indexOf('--baseUrls') > -1); - assert(containsOption); - }); - - it('supports specifying URL patterns', () => { - const containsOption = !!command.options - .find(o => o.option.indexOf('--urlPattern') > -1); - assert(containsOption); - }); - - it('supports specifying item ID', () => { - const containsOption = !!command.options - .find(o => o.option.indexOf('--itemId') > -1); - assert(containsOption); + it('passes validation with all required options', () => { + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: 'conn', + baseUrls: 'https://contoso.com', + urlPattern: '/(?.*)', + itemId: '{id}', + priority: 1 + }); + assert.strictEqual(actual.success, true); }); - it('supports specifying priority', () => { - const containsOption = !!command.options - .find(o => o.option.indexOf('--priority') > -1); - assert(containsOption); + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ + externalConnectionId: 'conn', + baseUrls: 'https://contoso.com', + urlPattern: '/(?.*)', + itemId: '{id}', + priority: 1, + unknownOption: 'value' + }); + assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/external/commands/connection/connection-urltoitemresolver-add.ts b/src/m365/external/commands/connection/connection-urltoitemresolver-add.ts index 2427c8f99e8..87cf7a7f5a6 100644 --- a/src/m365/external/commands/connection/connection-urltoitemresolver-add.ts +++ b/src/m365/external/commands/connection/connection-urltoitemresolver-add.ts @@ -1,22 +1,26 @@ import { ExternalConnectors } from '@microsoft/microsoft-graph-types/microsoft-graph'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.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, + externalConnectionId: z.string().alias('c'), + baseUrls: z.string(), + urlPattern: z.string(), + itemId: z.string().alias('i'), + priority: z.number().alias('p') +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - externalConnectionId: string; - baseUrls: string; - urlPattern: string; - itemId: string; - priority: number; -} - class ExternalConnectionUrlToItemResolverAddCommand extends GraphCommand { public get name(): string { return commands.CONNECTION_URLTOITEMRESOLVER_ADD; @@ -26,30 +30,8 @@ class ExternalConnectionUrlToItemResolverAddCommand extends GraphCommand { return 'Adds a URL to item resolver to an external connection'; } - constructor() { - super(); - - this.#initOptions(); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-c, --externalConnectionId ' - }, - { - option: '--baseUrls ' - }, - { - option: '--urlPattern ' - }, - { - option: '-i, --itemId ' - }, - { - option: '-p, --priority ' - } - ); + public get schema(): z.ZodType | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/external/commands/item/item-add.spec.ts b/src/m365/external/commands/item/item-add.spec.ts index f944c4f49b7..731fc7cb540 100644 --- a/src/m365/external/commands/item/item-add.spec.ts +++ b/src/m365/external/commands/item/item-add.spec.ts @@ -11,12 +11,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 './item-add.js'; +import command, { options } from './item-add.js'; describe(commands.ITEM_ADD, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -25,6 +26,7 @@ describe(commands.ITEM_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; logger = { log: async () => { }, logRaw: async () => { }, @@ -300,228 +302,180 @@ describe(commands.ITEM_ADD, () => { }); //#region validation - it('fails validation when invalid contentType specified', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'invalid', - acls: 'grant,everyone,everyone', - name: 'Test item' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation when invalid contentType specified', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'invalid', + acls: 'grant,everyone,everyone' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation when contentType is text', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,everyone,everyone', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when contentType is text', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,everyone,everyone' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation when contentType is html', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'html', - acls: 'grant,everyone,everyone', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when contentType is html', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'html', + acls: 'grant,everyone,everyone' + }); + assert.strictEqual(actual.success, true); }); - it('fails validation when one acl with other than 3 elements', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,everyone', - name: 'Test item' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation when one acl with other than 3 elements', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,everyone' + }); + assert.strictEqual(actual.success, false); }); - it('fails validation when multiple acls specified where one is with other than 3 elements', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,everyone,everyone;grant,everyone', - name: 'Test item' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation when multiple acls specified where one is with other than 3 elements', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,everyone,everyone;grant,everyone' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation for a single correct acl', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,everyone,everyone', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for a single correct acl', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,everyone,everyone' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation for multiple correct acls', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,everyone,everyone;grant,everyone,everyone', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for multiple correct acls', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,everyone,everyone;grant,everyone,everyone' + }); + assert.strictEqual(actual.success, true); }); - it('fails validation for invalid acl access type', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'invalid,everyone,everyone', - name: 'Test item' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation for invalid acl access type', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'invalid,everyone,everyone' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation for acl access type grant', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,everyone,everyone', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for acl access type grant', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,everyone,everyone' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation for acl access type deny', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'deny,everyone,everyone', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for acl access type deny', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'deny,everyone,everyone' + }); + assert.strictEqual(actual.success, true); }); - it('fails validation for invalid acl type', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,invalid,everyone', - name: 'Test item' - } - }, commandInfo); - assert.notStrictEqual(actual, false); + it('fails validation for invalid acl type', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,invalid,everyone' + }); + assert.strictEqual(actual.success, false); }); - it('passes validation for acl type user', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,user,steve@contoso.com', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for acl type user', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,user,steve@contoso.com' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation for acl type grant', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,group,Users', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for acl type grant', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,group,Users' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation for acl type everyone', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,everyone,everyone', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for acl type everyone', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,everyone,everyone' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation for acl type everyoneExceptGuests', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,everyoneExceptGuests,everyoneExceptGuests', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for acl type everyoneExceptGuests', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,everyoneExceptGuests,everyoneExceptGuests' + }); + assert.strictEqual(actual.success, true); }); - it('passes validation for acl type externalGroup', async () => { - const actual = await command.validate({ - options: { - id: 'ticket1', - externalConnectionId: 'connection', - content: 'Hello world', - contentType: 'text', - acls: 'grant,externalGroup,Users', - name: 'Test item' - } - }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation for acl type externalGroup', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + contentType: 'text', + acls: 'grant,externalGroup,Users' + }); + assert.strictEqual(actual.success, true); }); //#endregion @@ -531,34 +485,14 @@ describe(commands.ITEM_ADD, () => { }); //#region options - it('supports specifying id', () => { - const containsOption = command.options - .some(o => o.option.indexOf('--id') > -1); - assert(containsOption); - }); - - it('supports specifying externalConnectionId', () => { - const containsOption = command.options - .some(o => o.option.indexOf('--externalConnectionId') > -1); - assert(containsOption); - }); - - it('supports specifying content', () => { - const containsOption = command.options - .some(o => o.option.indexOf('--content') > -1); - assert(containsOption); - }); - - it('supports specifying contentType', () => { - const containsOption = command.options - .some(o => o.option.indexOf('--contentType') > -1); - assert(containsOption); - }); - - it('supports specifying acls', () => { - const containsOption = command.options - .some(o => o.option.indexOf('--acls') > -1); - assert(containsOption); + it('passes validation with all required options', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'ticket1', + externalConnectionId: 'connection', + content: 'Hello world', + acls: 'grant,everyone,everyone' + }); + assert.strictEqual(actual.success, true); }); //#endregion }); diff --git a/src/m365/external/commands/item/item-add.ts b/src/m365/external/commands/item/item-add.ts index 22c8f8f7b98..2aeb42674b8 100644 --- a/src/m365/external/commands/item/item-add.ts +++ b/src/m365/external/commands/item/item-add.ts @@ -1,25 +1,52 @@ import { ExternalConnectors } from '@microsoft/microsoft-graph-types/microsoft-graph'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +const contentTypes = ['text', 'html'] as const; + +export const options = z.object({ + ...globalOptionsZod.shape, + id: z.string(), + externalConnectionId: z.string(), + content: z.string(), + contentType: z.enum(contentTypes).optional(), + acls: z.string() + .refine(val => { + const acls = val.split(';'); + return acls.every(acl => acl.split(',').length === 3); + }, { + message: 'The value for option acls is not in the correct format. The correct format is "accessType,type,value", eg. "grant,everyone,everyone"' + }) + .refine(val => { + const acls = val.split(';'); + const accessTypeValues = ['grant', 'deny']; + return acls.every(acl => accessTypeValues.includes(acl.split(',')[0])); + }, { + message: 'The accessType value for option acls is not valid. Allowed values are grant, deny' + }) + .refine(val => { + const acls = val.split(';'); + const aclTypeValues = ['user', 'group', 'everyone', 'everyoneExceptGuests', 'externalGroup']; + return acls.every(acl => { + const parts = acl.split(','); + return parts.length >= 2 && aclTypeValues.includes(parts[1]); + }); + }, { + message: 'The type value for option acls is not valid. Allowed values are user, group, everyone, everyoneExceptGuests, externalGroup' + }) +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - id: string; - externalConnectionId: string; - content: string; - contentType?: string; - acls: string; -} - class ExternalItemAddCommand extends GraphCommand { - private static contentType: string[] = ['text', 'html']; - public get name(): string { return commands.ITEM_ADD; } @@ -28,75 +55,8 @@ class ExternalItemAddCommand extends GraphCommand { return 'Creates external item'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - contentType: typeof args.options.contentType - }); - this.trackUnknownOptions(this.telemetryProperties, args.options); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--id ' - }, - { - option: '--externalConnectionId ' - }, - { - option: '--content ' - }, - { - option: '--contentType [contentType]', - autocomplete: ExternalItemAddCommand.contentType - }, - { - option: '--acls ' - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.contentType && - ExternalItemAddCommand.contentType.indexOf(args.options.contentType) < 0) { - return `${args.options.contentType} is not a valid value for contentType. Allowed values are ${ExternalItemAddCommand.contentType.join(', ')}`; - } - - // verify that each value for ACLs consists of three parts - // and that the values are correct - const acls: string[] = args.options.acls.split(';'); - for (let i = 0; i < acls.length; i++) { - const acl: string[] = acls[i].split(','); - if (acl.length !== 3) { - return `The value ${acls[i]} for option acls is not in the correct format. The correct format is "accessType,type,value", eg. "grant,everyone,everyone"`; - } - - const accessTypeValues = ['grant', 'deny']; - if (accessTypeValues.indexOf(acl[0]) < 0) { - return `The value ${acl[0]} for option acls is not valid. Allowed values are ${accessTypeValues.join(', ')}}`; - } - - const aclTypeValues = ['user', 'group', 'everyone', 'everyoneExceptGuests', 'externalGroup']; - if (aclTypeValues.indexOf(acl[1]) < 0) { - return `The value ${acl[1]} for option acls is not valid. Allowed values are ${aclTypeValues.join(', ')}}`; - } - } - - return true; - } - ); + public get schema(): z.ZodType | undefined { + return options; } public allowUnknownOptions(): boolean | undefined { @@ -128,7 +88,7 @@ class ExternalItemAddCommand extends GraphCommand { // we need to rewrite the @odata properties to the correct format // to extract multiple values for collections into arrays this.rewriteCollectionProperties(args.options); - this.addUnknownOptionsToPayload(requestBody.properties, args.options); + this.addUnknownOptionsToPayloadZod(requestBody.properties, args.options); const requestOptions: CliRequestOptions = { url: `${this.resource}/v1.0/external/connections/${args.options.externalConnectionId}/items/${args.options.id}`,