Skip to content
Draft
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
89 changes: 53 additions & 36 deletions src/m365/spo/commands/user/user-ensure.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 './user-ensure.js';
import command, { options } from './user-ensure.js';
import { entraGroup } from '../../../../utils/entraGroup.js';

describe(commands.USER_ENSURE, () => {
Expand Down Expand Up @@ -142,6 +142,7 @@ describe(commands.USER_ENSURE, () => {
let logger: Logger;
let loggerLogSpy: sinon.SinonSpy;
let commandInfo: CommandInfo;
let commandOptionsSchema: typeof options;

before(() => {
sinon.stub(auth, 'restoreAuth').resolves();
Expand All @@ -150,6 +151,7 @@ describe(commands.USER_ENSURE, () => {
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 @@ -199,7 +201,7 @@ describe(commands.USER_ENSURE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, userName: validUserName } });
await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, userName: validUserName }) });
assert(loggerLogSpy.calledWith(ensuredUserResponse));
});

Expand All @@ -216,7 +218,7 @@ describe(commands.USER_ENSURE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraId: validEntraId } });
await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, entraId: validEntraId }) });
assert(loggerLogSpy.calledWith(ensuredUserResponse));
});

Expand All @@ -229,7 +231,7 @@ describe(commands.USER_ENSURE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, loginName: validLoginName } });
await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, loginName: validLoginName }) });
assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'i:0#.f|membership|john@contoso.com' });
});

Expand All @@ -244,7 +246,7 @@ describe(commands.USER_ENSURE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraGroupId: validEntraGroupId } });
await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, entraGroupId: validEntraGroupId }) });
assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'c:0o.c|federateddirectoryclaimprovider|2056d2f6-3257-4253-8cfc-b73393e414e5' });
});

Expand All @@ -259,7 +261,7 @@ describe(commands.USER_ENSURE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraGroupName: validEntraSecurityGroupName } });
await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, entraGroupName: validEntraSecurityGroupName }) });
assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'c:0t.c|tenant|2056d2f6-3257-4253-8cfc-b73393e414e5' });
});

Expand All @@ -274,7 +276,7 @@ describe(commands.USER_ENSURE, () => {
throw 'Invalid request';
});

await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraGroupName: validEntraGroupName } });
await command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, entraGroupName: validEntraGroupName }) });
assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'c:0o.c|federateddirectoryclaimprovider|2056d2f6-3257-4253-8cfc-b73393e414e5' });
});

Expand All @@ -295,7 +297,7 @@ describe(commands.USER_ENSURE, () => {
};
});

await assert.rejects(command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraId: validEntraId } }), new CommandError(`Resource '${validEntraId}' does not exist or one of its queried reference-property objects are not present.`));
await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, entraId: validEntraId }) }), new CommandError(`Resource '${validEntraId}' does not exist or one of its queried reference-property objects are not present.`));
});

it('throws error message when no user was found with a specific user name', async () => {
Expand All @@ -319,51 +321,66 @@ describe(commands.USER_ENSURE, () => {
throw 'Invalid request';
});

await assert.rejects(command.action(logger, { options: { verbose: true, webUrl: validWebUrl, userName: validUserName } }), new CommandError(error.error['odata.error'].message.value));
await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ verbose: true, webUrl: validWebUrl, userName: validUserName }) }), new CommandError(error.error['odata.error'].message.value));
});

it('fails validation if webUrl is not a valid url', async () => {
const actual = await command.validate({ options: { webUrl: 'invalid', entraId: validEntraId } }, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation if webUrl is not a valid url', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', entraId: validEntraId });
assert.strictEqual(actual.success, false);
});

it('fails validation if entraId is not a valid id', async () => {
const actual = await command.validate({ options: { webUrl: validWebUrl, entraId: 'invalid' } }, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation if entraId is not a valid id', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraId: 'invalid' });
assert.strictEqual(actual.success, false);
});

it('fails validation if userName is not a valid user principal name', async () => {
const actual = await command.validate({ options: { webUrl: validWebUrl, userName: 'invalid' } }, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation if userName is not a valid user principal name', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: 'invalid' });
assert.strictEqual(actual.success, false);
});

it('fails validation if entraGroupId is not a valid id', async () => {
const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: 'invalid' } }, commandInfo);
assert.notStrictEqual(actual, true);
it('fails validation if entraGroupId is not a valid id', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupId: 'invalid' });
assert.strictEqual(actual.success, false);
});

it('passes validation if the url is valid and entraId is a valid id', async () => {
const actual = await command.validate({ options: { webUrl: validWebUrl, entraId: validEntraId } }, commandInfo);
assert.strictEqual(actual, true);
it('fails validation without a user selector', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl });
assert.strictEqual(actual.success, false);
});

it('passes validation if the url is valid and userName is a valid user principal name', async () => {
const actual = await command.validate({ options: { webUrl: validWebUrl, userName: validUserName } }, commandInfo);
assert.strictEqual(actual, true);
it('fails validation with multiple user selectors', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: validUserName, loginName: validLoginName });
assert.strictEqual(actual.success, false);
});

it('passes validation if the url is valid and loginName is passed', async () => {
const actual = await command.validate({ options: { webUrl: validWebUrl, loginName: validLoginName } }, commandInfo);
assert.strictEqual(actual, true);
it('passes validation if the url is valid and entraId is a valid id', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraId: validEntraId });
assert.strictEqual(actual.success, true);
});

