Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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 './approleassignment-add.js';
import command, { options } from './approleassignment-add.js';
import { settingsNames } from '../../../../settingsNames.js';

describe(commands.APPROLEASSIGNMENT_ADD, () => {
let log: string[];
let logger: Logger;
let loggerLogSpy: sinon.SinonSpy;
let commandInfo: CommandInfo;
let commandOptionsSchema: typeof options;

const getRequestStub = (): sinon.SinonStub => {
return sinon.stub(request, 'get').callsFake(async (opts: any) => {
Expand Down Expand Up @@ -48,6 +49,7 @@ describe(commands.APPROLEASSIGNMENT_ADD, () => {
sinon.stub(session, 'getId').returns('');
auth.connection.active = true;
commandInfo = cli.getCommandInfo(command);
commandOptionsSchema = commandInfo.command.getSchemaToParse()! as typeof options;
});

beforeEach(() => {
Expand Down Expand Up @@ -274,93 +276,39 @@ describe(commands.APPROLEASSIGNMENT_ADD, () => {
new CommandError(`Resource '' does not exist or one of its queried reference-property objects are not present`));
});

it('fails validation if neither appId, objectId nor displayName are not specified', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

const actual = await command.validate({ options: { resource: 'abc', scopes: 'abc' } }, commandInfo);
assert.notStrictEqual(actual, true);
});

it('fails validation if the appId is not a valid GUID', async () => {
const actual = await command.validate({ options: { appId: '123', resource: 'abc', scopes: 'abc' } }, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation if neither appId, objectId nor displayName are not specified', () => {
const actual = commandOptionsSchema.safeParse({ resource: 'abc', scopes: 'abc' });
assert.strictEqual(actual.success, false);
});

it('fails validation if the objectId is not a valid GUID', async () => {
const actual = await command.validate({ options: { appObjectId: '123', resource: 'abc', scopes: 'abc' } }, commandInfo);
assert.notStrictEqual(actual, true);
});

it('fails validation if both appId and appDisplayName are specified', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

const actual = await command.validate({ options: { appId: '123', appDisplayName: 'abc', resource: 'abc', scopes: 'abc' } }, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation if the appId is not a valid GUID', () => {
const actual = commandOptionsSchema.safeParse({ appId: '123', resource: 'abc', scopes: 'abc' });
assert.strictEqual(actual.success, false);
});

it('fails validation if both appObjectId and appDisplayName are specified', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

const actual = await command.validate({ options: { appObjectId: '123', appDisplayName: 'abc', resource: 'abc', scopes: 'abc' } }, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation if the objectId is not a valid GUID', () => {
const actual = commandOptionsSchema.safeParse({ appObjectId: '123', resource: 'abc', scopes: 'abc' });
assert.strictEqual(actual.success, false);
});

it('fails validation if both appObjectId, appId and appDisplayName are specified', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

const actual = await command.validate({ options: { appId: '123', appObjectId: '123', appDisplayName: 'abc', resource: 'abc', scopes: 'abc' } }, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation if both appId and appDisplayName are specified', () => {
const actual = commandOptionsSchema.safeParse({ appId: '57907bf8-73fa-43a6-89a5-1f603e29e452', appDisplayName: 'abc', resource: 'abc', scopes: 'abc' });
assert.strictEqual(actual.success, false);
});

it('passes validation when the appId option specified', async () => {
const actual = await command.validate({ options: { appId: '57907bf8-73fa-43a6-89a5-1f603e29e452', resource: 'abc', scopes: 'abc' } }, commandInfo);
assert.strictEqual(actual, true);
it('fails validation if both appObjectId and appDisplayName are specified', () => {
const actual = commandOptionsSchema.safeParse({ appObjectId: '57907bf8-73fa-43a6-89a5-1f603e29e452', appDisplayName: 'abc', resource: 'abc', scopes: 'abc' });
assert.strictEqual(actual.success, false);
});

it('supports specifying appId', () => {
const options = command.options;
let containsOption = false;
options.forEach(o => {
if (o.option.indexOf('--appId') > -1) {
containsOption = true;
}
});
assert(containsOption);
it('fails validation if both appObjectId, appId and appDisplayName are specified', () => {
const actual = commandOptionsSchema.safeParse({ appId: '57907bf8-73fa-43a6-89a5-1f603e29e452', appObjectId: '57907bf8-73fa-43a6-89a5-1f603e29e452', appDisplayName: 'abc', resource: 'abc', scopes: 'abc' });
assert.strictEqual(actual.success, false);
});

it('supports specifying appDisplayName', () => {
const options = command.options;
let containsOption = false;
options.forEach(o => {
if (o.option.indexOf('--appDisplayName') > -1) {
containsOption = true;
}
});
assert(containsOption);
it('passes validation when the appId option specified', () => {
const actual = commandOptionsSchema.safeParse({ appId: '57907bf8-73fa-43a6-89a5-1f603e29e452', resource: 'abc', scopes: 'abc' });
assert.strictEqual(actual.success, true);
});
});

87 changes: 23 additions & 64 deletions src/m365/entra/commands/approleassignment/approleassignment-add.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os from 'os';
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 { formatting } from '../../../../utils/formatting.js';
import { validation } from '../../../../utils/validation.js';
Expand All @@ -15,18 +16,21 @@ interface AppRole {
resourceId: string;
}

export const options = z.strictObject({
...globalOptionsZod.shape,
appId: z.uuid().optional(),
appObjectId: z.uuid().optional(),
appDisplayName: z.string().optional(),
resource: z.string().alias('r'),
scopes: z.string().alias('s')
});

declare type Options = z.infer<typeof options>;

interface CommandArgs {
options: Options;
}

interface Options extends GlobalOptions {
appId?: string;
appObjectId?: string;
appDisplayName?: string;
resource: string;
scopes: string;
}

class EntraAppRoleAssignmentAddCommand extends GraphCommand {
public get name(): string {
return commands.APPROLEASSIGNMENT_ADD;
Expand All @@ -36,64 +40,19 @@ class EntraAppRoleAssignmentAddCommand extends GraphCommand {
return 'Adds service principal permissions also known as scopes and app role assignments for specified Microsoft Entra application registration';
}

constructor() {
super();

this.#initTelemetry();
this.#initOptions();
this.#initValidators();
this.#initOptionSets();
}

#initTelemetry(): void {
this.telemetry.push((args: CommandArgs) => {
Object.assign(this.telemetryProperties, {
appId: typeof args.options.appId !== 'undefined',
appObjectId: typeof args.options.appObjectId !== 'undefined',
appDisplayName: typeof args.options.appDisplayName !== 'undefined'
});
});
}

#initOptions(): void {
this.options.unshift(
{
option: '--appId [appId]'
},
{
option: '--appObjectId [appObjectId]'
},
{
option: '--appDisplayName [appDisplayName]'
},
{
option: '-r, --resource <resource>',
autocomplete: ['Microsoft Graph', 'SharePoint', 'OneNote', 'Exchange', 'Microsoft Forms', 'Azure Active Directory Graph', 'Skype for Business']
},
{
option: '-s, --scopes <scopes>'
}
);
public get schema(): z.ZodType | undefined {
return options;
}

#initValidators(): void {
this.validators.push(
async (args: CommandArgs) => {
if (args.options.appId && !validation.isValidGuid(args.options.appId)) {
return `${args.options.appId} is not a valid GUID`;
}

if (args.options.appObjectId && !validation.isValidGuid(args.options.appObjectId)) {
return `${args.options.appObjectId} is not a valid GUID`;
public getRefinedSchema(schema: typeof options): z.ZodObject<any> | undefined {
return schema
.refine(options => [options.appId, options.appObjectId, options.appDisplayName].filter(o => o !== undefined).length === 1, {
error: 'Specify either appId, appObjectId, or appDisplayName',
params: {
customCode: 'optionSet',
options: ['appId', 'appObjectId', 'appDisplayName']
}

return true;
}
);
}

#initOptionSets(): void {
this.optionSets.push({ options: ['appId', 'appObjectId', 'appDisplayName'] });
});
}

public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
Expand Down
Loading
Loading