diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000000..a164dd3be90 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +/dist/* +/node_modules/* \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 245f7683901..37b4798324a 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -215,7 +215,8 @@ module.exports = { "Query.*", "app_displayname", "access_token", - "expires_on" + "expires_on", + "extension_*" ] } ], diff --git a/src/Command.ts b/src/Command.ts index e728d79d708..38902e1497b 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -14,8 +14,9 @@ import { md } from './utils/md.js'; import { GraphResponseError } from './utils/odata.js'; import { prompt } from './utils/prompt.js'; import { zod } from './utils/zod.js'; +import { optionsUtils } from './utils/optionsUtils.js'; -interface CommandOption { +export interface CommandOption { option: string; autocomplete?: string[] } @@ -469,32 +470,8 @@ export default abstract class Command { telemetry.trackEvent(this.getUsedCommandName(), this.getTelemetryProperties(args)); } - protected getUnknownOptions(options: any): any { - const unknownOptions: any = JSON.parse(JSON.stringify(options)); - // remove minimist catch-all option - delete unknownOptions._; - - const knownOptions: CommandOption[] = this.options; - const longOptionRegex: RegExp = /--([^\s]+)/; - const shortOptionRegex: RegExp = /-([a-z])\b/; - knownOptions.forEach(o => { - const longOptionName: string = (longOptionRegex.exec(o.option) as RegExpExecArray)[1]; - delete unknownOptions[longOptionName]; - - // short names are optional so we need to check if the current command has - // one before continuing - const shortOptionMatch: RegExpExecArray | null = shortOptionRegex.exec(o.option); - if (shortOptionMatch) { - const shortOptionName: string = shortOptionMatch[1]; - delete unknownOptions[shortOptionName]; - } - }); - - return unknownOptions; - } - protected trackUnknownOptions(telemetryProps: any, options: any): void { - const unknownOptions: any = this.getUnknownOptions(options); + const unknownOptions: any = optionsUtils.getUnknownOptions(options, this.options); const unknownOptionsNames: string[] = Object.getOwnPropertyNames(unknownOptions); unknownOptionsNames.forEach(o => { telemetryProps[o] = true; @@ -502,11 +479,7 @@ export default abstract class Command { } protected addUnknownOptionsToPayload(payload: any, options: any): void { - const unknownOptions: any = this.getUnknownOptions(options); - const unknownOptionsNames: string[] = Object.getOwnPropertyNames(unknownOptions); - unknownOptionsNames.forEach(o => { - payload[o] = unknownOptions[o]; - }); + optionsUtils.addUnknownOptionsToPayload(payload, options, this.options); } private loadValuesFromAccessToken(args: CommandArgs): void { diff --git a/src/m365/adaptivecard/commands/adaptivecard-send.ts b/src/m365/adaptivecard/commands/adaptivecard-send.ts index 53e2b197fc1..b0d9cbfd934 100644 --- a/src/m365/adaptivecard/commands/adaptivecard-send.ts +++ b/src/m365/adaptivecard/commands/adaptivecard-send.ts @@ -4,6 +4,7 @@ import GlobalOptions from '../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../request.js'; import AnonymousCommand from '../../base/AnonymousCommand.js'; import commands from '../commands.js'; +import { optionsUtils } from '../../../utils/optionsUtils.js'; interface CommandArgs { options: Options; @@ -113,7 +114,7 @@ class AdaptiveCardSendCommand extends AnonymousCommand { } public async commandAction(logger: Logger, args: CommandArgs): Promise { - const unknownOptions = this.getUnknownOptions(args.options); + const unknownOptions = optionsUtils.getUnknownOptions(args.options, this.options); const unknownOptionNames: string[] = Object.getOwnPropertyNames(unknownOptions); const card: any = await this.getCard(args, unknownOptionNames, unknownOptions); diff --git a/src/m365/base/SpoCommand.ts b/src/m365/base/SpoCommand.ts index fba2e6b4dc7..e81e56daac5 100644 --- a/src/m365/base/SpoCommand.ts +++ b/src/m365/base/SpoCommand.ts @@ -2,6 +2,7 @@ import { createRequire } from 'module'; import auth, { AuthType } from '../../Auth.js'; import { Logger } from '../../cli/Logger.js'; import Command, { CommandArgs, CommandError } from '../../Command.js'; +import { optionsUtils } from '../../utils/optionsUtils.js'; const require = createRequire(import.meta.url); const csomDefs = require('../../../csom.json'); @@ -80,7 +81,7 @@ export default abstract class SpoCommand extends Command { } protected validateUnknownCsomOptions(options: any, csomObject: string, csomPropertyType: 'get' | 'set'): string | boolean { - const unknownOptions: any = this.getUnknownOptions(options); + const unknownOptions: any = optionsUtils.getUnknownOptions(options, this.options); const optionNames: string[] = Object.getOwnPropertyNames(unknownOptions); if (optionNames.length === 0) { return true; diff --git a/src/m365/commands/request.ts b/src/m365/commands/request.ts index 9c1f0aeb9c6..412b29ffb47 100644 --- a/src/m365/commands/request.ts +++ b/src/m365/commands/request.ts @@ -7,6 +7,7 @@ import GlobalOptions from '../../GlobalOptions.js'; import { Logger } from '../../cli/Logger.js'; import request from '../../request.js'; import commands from './commands.js'; +import { optionsUtils } from '../../utils/optionsUtils.js'; interface CommandArgs { options: Options; @@ -53,7 +54,7 @@ class RequestCommand extends Command { filePath: typeof args.options.filePath !== 'undefined' }; - const unknownOptions: any = this.getUnknownOptions(args.options); + const unknownOptions: any = optionsUtils.getUnknownOptions(args.options, this.options); const unknownOptionsNames: string[] = Object.getOwnPropertyNames(unknownOptions); unknownOptionsNames.forEach(o => { properties[o] = typeof unknownOptions[o] !== 'undefined'; @@ -119,7 +120,7 @@ class RequestCommand extends Command { const method = (args.options.method || 'get').toUpperCase(); const headers: RawAxiosRequestHeaders = {}; - const unknownOptions: any = this.getUnknownOptions(args.options); + const unknownOptions: any = optionsUtils.getUnknownOptions(args.options, this.options); const unknownOptionsNames: string[] = Object.getOwnPropertyNames(unknownOptions); unknownOptionsNames.forEach(o => { headers[o] = unknownOptions[o]; diff --git a/src/m365/commands/setup.ts b/src/m365/commands/setup.ts index 16bd6a797a6..975776c3a1c 100644 --- a/src/m365/commands/setup.ts +++ b/src/m365/commands/setup.ts @@ -311,6 +311,7 @@ class SetupCommand extends AnonymousCommand { }); const appInfo: AppInfo = await entraApp.createAppRegistration({ options, + defaultOptions: this.options, apis, logger, verbose: this.verbose, diff --git a/src/m365/entra/commands/administrativeunit/administrativeunit-add.spec.ts b/src/m365/entra/commands/administrativeunit/administrativeunit-add.spec.ts index 7f3b5c2521e..92d17c28449 100644 --- a/src/m365/entra/commands/administrativeunit/administrativeunit-add.spec.ts +++ b/src/m365/entra/commands/administrativeunit/administrativeunit-add.spec.ts @@ -21,6 +21,14 @@ describe(commands.ADMINISTRATIVEUNIT_ADD, () => { visibility: null }; + const administrativeUnitWithDirectoryExtensionReponse: any = { + id: 'fc33aa61-cf0e-46b6-9506-f633347202ab', + displayName: 'European Division', + description: null, + visibility: null, + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + }; + let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; @@ -71,6 +79,10 @@ describe(commands.ADMINISTRATIVEUNIT_ADD, () => { assert.notStrictEqual(command.description, null); }); + it('allows unknown options', () => { + assert.strictEqual(command.allowUnknownOptions(), true); + }); + it('creates an administrative unit with a specific display name', async () => { const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/directory/administrativeUnits') { @@ -110,6 +122,32 @@ describe(commands.ADMINISTRATIVEUNIT_ADD, () => { assert(loggerLogSpy.calledOnceWith(administrativeUnitReponse)); }); + it('creates an administrative unit with unknown options', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/directory/administrativeUnits') { + return administrativeUnitWithDirectoryExtensionReponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: + { + displayName: 'European Division', + description: 'European Division Administration', + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + } + }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + displayName: 'European Division', + description: 'European Division Administration', + visibility: null, + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + }); + assert(loggerLogSpy.calledOnceWith(administrativeUnitWithDirectoryExtensionReponse)); + }); + it('creates a hidden administrative unit with a specific display name and description', async () => { const privateAdministrativeUnitResponse = { ...administrativeUnitReponse }; privateAdministrativeUnitResponse.description = 'European Division Administration'; diff --git a/src/m365/entra/commands/administrativeunit/administrativeunit-add.ts b/src/m365/entra/commands/administrativeunit/administrativeunit-add.ts index 37e60e17884..72a9ac053de 100644 --- a/src/m365/entra/commands/administrativeunit/administrativeunit-add.ts +++ b/src/m365/entra/commands/administrativeunit/administrativeunit-add.ts @@ -24,6 +24,10 @@ class EntraAdministrativeUnitAddCommand extends GraphCommand { return 'Creates an administrative unit'; } + public allowUnknownOptions(): boolean | undefined { + return true; + } + constructor() { super(); @@ -54,17 +58,21 @@ class EntraAdministrativeUnitAddCommand extends GraphCommand { } public async commandAction(logger: Logger, args: CommandArgs): Promise { + const requestBody = { + description: args.options.description, + displayName: args.options.displayName, + visibility: args.options.hiddenMembership ? 'HiddenMembership' : null + }; + + this.addUnknownOptionsToPayload(requestBody, args.options); + const requestOptions: CliRequestOptions = { url: `${this.resource}/v1.0/directory/administrativeUnits`, headers: { accept: 'application/json;odata.metadata=none' }, responseType: 'json', - data: { - description: args.options.description, - displayName: args.options.displayName, - visibility: args.options.hiddenMembership ? 'HiddenMembership' : null - } + data: requestBody }; try { diff --git a/src/m365/entra/commands/app/app-add.spec.ts b/src/m365/entra/commands/app/app-add.spec.ts index af92d3acbad..819847e3ff4 100644 --- a/src/m365/entra/commands/app/app-add.spec.ts +++ b/src/m365/entra/commands/app/app-add.spec.ts @@ -203,6 +203,10 @@ describe(commands.APP_ADD, () => { assert.notStrictEqual(command.description, null); }); + it('allows unknown options', () => { + assert.strictEqual(command.allowUnknownOptions(), true); + }); + it('creates Microsoft Entra app reg with just the name', async () => { sinon.stub(request, 'get').rejects('Issues GET request'); sinon.stub(request, 'patch').rejects('Issued PATCH request'); @@ -292,6 +296,97 @@ describe(commands.APP_ADD, () => { })); }); + it('creates Microsoft Entra app reg with the name and directory extension', async () => { + sinon.stub(request, 'get').rejects('Issues GET request'); + sinon.stub(request, 'patch').rejects('Issued PATCH request'); + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/myorganization/applications' && + JSON.stringify(opts.data) === JSON.stringify({ + "displayName": "My Microsoft Entra app", + "signInAudience": "AzureADMyOrg", + "extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker": 'JobGroupN' + })) { + return { + "id": "5b31c38c-2584-42f0-aa47-657fb3a84230", + "deletedDateTime": null, + "appId": "bc724b77-da87-43a9-b385-6ebaaf969db8", + "applicationTemplateId": null, + "createdDateTime": "2020-12-31T14:44:13.7945807Z", + "displayName": "My Microsoft Entra app", + "description": null, + "groupMembershipClaims": null, + "identifierUris": [], + "isDeviceOnlyAuthSupported": null, + "isFallbackPublicClient": null, + "notes": null, + "optionalClaims": null, + "publisherDomain": "contoso.onmicrosoft.com", + "signInAudience": "AzureADMyOrg", + "tags": [], + "tokenEncryptionKeyId": null, + "verifiedPublisher": { + "displayName": null, + "verifiedPublisherId": null, + "addedDateTime": null + }, + "spa": { + "redirectUris": [] + }, + "defaultRedirectUri": null, + "addIns": [], + "api": { + "acceptMappedClaims": null, + "knownClientApplications": [], + "requestedAccessTokenVersion": null, + "oauth2PermissionScopes": [], + "preAuthorizedApplications": [] + }, + "appRoles": [], + "info": { + "logoUrl": null, + "marketingUrl": null, + "privacyStatementUrl": null, + "supportUrl": null, + "termsOfServiceUrl": null + }, + "keyCredentials": [], + "parentalControlSettings": { + "countriesBlockedForMinors": [], + "legalAgeGroupRule": "Allow" + }, + "passwordCredentials": [], + "publicClient": { + "redirectUris": [] + }, + "requiredResourceAccess": [], + "web": { + "homePageUrl": null, + "logoutUrl": null, + "redirectUris": [], + "implicitGrantSettings": { + "enableAccessTokenIssuance": false, + "enableIdTokenIssuance": false + } + } + }; + } + + throw `Invalid POST request: ${JSON.stringify(opts, null, 2)}`; + }); + + await command.action(logger, { + options: { + name: 'My Microsoft Entra app', + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + } + }); + assert(loggerLogSpy.calledWith({ + appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', + objectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', + tenantId: '' + })); + }); + it('creates multitenant Microsoft Entra app reg', async () => { sinon.stub(request, 'get').rejects('Issues GET request'); sinon.stub(request, 'patch').rejects('Issued PATCH request'); diff --git a/src/m365/entra/commands/app/app-add.ts b/src/m365/entra/commands/app/app-add.ts index caa145571fe..d8c9a826060 100644 --- a/src/m365/entra/commands/app/app-add.ts +++ b/src/m365/entra/commands/app/app-add.ts @@ -40,6 +40,10 @@ class EntraAppAddCommand extends GraphCommand { return 'Creates new Entra app registration'; } + public allowUnknownOptions(): boolean | undefined { + return true; + } + constructor() { super(); @@ -225,6 +229,7 @@ class EntraAppAddCommand extends GraphCommand { }); let appInfo: any = await entraApp.createAppRegistration({ options: args.options, + defaultOptions: this.options, apis, logger, verbose: this.verbose, diff --git a/src/m365/entra/commands/app/app-set.spec.ts b/src/m365/entra/commands/app/app-set.spec.ts index 3936f23a3d6..b399c443855 100644 --- a/src/m365/entra/commands/app/app-set.spec.ts +++ b/src/m365/entra/commands/app/app-set.spec.ts @@ -73,6 +73,10 @@ describe(commands.APP_SET, () => { assert.notStrictEqual(command.description, null); }); + it('allows unknown options', () => { + assert.strictEqual(command.allowUnknownOptions(), true); + }); + it('updates uris for the specified appId', async () => { sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `https://graph.microsoft.com/v1.0/myorganization/applications?$filter=appId eq 'bc724b77-da87-43a9-b385-6ebaaf969db8'&$select=id`) { @@ -104,6 +108,46 @@ describe(commands.APP_SET, () => { }); }); + it('updates unknown options for the specified appId', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/myorganization/applications?$filter=appId eq 'bc724b77-da87-43a9-b385-6ebaaf969db8'&$select=id`) { + return { + value: [{ + id: '5b31c38c-2584-42f0-aa47-657fb3a84230' + }] + }; + } + + throw `Invalid request ${JSON.stringify(opts)}`; + }); + sinon.stub(request, 'patch').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/myorganization/applications/5b31c38c-2584-42f0-aa47-657fb3a84230' && + opts.data && + Object.keys(opts.data).indexOf('extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker') > -1 && + opts.data.extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker === 'JobGroupN') { + return; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/myorganization/applications/5b31c38c-2584-42f0-aa47-657fb3a84230' && + opts.data && + Object.keys(opts.data).indexOf('identifierUris') > -1 && + opts.data.identifierUris[0] === 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8') { + return; + } + + throw `Invalid request ${JSON.stringify(opts)}`; + }); + + await command.action(logger, { + options: { + debug: true, + appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', + uris: 'https://contoso.com/bc724b77-da87-43a9-b385-6ebaaf969db8', + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + } + }); + }); + it('updates multiple URIs for the specified appId', async () => { sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `https://graph.microsoft.com/v1.0/myorganization/applications?$filter=appId eq 'bc724b77-da87-43a9-b385-6ebaaf969db8'&$select=id`) { @@ -1280,7 +1324,15 @@ describe(commands.APP_SET, () => { }); it('fails validation if objectId and name specified', async () => { - const actual = await command.validate({ options: { appObjectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', appName: 'My app' } }, commandInfo); + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { objectId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' } }, commandInfo); assert.notStrictEqual(actual, true); }); diff --git a/src/m365/entra/commands/app/app-set.ts b/src/m365/entra/commands/app/app-set.ts index 21fcc119f72..f0fa18db266 100644 --- a/src/m365/entra/commands/app/app-set.ts +++ b/src/m365/entra/commands/app/app-set.ts @@ -7,6 +7,7 @@ import { formatting } from '../../../../utils/formatting.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { cli } from '../../../../cli/cli.js'; +import { optionsUtils } from '../../../../utils/optionsUtils.js'; interface CommandArgs { options: Options; @@ -37,6 +38,10 @@ class EntraAppSetCommand extends GraphCommand { return 'Updates Entra app registration'; } + public allowUnknownOptions(): boolean | undefined { + return true; + } + constructor() { super(); @@ -127,6 +132,7 @@ class EntraAppSetCommand extends GraphCommand { public async commandAction(logger: Logger, args: CommandArgs): Promise { try { let objectId = await this.getAppObjectId(args, logger); + objectId = await this.updateUnknownOptions(args, objectId); objectId = await this.configureUri(args, objectId, logger); objectId = await this.configureRedirectUris(args, objectId, logger); objectId = await this.updateAllowPublicClientFlows(args, objectId, logger); @@ -176,6 +182,24 @@ class EntraAppSetCommand extends GraphCommand { return result.id; } + private async updateUnknownOptions(args: CommandArgs, objectId: string): Promise { + if (Object.keys(optionsUtils.getUnknownOptions(args.options, this.options)).length > 0) { + const requestBody = {}; + optionsUtils.addUnknownOptionsToPayload(requestBody, args.options, this.options); + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/myorganization/applications/${objectId}`, + headers: { + 'content-type': 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: requestBody + }; + await request.patch(requestOptions); + } + return objectId; + } + private async updateAllowPublicClientFlows(args: CommandArgs, objectId: string, logger: Logger): Promise { if (args.options.allowPublicClientFlows === undefined) { return objectId; diff --git a/src/m365/entra/commands/group/group-add.spec.ts b/src/m365/entra/commands/group/group-add.spec.ts index 47874bc868d..f8ac02217ef 100644 --- a/src/m365/entra/commands/group/group-add.spec.ts +++ b/src/m365/entra/commands/group/group-add.spec.ts @@ -57,6 +57,10 @@ describe(commands.GROUP_ADD, () => { "onPremisesProvisioningErrors": [], "serviceProvisioningErrors": [] }; + const microsoft365GroupWithDirectoryExtension = { + ...microsoft365Group, + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + }; const securityGroup = { "id": "bc91082e-73ad-4a97-9852-e66004c7b0b6", "deletedDateTime": null, @@ -222,6 +226,10 @@ describe(commands.GROUP_ADD, () => { assert.notStrictEqual(command.description, null); }); + it('allows unknown options', () => { + assert.strictEqual(command.allowUnknownOptions(), true); + }); + it('fails validation if the length of displayName is more than 256 characters', async () => { const displayName = 'lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum'; const actual = await command.validate({ options: { displayName: displayName, type: 'security' } }, commandInfo); @@ -302,6 +310,28 @@ describe(commands.GROUP_ADD, () => { assert(loggerLogSpy.calledWith(microsoft365Group)); }); + it('successfully creates Microsoft 365 group with uknown options', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { + return microsoft365GroupWithDirectoryExtension; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', type: 'microsoft365', extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + displayName: 'Microsoft 365 Group', + description: 'Microsoft 365 group', + mailNickName: 'Microsoft365Group', + visibility: 'Public', + groupTypes: ['Unified'], + mailEnabled: true, + securityEnabled: true, + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + }); + }); + it('successfully creates security group without owners and members', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { diff --git a/src/m365/entra/commands/group/group-set.spec.ts b/src/m365/entra/commands/group/group-set.spec.ts index 1b1897337ae..4a193dd2fb8 100644 --- a/src/m365/entra/commands/group/group-set.spec.ts +++ b/src/m365/entra/commands/group/group-set.spec.ts @@ -71,6 +71,10 @@ describe(commands.GROUP_SET, () => { assert.notStrictEqual(command.description, null); }); + it('allows unknown options', () => { + assert.strictEqual(command.allowUnknownOptions(), true); + }); + it('fails validation if the length of newDisplayName is more than 256 characters', async () => { const displayName = 'lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum'; const actual = await command.validate({ options: { id: groupId, newDisplayName: displayName } }, commandInfo); @@ -161,6 +165,25 @@ describe(commands.GROUP_SET, () => { }); }); + it('successfully updates group specified by id with unknown options', async () => { + const patchRequestStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: groupId, description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', newDisplayName: '365 group', extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN', verbose: true } }); + assert.deepStrictEqual(patchRequestStub.lastCall.args[0].data, { + displayName: '365 group', + description: 'Microsoft 365 group', + mailNickName: 'Microsoft365Group', + visibility: 'Public', + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + }); + }); + it('successfully updates group specified by displayName', async () => { const patchRequestStub = sinon.stub(request, 'patch').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}`) { diff --git a/src/m365/entra/commands/group/group-set.ts b/src/m365/entra/commands/group/group-set.ts index 1e1430310b0..4d177c40c1b 100644 --- a/src/m365/entra/commands/group/group-set.ts +++ b/src/m365/entra/commands/group/group-set.ts @@ -38,6 +38,10 @@ class EntraGroupSetCommand extends GraphCommand { return 'Updates a Microsoft Entra group'; } + public allowUnknownOptions(): boolean | undefined { + return true; + } + constructor(){ super(); @@ -195,17 +199,21 @@ class EntraGroupSetCommand extends GraphCommand { groupId = await entraGroup.getGroupIdByDisplayName(args.options.displayName); } + const requestBody = { + displayName: args.options.newDisplayName, + description: args.options.description === '' ? null : args.options.description, + mailNickName: args.options.mailNickname, + visibility: args.options.visibility + }; + + this.addUnknownOptionsToPayload(requestBody, args.options); + const requestOptions: CliRequestOptions = { url: `${this.resource}/v1.0/groups/${groupId}`, headers: { accept: 'application/json;odata.metadata=none' }, - data: { - displayName: args.options.newDisplayName, - description: args.options.description === '' ? null : args.options.description, - mailNickName: args.options.mailNickname, - visibility: args.options.visibility - } + data: requestBody }; await request.patch(requestOptions); diff --git a/src/m365/entra/commands/user/user-add.spec.ts b/src/m365/entra/commands/user/user-add.spec.ts index ca1bd15add3..436be8edcef 100644 --- a/src/m365/entra/commands/user/user-add.spec.ts +++ b/src/m365/entra/commands/user/user-add.spec.ts @@ -52,6 +52,16 @@ describe(commands.USER_ADD, () => { password: password }; + const userResponseWithoutPasswordAndWithDirectoryExtension = { + ...userResponseWithoutPassword, + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + }; + + const userResponseWithPasswordAndDirectoryExtension = { + ...userResponseWithPassword, + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + }; + const graphError = { error: { code: "Request_BadRequest", @@ -123,6 +133,10 @@ describe(commands.USER_ADD, () => { assert.notStrictEqual(command.description, null); }); + it('allows unknown options', () => { + assert.strictEqual(command.allowUnknownOptions(), true); + }); + it('creates Microsoft Entra user using a preset password but requiring the user to change it the next login', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === graphBaseUrl) { @@ -150,6 +164,19 @@ describe(commands.USER_ADD, () => { assert(loggerLogSpy.calledWith(userResponseWithPassword)); }); + it('creates Microsoft Entra user with uknown options', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === graphBaseUrl) { + return userResponseWithoutPasswordAndWithDirectoryExtension; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { userName: userName, displayName: displayName, password: password, extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' } }); + assert(loggerLogSpy.calledWith(userResponseWithPasswordAndDirectoryExtension)); + }); + it('creates Microsoft Entra user and set its manager by id', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === graphBaseUrl) { diff --git a/src/m365/entra/commands/user/user-list.ts b/src/m365/entra/commands/user/user-list.ts index fdce96baafc..3f8eda97002 100644 --- a/src/m365/entra/commands/user/user-list.ts +++ b/src/m365/entra/commands/user/user-list.ts @@ -5,6 +5,7 @@ import { formatting } from '../../../../utils/formatting.js'; import { odata } from '../../../../utils/odata.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; +import { optionsUtils } from '../../../../utils/optionsUtils.js'; interface CommandArgs { options: Options; @@ -115,7 +116,7 @@ class EntraUserListCommand extends GraphCommand { private getFilter(options: Options): string | null { const filters: string[] = []; - const unknownOptions = this.getUnknownOptions(options); + const unknownOptions = optionsUtils.getUnknownOptions(options, this.options); Object.keys(unknownOptions).forEach(key => { if (typeof options[key] === 'boolean') { throw `Specify value for the ${key} property`; diff --git a/src/m365/entra/commands/user/user-set.spec.ts b/src/m365/entra/commands/user/user-set.spec.ts index 3da3de2bf43..8d8386bc416 100644 --- a/src/m365/entra/commands/user/user-set.spec.ts +++ b/src/m365/entra/commands/user/user-set.spec.ts @@ -94,6 +94,10 @@ describe(commands.USER_SET, () => { assert.notStrictEqual(command.description, null); }); + it('allows unknown options', () => { + assert.strictEqual(command.allowUnknownOptions(), true); + }); + it('fails validation if neither the id nor the userName are specified', async () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { if (settingName === settingsNames.prompt) { @@ -283,6 +287,46 @@ describe(commands.USER_SET, () => { assert(loggerLogSpy.notCalled); }); + it('correctly updates information about the specified user with unknown options', async () => { + const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if ((opts.url as string).indexOf(`/v1.0/users/`) > -1) { + return; + } + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + verbose: true, + id: id, + department: 'Sales & Marketing', + companyName: 'Contoso', + displayName: displayName, + firstName: firstName, + lastName: lastName, + usageLocation: usageLocation, + officeLocation: officeLocation, + jobTitle: jobTitle, + preferredLanguage: preferredLanguage, + accountEnabled: false, + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + } + } as any); + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + companyName: 'Contoso', + department: 'Sales & Marketing', + displayName: displayName, + givenName: firstName, + surname: lastName, + usageLocation: usageLocation, + officeLocation: officeLocation, + jobTitle: jobTitle, + preferredLanguage: preferredLanguage, + accountEnabled: false, + extension_b7d8e648520f41d3b9c0fdeb91768a0a_jobGroupTracker: 'JobGroupN' + }); + }); + it('correctly updates user with an empty value', async () => { const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/users/${id}`) { diff --git a/src/m365/tenant/commands/people/people-profilecardproperty-add.ts b/src/m365/tenant/commands/people/people-profilecardproperty-add.ts index 4491c4793d0..3a1f873722b 100644 --- a/src/m365/tenant/commands/people/people-profilecardproperty-add.ts +++ b/src/m365/tenant/commands/people/people-profilecardproperty-add.ts @@ -1,6 +1,7 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; +import { optionsUtils } from '../../../../utils/optionsUtils.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { profileCardPropertyNames } from './profileCardProperties.js'; @@ -34,7 +35,7 @@ class TenantPeopleProfileCardPropertyAddCommand extends GraphCommand { #initTelemetry(): void { this.telemetry.push((args: CommandArgs) => { // Add unknown options to telemetry - const unknownOptions = Object.keys(this.getUnknownOptions(args.options)); + const unknownOptions = Object.keys(optionsUtils.getUnknownOptions(args.options, this.options)); const unknownOptionsObj = unknownOptions.reduce((obj, key) => ({ ...obj, [key]: true }), {}); Object.assign(this.telemetryProperties, { @@ -73,7 +74,7 @@ class TenantPeopleProfileCardPropertyAddCommand extends GraphCommand { return `The option 'displayName' can only be used when adding customAttributes as profile card properties`; } - const unknownOptions = Object.keys(this.getUnknownOptions(args.options)); + const unknownOptions = Object.keys(optionsUtils.getUnknownOptions(args.options, this.options)); if (!propertyName.startsWith('customattribute') && unknownOptions.length > 0) { return `Unknown options like ${unknownOptions.join(', ')} are only supported with customAttributes`; @@ -151,7 +152,7 @@ class TenantPeopleProfileCardPropertyAddCommand extends GraphCommand { } private getLocalizations(options: Options): { languageTag: string, displayName: string }[] { - const unknownOptions = Object.keys(this.getUnknownOptions(options)); + const unknownOptions = Object.keys(optionsUtils.getUnknownOptions(options, this.options)); if (unknownOptions.length === 0) { return []; diff --git a/src/m365/tenant/commands/people/people-profilecardproperty-set.ts b/src/m365/tenant/commands/people/people-profilecardproperty-set.ts index 9cd9f4d3181..c48d09ec6a2 100644 --- a/src/m365/tenant/commands/people/people-profilecardproperty-set.ts +++ b/src/m365/tenant/commands/people/people-profilecardproperty-set.ts @@ -4,6 +4,7 @@ import GraphCommand from '../../../base/GraphCommand.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { Localization, ProfileCardProperty, profileCardPropertyNames as allProfileCardPropertyNames } from './profileCardProperties.js'; import commands from '../../commands.js'; +import { optionsUtils } from '../../../../utils/optionsUtils.js'; interface CommandArgs { options: Options; @@ -37,7 +38,7 @@ class TenantPeopleProfileCardPropertySetCommand extends GraphCommand { #initTelemetry(): void { this.telemetry.push((args: CommandArgs) => { // Add unknown options to telemetry - const unknownOptions = Object.keys(this.getUnknownOptions(args.options)); + const unknownOptions = Object.keys(optionsUtils.getUnknownOptions(args.options, this.options)); const unknownOptionsObj = unknownOptions.reduce((obj, key) => ({ ...obj, [key]: true }), {}); Object.assign(this.telemetryProperties, { @@ -67,7 +68,7 @@ class TenantPeopleProfileCardPropertySetCommand extends GraphCommand { } // Unknown options are allowed only if they start with 'displayName-' - const unknownOptionKeys = Object.keys(this.getUnknownOptions(args.options)); + const unknownOptionKeys = Object.keys(optionsUtils.getUnknownOptions(args.options, this.options)); const invalidOptionKey = unknownOptionKeys.find(o => !o.startsWith('displayName-')); if (invalidOptionKey) { return `Invalid option: '${invalidOptionKey}'`; @@ -139,7 +140,7 @@ class TenantPeopleProfileCardPropertySetCommand extends GraphCommand { * @example Transform "--displayName-en-US 'Cost center'" to { languageTag: 'en-US', displayName: 'Cost center' } */ private getLocalizations(options: Options): Localization[] { - const unknownOptions = this.getUnknownOptions(options); + const unknownOptions = optionsUtils.getUnknownOptions(options, this.options); const result = Object.keys(unknownOptions).map(o => ({ languageTag: o.substring(o.indexOf('-') + 1), diff --git a/src/utils/entraApp.ts b/src/utils/entraApp.ts index d017fcf3349..532651b22ae 100644 --- a/src/utils/entraApp.ts +++ b/src/utils/entraApp.ts @@ -3,6 +3,8 @@ import fs from 'fs'; import { Logger } from '../cli/Logger.js'; import request, { CliRequestOptions } from '../request.js'; import { odata } from './odata.js'; +import { optionsUtils } from './optionsUtils.js'; +import { CommandOption } from '../Command.js'; export interface AppInfo { appId: string; @@ -214,8 +216,9 @@ function updateAppPermissions({ spId, resourceAccessPermission, oAuth2Permission export const entraApp = { appPermissions: [] as AppPermissions[], - createAppRegistration: async ({ options, apis, logger, verbose, debug }: { + createAppRegistration: async ({ options, apis, logger, verbose, debug, defaultOptions }: { options: AppCreationOptions, + defaultOptions: CommandOption[], apis: RequiredResourceAccess[], logger: Logger, verbose: boolean, @@ -263,6 +266,8 @@ export const entraApp = { applicationInfo.isFallbackPublicClient = true; } + optionsUtils.addUnknownOptionsToPayload(applicationInfo, options, defaultOptions); + if (verbose) { await logger.logToStderr(`Creating Microsoft Entra app registration...`); } diff --git a/src/utils/optionsUtils.ts b/src/utils/optionsUtils.ts new file mode 100644 index 00000000000..35a15ff52e0 --- /dev/null +++ b/src/utils/optionsUtils.ts @@ -0,0 +1,35 @@ +import { CommandOption } from '../Command.js'; + +const longOptionRegex: RegExp = /--([^\s]+)/; +const shortOptionRegex: RegExp = /-([a-z])\b/; + +export const optionsUtils = { + getUnknownOptions(options: any, knownOptions: CommandOption[]): any { + const unknownOptions: any = JSON.parse(JSON.stringify(options)); + // remove minimist catch-all option + delete unknownOptions._; + + knownOptions.forEach(o => { + const longOptionName: string = (longOptionRegex.exec(o.option) as RegExpExecArray)[1]; + delete unknownOptions[longOptionName]; + + // short names are optional so we need to check if the current command has + // one before continuing + const shortOptionMatch: RegExpExecArray | null = shortOptionRegex.exec(o.option); + if (shortOptionMatch) { + const shortOptionName: string = shortOptionMatch[1]; + delete unknownOptions[shortOptionName]; + } + }); + + return unknownOptions; + }, + + addUnknownOptionsToPayload(payload: any, options: any, knownOptions: CommandOption[]): void { + const unknownOptions: any = this.getUnknownOptions(options, knownOptions); + const unknownOptionsNames: string[] = Object.getOwnPropertyNames(unknownOptions); + unknownOptionsNames.forEach(o => { + payload[o] = unknownOptions[o]; + }); + } +}; \ No newline at end of file