it('passes validation if the url is valid and entraGroupName is passed', async () => {
const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupName: validEntraGroupName } }, commandInfo);
assert.strictEqual(actual, true);
it('passes validation if the url is valid and userName is a valid user principal name', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: validUserName });
assert.strictEqual(actual.success, true);
});

it('passes validation if the url is valid and entraGroupId is passed', async () => {
const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: validEntraGroupId } }, commandInfo);
assert.strictEqual(actual, true);
it('passes validation if the url is valid and loginName is passed', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, loginName: validLoginName });
assert.strictEqual(actual.success, true);
});

it('passes validation if the url is valid and entraGroupName is passed', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupName: validEntraGroupName });
assert.strictEqual(actual.success, true);
});

it('passes validation if the url is valid and entraGroupId is passed', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, entraGroupId: validEntraGroupId });
assert.strictEqual(actual.success, true);
});

it('fails validation with unknown options', () => {
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, userName: validUserName, unknownOption: 'value' });
assert.strictEqual(actual.success, false);
});
});
108 changes: 25 additions & 83 deletions src/m365/spo/commands/user/user-ensure.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
import { Logger } from '../../../../cli/Logger.js';
import GlobalOptions from '../../../../GlobalOptions.js';
import { globalOptionsZod } from '../../../../Command.js';
import request, { CliRequestOptions } from '../../../../request.js';
import { entraGroup } from '../../../../utils/entraGroup.js';
import { Group } from '@microsoft/microsoft-graph-types';
import { validation } from '../../../../utils/validation.js';
import SpoCommand from '../../../base/SpoCommand.js';
import commands from '../../commands.js';
import { entraUser } from '../../../../utils/entraUser.js';
import { z } from 'zod';

export const options = z.strictObject({
...globalOptionsZod.shape,
webUrl: z.string().refine(webUrl => validation.isValidSharePointUrl(webUrl) === true, {
error: e => validation.isValidSharePointUrl(e.input as string).toString()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see an inconsistency here. For some commands, you return ${e.input} is not a valid SharePoint Online site URL., while other commands return validation.isValidSharePointUrl(e.input as string).toString().
I would prefer returning ${e.input} is not a valid SharePoint Online site URL. to avoid calling validation.isValidSharePointUrl twice.

}).alias('u'),
entraId: z.string().refine(id => validation.isValidGuid(id), { error: e => `${e.input} is not a valid GUID.` }).optional(),
userName: z.string().refine(userName => validation.isValidUserPrincipalName(userName), { error: e => `${e.input} is not a valid userName.` }).optional(),
loginName: z.string().optional(),
entraGroupId: z.string().refine(id => validation.isValidGuid(id), { error: e => `${e.input} is not a valid GUID for option 'entraGroupId'.` }).optional(),
entraGroupName: z.string().optional()
});

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

interface CommandArgs {
options: Options;
}

interface Options extends GlobalOptions {
webUrl: string;
entraId?: string;
userName?: string;
loginName?: string;
entraGroupId?: string;
entraGroupName?: string;
}

class SpoUserEnsureCommand extends SpoCommand {
public get name(): string {
return commands.USER_ENSURE;
Expand All @@ -30,82 +36,18 @@ class SpoUserEnsureCommand extends SpoCommand {
return 'Ensures that a user is available on a specific site';
}

constructor() {
super();

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

#initTelemetry(): void {
this.telemetry.push((args: CommandArgs) => {
Object.assign(this.telemetryProperties, {
entraId: typeof args.options.entraId !== 'undefined',
userName: typeof args.options.userName !== 'undefined',
loginName: typeof args.options.loginName !== 'undefined',
entraGroupId: typeof args.options.entraGroupId !== 'undefined',
entraGroupName: typeof args.options.entraGroupName !== 'undefined'
});
});
}

#initOptions(): void {
this.options.unshift(
{
option: '-u, --webUrl <webUrl>'
},
{
option: '--entraId [entraId]'
},
{
option: '--userName [userName]'
},
{
option: '--loginName [loginName]'
},
{
option: '--entraGroupId [entraGroupId]'
},
{
option: '--entraGroupName [entraGroupName]'
}
);
public get schema(): z.ZodType {
return options;
}

#initValidators(): void {
this.validators.push(
async (args: CommandArgs) => {
const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl);
if (isValidSharePointUrl !== true) {
return isValidSharePointUrl;
}

if (args.options.entraId && !validation.isValidGuid(args.options.entraId)) {
return `${args.options.entraId} is not a valid GUID.`;
}

if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) {
return `${args.options.userName} is not a valid userName.`;
}

if (args.options.entraGroupId && !validation.isValidGuid(args.options.entraGroupId)) {
return `${args.options.entraGroupId} is not a valid GUID for option 'entraGroupId'.`;
}

return true;
public getRefinedSchema(schema: typeof options): z.ZodObject<any> | undefined {
return schema.refine(opts => [opts.entraId, opts.userName, opts.loginName, opts.entraGroupId, opts.entraGroupName].filter(value => value !== undefined).length === 1, {
error: 'Specify one of the following options: entraId, userName, loginName, entraGroupId, entraGroupName.',
params: {
customCode: 'optionSet',
options: ['entraId', 'userName', 'loginName', 'entraGroupId', 'entraGroupName']
}
);
}

#initOptionSets(): void {
this.optionSets.push({ options: ['entraId', 'userName', 'loginName', 'entraGroupId', 'entraGroupName'] });
}

#initTypes(): void {
this.types.string.push('webUrl', 'entraId', 'userName', 'loginName', 'entraGroupId', 'entraGroupName');
});
}

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