Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhancement: Allow unknown options for entra group/app/administrativeunit commands. Closes #6314 #6543

Closed
3 changes: 2 additions & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ module.exports = {
"Query.*",
"app_displayname",
"access_token",
"expires_on"
"expires_on",
"extension_*"
]
}
],
Expand Down
36 changes: 5 additions & 31 deletions src/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Expand Down Expand Up @@ -469,44 +470,17 @@ export default abstract class Command {
await 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;
});
}

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];
});
const unknownOptions: any = optionsUtils.getUnknownOptions(options, this.options);
optionsUtils.addUnknownOptionsToPayload(payload, unknownOptions);
}

private loadValuesFromAccessToken(args: CommandArgs): void {
Expand Down
3 changes: 2 additions & 1 deletion src/m365/adaptivecard/commands/adaptivecard-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -113,7 +114,7 @@ class AdaptiveCardSendCommand extends AnonymousCommand {
}

public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
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);

Expand Down
3 changes: 2 additions & 1 deletion src/m365/base/SpoCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 3 additions & 14 deletions src/m365/commands/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,14 @@ class RequestCommand extends Command {

#initTelemetry(): void {
this.telemetry.push((args: CommandArgs) => {
const properties: any = {
Object.assign(this.telemetryProperties, {
method: args.options.method || 'get',
resource: typeof args.options.resource !== 'undefined',
accept: args.options.accept || 'application/json',
body: typeof args.options.body !== 'undefined',
filePath: typeof args.options.filePath !== 'undefined'
};

const unknownOptions: any = this.getUnknownOptions(args.options);
const unknownOptionsNames: string[] = Object.getOwnPropertyNames(unknownOptions);
unknownOptionsNames.forEach(o => {
properties[o] = typeof unknownOptions[o] !== 'undefined';
});

Object.assign(this.telemetryProperties, properties);
this.trackUnknownOptions(this.telemetryProperties, args.options);
});
}

Expand Down Expand Up @@ -119,11 +112,7 @@ class RequestCommand extends Command {
const method = (args.options.method || 'get').toUpperCase();
const headers: RawAxiosRequestHeaders = {};

const unknownOptions: any = this.getUnknownOptions(args.options);
const unknownOptionsNames: string[] = Object.getOwnPropertyNames(unknownOptions);
unknownOptionsNames.forEach(o => {
headers[o] = unknownOptions[o];
});
this.addUnknownOptionsToPayload(headers, args.options);

if (!headers.accept) {
headers.accept = 'application/json';
Expand Down
2 changes: 2 additions & 0 deletions src/m365/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { validation } from '../../utils/validation.js';
import AnonymousCommand from '../base/AnonymousCommand.js';
import commands from './commands.js';
import { interactivePreset, powerShellPreset, scriptingPreset } from './setupPresets.js';
import { optionsUtils } from '../../utils/optionsUtils.js';

export interface Preferences {
clientId?: string;
Expand Down Expand Up @@ -311,6 +312,7 @@ class SetupCommand extends AnonymousCommand {
});
const appInfo: AppInfo = await entraApp.createAppRegistration({
options,
unknownOptions: optionsUtils.getUnknownOptions(options, this.options),
apis,
logger,
verbose: this.verbose,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class EntraAdministrativeUnitAddCommand extends GraphCommand {
return 'Creates an administrative unit';
}

public allowUnknownOptions(): boolean | undefined {
return true;
}

constructor() {
super();

Expand Down Expand Up @@ -54,17 +58,21 @@ class EntraAdministrativeUnitAddCommand extends GraphCommand {
}

public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
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 {
Expand Down
95 changes: 95 additions & 0 deletions src/m365/entra/commands/app/app-add.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions src/m365/entra/commands/app/app-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AppCreationOptions, AppInfo, entraApp } from '../../../../utils/entraAp
import GraphCommand from '../../../base/GraphCommand.js';
import { M365RcJson } from '../../../base/M365RcJson.js';
import commands from '../../commands.js';
import { optionsUtils } from '../../../../utils/optionsUtils.js';

interface CommandArgs {
options: Options;
Expand Down Expand Up @@ -40,6 +41,10 @@ class EntraAppAddCommand extends GraphCommand {
return 'Creates new Entra app registration';
}

public allowUnknownOptions(): boolean | undefined {
return true;
}

constructor() {
super();

Expand Down Expand Up @@ -225,6 +230,7 @@ class EntraAppAddCommand extends GraphCommand {
});
let appInfo: any = await entraApp.createAppRegistration({
options: args.options,
unknownOptions: optionsUtils.getUnknownOptions(args.options, this.options),
apis,
logger,
verbose: this.verbose,
Expand Down
Loading