Skip to content

Commit

Permalink
Extend 'team user app remove' command with --userName (UPN) . Closes #…
Browse files Browse the repository at this point in the history
  • Loading branch information
reshmee011 authored and Waldek Mastykarz committed Nov 9, 2023
1 parent 6775e3a commit 15330d4
Show file tree
Hide file tree
Showing 3 changed files with 108 additions and 23 deletions.
13 changes: 11 additions & 2 deletions docs/docs/cmd/teams/user/user-app-remove.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ m365 teams user app remove [options]
`--id <id>`
: The unique id of the app instance installed for the user.

`--userId <userId>`
: The ID of the user to uninstall the app for.
`--userId [userId]`
: The ID of the user to uninstall the app for. Specify `userId` or `userName` but not both.

`--userName [userName]`
: The UPN of the user to uninstall the app for. Specify `userId` or `userName` but not both.

`-f, --force`
: Confirm removal of app for user.
Expand All @@ -38,6 +41,12 @@ Uninstall an app for the specified user.
m365 teams user app remove --id YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY= --userId 2609af39-7775-4f94-a3dc-0dd67657e900
```

Uninstall an app for the specified user using its UPN.

```sh
m365 teams user app remove --id YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY= --userName [email protected]
```

## Response

The command won't return a response on success.
90 changes: 74 additions & 16 deletions src/m365/teams/commands/user/user-app-remove.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CommandInfo } from '../../../../cli/CommandInfo.js';
import { Logger } from '../../../../cli/Logger.js';
import { CommandError } from '../../../../Command.js';
import request from '../../../../request.js';
import { formatting } from '../../../../utils/formatting.js';
import { telemetry } from '../../../../telemetry.js';
import { pid } from '../../../../utils/pid.js';
import { session } from '../../../../utils/session.js';
Expand All @@ -14,6 +15,9 @@ import commands from '../../commands.js';
import command from './user-app-remove.js';

describe(commands.USER_APP_REMOVE, () => {
const userId = '15d7a78e-fd77-4599-97a5-dbb6372846c6';
const userName = '[email protected]';
const appId = 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=';
let log: string[];
let logger: Logger;
let promptIssued: boolean = false;
Expand Down Expand Up @@ -74,7 +78,17 @@ describe(commands.USER_APP_REMOVE, () => {
const actual = await command.validate({
options: {
userId: 'invalid',
id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY='
id: appId
}
}, commandInfo);
assert.notStrictEqual(actual, true);
});

it('fails validation if the userName is not a valid UPN.', async () => {
const actual = await command.validate({
options: {
userName: "no-an-email",
id: appId
}
}, commandInfo);
assert.notStrictEqual(actual, true);
Expand All @@ -83,8 +97,18 @@ describe(commands.USER_APP_REMOVE, () => {
it('passes validation when the input is correct', async () => {
const actual = await command.validate({
options: {
id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=',
userId: '15d7a78e-fd77-4599-97a5-dbb6372846c5'
id: appId,
userId: userId
}
}, commandInfo);
assert.strictEqual(actual, true);
});

it('passes validation when the input is correct (userName)', async () => {
const actual = await command.validate({
options: {
id: appId,
userName: userName
}
}, commandInfo);
assert.strictEqual(actual, true);
Expand All @@ -93,8 +117,8 @@ describe(commands.USER_APP_REMOVE, () => {
it('prompts before removing the app when confirmation argument is not passed', async () => {
await command.action(logger, {
options: {
userId: 'c527a470-a882-481c-981c-ee6efaba85c7',
id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY='
userId: userId,
id: appId
}
} as any);

Expand All @@ -106,34 +130,51 @@ describe(commands.USER_APP_REMOVE, () => {

await command.action(logger, {
options: {
userId: 'c527a470-a882-481c-981c-ee6efaba85c7',
id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY='
userId: userId,
id: appId
}
} as any);
assert(requestDeleteSpy.notCalled);
});

it('removes the app for the specified user when confirmation is specified (debug)', async () => {
sinon.stub(request, 'delete').callsFake(async (opts) => {
if ((opts.url as string).indexOf(`/users/c527a470-a882-481c-981c-ee6efaba85c7/teamwork/installedApps/YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=`) > -1) {
if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) {
return;
}
throw 'Invalid request';
});

await command.action(logger, {
options: {
userId: 'c527a470-a882-481c-981c-ee6efaba85c7',
id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=',
userId: userId,
id: appId,
debug: true,
force: true
}
} as any);
});

it('removes the app for the specified user using username when confirmation is specified.', async () => {
sinon.stub(request, 'delete').callsFake(async (opts) => {
if ((opts.url as string).indexOf(`/users/${formatting.encodeQueryParameter(userName)}/teamwork/installedApps/${appId}`) > -1) {
return Promise.resolve();
}
throw 'Invalid request';
});

await command.action(logger, {
options: {
userName: userName,
id: appId,
force: true
}
} as any);
});

it('removes the app for the specified user when prompt is confirmed (debug)', async () => {
sinon.stub(request, 'delete').callsFake((opts) => {
if ((opts.url as string).indexOf(`/users/c527a470-a882-481c-981c-ee6efaba85c7/teamwork/installedApps/YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=`) > -1) {
if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) {
return Promise.resolve();
}
throw 'Invalid request';
Expand All @@ -144,13 +185,30 @@ describe(commands.USER_APP_REMOVE, () => {

await command.action(logger, {
options: {
userId: 'c527a470-a882-481c-981c-ee6efaba85c7',
id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=',
userId: userId,
id: appId,
debug: true
}
} as any);
});

it('removes the app for the specified user using username', async () => {
sinon.stub(request, 'get').callsFake(async (opts) => {
if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) {
return Promise.resolve();
}
throw 'Invalid request';
});

await command.action(logger, {
options: {
userName: userName,
id: appId
}
} as any);
});


it('correctly handles error while removing teams app', async () => {
const error = {
"error": {
Expand All @@ -165,16 +223,16 @@ describe(commands.USER_APP_REMOVE, () => {
};

sinon.stub(request, 'delete').callsFake(async (opts) => {
if ((opts.url as string).indexOf(`/users/c527a470-a882-481c-981c-ee6efaba85c7/teamwork/installedApps/YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=`) > -1) {
if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) {
throw error;
}
throw 'Invalid request';
});

await assert.rejects(command.action(logger, {
options: {
userId: 'c527a470-a882-481c-981c-ee6efaba85c7',
id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=',
userId: userId,
id: appId,
force: true
}
} as any), new CommandError(error.error.message));
Expand Down
28 changes: 23 additions & 5 deletions src/m365/teams/commands/user/user-app-remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Cli } from '../../../../cli/Cli.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';
import GraphCommand from '../../../base/GraphCommand.js';
import commands from '../../commands.js';
Expand All @@ -12,7 +13,8 @@ interface CommandArgs {

interface Options extends GlobalOptions {
id: string;
userId: string;
userId?: string;
userName?: string;
force?: boolean;
}

Expand All @@ -31,11 +33,14 @@ class TeamsUserAppRemoveCommand extends GraphCommand {
this.#initTelemetry();
this.#initOptions();
this.#initValidators();
this.#initOptionSets();
}

#initTelemetry(): void {
this.telemetry.push((args: CommandArgs) => {
Object.assign(this.telemetryProperties, {
userId: typeof args.options.userId !== 'undefined',
userName: typeof args.options.userName !== 'undefined',
force: (!!args.options.force).toString()
});
});
Expand All @@ -47,7 +52,10 @@ class TeamsUserAppRemoveCommand extends GraphCommand {
option: '--id <id>'
},
{
option: '--userId <userId>'
option: '--userId [userId]'
},
{
option: '--userName [userName]'
},
{
option: '-f, --force'
Expand All @@ -58,21 +66,31 @@ class TeamsUserAppRemoveCommand extends GraphCommand {
#initValidators(): void {
this.validators.push(
async (args: CommandArgs) => {
if (!validation.isValidGuid(args.options.userId)) {
if (args.options.userId && !validation.isValidGuid(args.options.userId)) {
return `${args.options.userId} is not a valid GUID`;
}

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

return true;
}
);
}

#initOptionSets(): void {
this.optionSets.push({ options: ['userId', 'userName'] });
}

public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
const removeApp = async (): Promise<void> => {
// validation ensures that here we have either userId or userName
const userId: string = (args.options.userId ?? args.options.userName) as string;
const endpoint: string = `${this.resource}/v1.0`;

const requestOptions: CliRequestOptions = {
url: `${endpoint}/users/${args.options.userId}/teamwork/installedApps/${args.options.id}`,
url: `${endpoint}/users/${formatting.encodeQueryParameter(userId)}/teamwork/installedApps/${args.options.id}`,
headers: {
'accept': 'application/json;odata.metadata=none'
},
Expand All @@ -91,7 +109,7 @@ class TeamsUserAppRemoveCommand extends GraphCommand {
await removeApp();
}
else {
const result = await Cli.promptForConfirmation({ message: `Are you sure you want to remove the app with id ${args.options.id} for user ${args.options.userId}?` });
const result = await Cli.promptForConfirmation({ message: `Are you sure you want to remove the app with id ${args.options.id} for user ${args.options.userId ?? args.options.userName}?` });

if (result) {
await removeApp();
Expand Down

0 comments on commit 15330d4

Please sign in to comment.