From ca23553329c197767bb1da2a67c770128125e373 Mon Sep 17 00:00:00 2001 From: reshmee011 Date: Tue, 5 Sep 2023 18:41:02 +0000 Subject: [PATCH 1/3] change for #5446 --- docs/docs/cmd/teams/user/user-app-remove.mdx | 13 ++- .../commands/user/user-app-remove.spec.ts | 99 ++++++++++++++++--- .../teams/commands/user/user-app-remove.ts | 41 +++++++- 3 files changed, 131 insertions(+), 22 deletions(-) diff --git a/docs/docs/cmd/teams/user/user-app-remove.mdx b/docs/docs/cmd/teams/user/user-app-remove.mdx index cda4387a971..604c210c8a7 100644 --- a/docs/docs/cmd/teams/user/user-app-remove.mdx +++ b/docs/docs/cmd/teams/user/user-app-remove.mdx @@ -16,8 +16,11 @@ m365 teams user app remove [options] `--id ` : The unique id of the app instance installed for the user. -`--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. @@ -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 admin@contoso.com +``` + ## Response The command won't return a response on success. diff --git a/src/m365/teams/commands/user/user-app-remove.spec.ts b/src/m365/teams/commands/user/user-app-remove.spec.ts index d2e479b23d1..deb065b34dd 100644 --- a/src/m365/teams/commands/user/user-app-remove.spec.ts +++ b/src/m365/teams/commands/user/user-app-remove.spec.ts @@ -7,6 +7,7 @@ import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; +import { formatting } from '../../../../utils/formatting.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; @@ -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 = 'admin@contoso.onmicrosoft.com'; + const appId = 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY='; let log: string[]; let logger: Logger; let promptOptions: any; @@ -72,7 +76,35 @@ 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 both userId and userName are not provided.', async () => { + const actual = await command.validate({ + options: { + } + }, 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); + }); + + it('fails validation if the both userId and userName are provided.', async () => { + const actual = await command.validate({ + options: { + userId: userId, + userName: userName } }, commandInfo); assert.notStrictEqual(actual, true); @@ -81,8 +113,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); @@ -91,8 +133,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); let promptIssued = false; @@ -107,8 +149,8 @@ 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); @@ -116,7 +158,7 @@ describe(commands.USER_APP_REMOVE, () => { 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'; @@ -124,8 +166,8 @@ describe(commands.USER_APP_REMOVE, () => { await command.action(logger, { options: { - userId: 'c527a470-a882-481c-981c-ee6efaba85c7', - id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=', + userId: userId, + id: appId, debug: true, force: true } @@ -134,7 +176,7 @@ describe(commands.USER_APP_REMOVE, () => { 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'; @@ -145,8 +187,33 @@ 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, 'delete').callsFake(async (opts) => { + if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) { + return Promise.resolve(); + } + + if ((opts.url as string).indexOf(`/users/${formatting.encodeQueryParameter(userName)}/id`) > -1) { + return { "value": userId }; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(Cli.prompt); + sinon.stub(Cli, 'prompt').resolves({ continue: true }); + + await command.action(logger, { + options: { + userName: userName, + id: appId, debug: true } } as any); @@ -166,7 +233,7 @@ 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'; @@ -174,8 +241,8 @@ describe(commands.USER_APP_REMOVE, () => { 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)); diff --git a/src/m365/teams/commands/user/user-app-remove.ts b/src/m365/teams/commands/user/user-app-remove.ts index eb98a07bf51..a4041b0bddb 100644 --- a/src/m365/teams/commands/user/user-app-remove.ts +++ b/src/m365/teams/commands/user/user-app-remove.ts @@ -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'; @@ -13,6 +14,7 @@ interface CommandArgs { interface Options extends GlobalOptions { id: string; userId: string; + userName: string; force?: boolean; } @@ -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() }); }); @@ -47,7 +52,10 @@ class TeamsUserAppRemoveCommand extends GraphCommand { option: '--id ' }, { - option: '--userId ' + option: '--userId [userId]' + }, + { + option: '--userName [userName]' }, { option: '-f, --force' @@ -58,21 +66,30 @@ 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 { const removeApp = async (): Promise => { + const userId: string = (await this.getUserId(args)).value; 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' }, @@ -95,7 +112,7 @@ class TeamsUserAppRemoveCommand extends GraphCommand { type: 'confirm', name: 'continue', default: false, - message: `Are you sure you want to remove the app with id ${args.options.id} for user ${args.options.userId}?` + 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.continue) { @@ -103,6 +120,22 @@ class TeamsUserAppRemoveCommand extends GraphCommand { } } } + + private async getUserId(args: CommandArgs): Promise<{ value: string }> { + if (args.options.userId) { + return { value: args.options.userId }; + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/users/${formatting.encodeQueryParameter(args.options.userName)}/id`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + return request.get<{ value: string; }>(requestOptions); + } } export default new TeamsUserAppRemoveCommand(); \ No newline at end of file From 5094b7cceacb8aa0682332d20e876eacd6488271 Mon Sep 17 00:00:00 2001 From: reshmee011 Date: Thu, 7 Sep 2023 05:22:47 +0000 Subject: [PATCH 2/3] Fix the test build errors --- .../commands/user/user-app-remove.spec.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/m365/teams/commands/user/user-app-remove.spec.ts b/src/m365/teams/commands/user/user-app-remove.spec.ts index deb065b34dd..14f73014f57 100644 --- a/src/m365/teams/commands/user/user-app-remove.spec.ts +++ b/src/m365/teams/commands/user/user-app-remove.spec.ts @@ -16,7 +16,7 @@ import command from './user-app-remove.js'; describe(commands.USER_APP_REMOVE, () => { const userId = '15d7a78e-fd77-4599-97a5-dbb6372846c6'; - const userName = 'admin@contoso.onmicrosoft.com'; + const userName = 'contoso@contoso.onmicrosoft.com'; const appId = 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY='; let log: string[]; let logger: Logger; @@ -195,7 +195,7 @@ describe(commands.USER_APP_REMOVE, () => { }); it('removes the app for the specified user using username', async () => { - sinon.stub(request, 'delete').callsFake(async (opts) => { + sinon.stub(request, 'get').callsFake(async (opts) => { if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) { return Promise.resolve(); } @@ -207,14 +207,32 @@ describe(commands.USER_APP_REMOVE, () => { throw 'Invalid request'; }); - sinonUtil.restore(Cli.prompt); - sinon.stub(Cli, 'prompt').resolves({ continue: true }); + await command.action(logger, { + options: { + userName: userName, + id: appId + } + } 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/${userId}/teamwork/installedApps/${appId}`) > -1) { + return Promise.resolve(); + } + + if ((opts.url as string).indexOf(`/users/${formatting.encodeQueryParameter(userName)}/id`) > -1) { + return { "value": userId }; + } + + throw 'Invalid request'; + }); await command.action(logger, { options: { userName: userName, id: appId, - debug: true + force: true } } as any); }); From 9e451fe43da5360e42c79fccdba7eee31367e13f Mon Sep 17 00:00:00 2001 From: reshmee011 Date: Mon, 9 Oct 2023 03:31:12 +0000 Subject: [PATCH 3/3] Update to remove function getUserId --- .../commands/user/user-app-remove.spec.ts | 63 ++++++------------- .../teams/commands/user/user-app-remove.ts | 18 +----- 2 files changed, 19 insertions(+), 62 deletions(-) diff --git a/src/m365/teams/commands/user/user-app-remove.spec.ts b/src/m365/teams/commands/user/user-app-remove.spec.ts index 14f73014f57..858b4389164 100644 --- a/src/m365/teams/commands/user/user-app-remove.spec.ts +++ b/src/m365/teams/commands/user/user-app-remove.spec.ts @@ -6,8 +6,8 @@ import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; -import { telemetry } from '../../../../telemetry.js'; import { formatting } from '../../../../utils/formatting.js'; +import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; @@ -82,14 +82,6 @@ describe(commands.USER_APP_REMOVE, () => { assert.notStrictEqual(actual, true); }); - it('fails validation if both userId and userName are not provided.', async () => { - const actual = await command.validate({ - options: { - } - }, commandInfo); - assert.notStrictEqual(actual, true); - }); - it('fails validation if the userName is not a valid UPN.', async () => { const actual = await command.validate({ options: { @@ -100,16 +92,6 @@ describe(commands.USER_APP_REMOVE, () => { assert.notStrictEqual(actual, true); }); - it('fails validation if the both userId and userName are provided.', async () => { - const actual = await command.validate({ - options: { - userId: userId, - userName: userName - } - }, commandInfo); - assert.notStrictEqual(actual, true); - }); - it('passes validation when the input is correct', async () => { const actual = await command.validate({ options: { @@ -174,69 +156,60 @@ describe(commands.USER_APP_REMOVE, () => { } 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/${userId}/teamwork/installedApps/${appId}`) > -1) { + 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'; }); - sinonUtil.restore(Cli.prompt); - sinon.stub(Cli, 'prompt').resolves({ continue: true }); - await command.action(logger, { options: { - userId: userId, + userName: userName, id: appId, - debug: true + force: true } } as any); }); - it('removes the app for the specified user using username', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { + 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/${userId}/teamwork/installedApps/${appId}`) > -1) { return Promise.resolve(); } - - if ((opts.url as string).indexOf(`/users/${formatting.encodeQueryParameter(userName)}/id`) > -1) { - return { "value": userId }; - } - throw 'Invalid request'; }); + sinonUtil.restore(Cli.prompt); + sinon.stub(Cli, 'prompt').resolves({ continue: true }); + await command.action(logger, { options: { - userName: userName, - id: appId + userId: userId, + id: appId, + debug: 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) => { + 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(); } - - if ((opts.url as string).indexOf(`/users/${formatting.encodeQueryParameter(userName)}/id`) > -1) { - return { "value": userId }; - } - throw 'Invalid request'; }); await command.action(logger, { options: { userName: userName, - id: appId, - force: true + id: appId } } as any); }); + it('correctly handles error while removing teams app', async () => { const error = { "error": { diff --git a/src/m365/teams/commands/user/user-app-remove.ts b/src/m365/teams/commands/user/user-app-remove.ts index a4041b0bddb..e4dd78350ea 100644 --- a/src/m365/teams/commands/user/user-app-remove.ts +++ b/src/m365/teams/commands/user/user-app-remove.ts @@ -85,7 +85,7 @@ class TeamsUserAppRemoveCommand extends GraphCommand { public async commandAction(logger: Logger, args: CommandArgs): Promise { const removeApp = async (): Promise => { - const userId: string = (await this.getUserId(args)).value; + const userId: string = args.options.userId ?? args.options.userName; const endpoint: string = `${this.resource}/v1.0`; const requestOptions: CliRequestOptions = { @@ -120,22 +120,6 @@ class TeamsUserAppRemoveCommand extends GraphCommand { } } } - - private async getUserId(args: CommandArgs): Promise<{ value: string }> { - if (args.options.userId) { - return { value: args.options.userId }; - } - - const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/users/${formatting.encodeQueryParameter(args.options.userName)}/id`, - headers: { - accept: 'application/json;odata.metadata=none' - }, - responseType: 'json' - }; - - return request.get<{ value: string; }>(requestOptions); - } } export default new TeamsUserAppRemoveCommand(); \ No newline at end of file