diff --git a/packages/cli/.vscode/launch.json b/packages/cli/.vscode/launch.json index 4338fe3e8e..89f80b57f4 100644 --- a/packages/cli/.vscode/launch.json +++ b/packages/cli/.vscode/launch.json @@ -113,6 +113,25 @@ "${workspaceFolder}/../api/build/**/*.js" ], "console": "integratedTerminal" + }, + { + "type": "pwa-node", + "request": "launch", + "name": "Launch uninstall command", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/cli.js", + "args": ["uninstall"], + "outFiles": [ + "${workspaceFolder}/lib/**/*.js", + "${workspaceFolder}/../fx-core/build/**/*.js", + "${workspaceFolder}/../api/build/**/*.js" + ], + "resolveSourceMapLocations": [ + "${workspaceFolder}/lib/**/*.js", + "${workspaceFolder}/../fx-core/build/**/*.js", + "${workspaceFolder}/../api/build/**/*.js" + ], + "console": "integratedTerminal" } ] } diff --git a/packages/cli/src/commands/engine.ts b/packages/cli/src/commands/engine.ts index 1780c15c8e..a048e46655 100644 --- a/packages/cli/src/commands/engine.ts +++ b/packages/cli/src/commands/engine.ts @@ -503,7 +503,7 @@ class CLIEngine { context.optionValues.platform = Platform.CLI; // set projectPath const projectFolderOption = context.command.options?.find( - (o) => o.questionName === "projectPath" + (o) => o.questionName === "projectPath" && o.required ); if (projectFolderOption) { // resolve projectPath diff --git a/packages/cli/src/commands/models/m365Unacquire.ts b/packages/cli/src/commands/models/m365Unacquire.ts index 9aef78fc3d..0c1753a4d5 100644 --- a/packages/cli/src/commands/models/m365Unacquire.ts +++ b/packages/cli/src/commands/models/m365Unacquire.ts @@ -1,12 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { CLICommand, err, ok } from "@microsoft/teamsfx-api"; -import { PackageService } from "@microsoft/teamsfx-core"; +import { UninstallInputs, QuestionNames } from "@microsoft/teamsfx-core"; import { logger } from "../../commonlib/logger"; import { MissingRequiredOptionError } from "../../error"; import { commands } from "../../resource"; import { TelemetryEvent } from "../../telemetry/cliTelemetryEvents"; import { m365utils, sideloadingServiceEndpoint } from "./m365Sideloading"; +import { getFxCore } from "../../activate"; export const m365UnacquireCommand: CLICommand = { name: "uninstall", @@ -14,44 +15,67 @@ export const m365UnacquireCommand: CLICommand = { description: commands.uninstall.description, options: [ { - name: "title-id", + name: QuestionNames.UninstallMode, + description: commands.uninstall.options["mode"], + type: "string", + }, + { + name: QuestionNames.TitleId, description: commands.uninstall.options["title-id"], type: "string", }, { - name: "manifest-id", + name: QuestionNames.ManifestId, description: commands.uninstall.options["manifest-id"], type: "string", }, + { + name: QuestionNames.Env, + description: commands.uninstall.options["env"], + type: "string", + }, + { + name: "folder", + questionName: QuestionNames.ProjectPath, + description: commands.uninstall.options["folder"], + type: "string", + }, + { + name: QuestionNames.UninstallOptions, + description: commands.uninstall.options["options"], + type: "array", + }, ], examples: [ { - command: `${process.env.TEAMSFX_CLI_BIN_NAME} uninstall --title-id U_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, - description: "Remove the acquired M365 App by Title ID", + command: `${process.env.TEAMSFX_CLI_BIN_NAME} uninstall -i false --mode title-id --title-id U_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, + description: "Remove the acquired Microsoft 365 Application using Title ID", + }, + { + command: `${process.env.TEAMSFX_CLI_BIN_NAME} uninstall -i false --mode manifest-id --manifest-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --options 'm365-app,app-registration,bot-framework-registration'`, + description: "Remove the acquired Microsoft 365 Application using Manifest ID", + }, + { + command: `${process.env.TEAMSFX_CLI_BIN_NAME} uninstall -i false --mode env --env xxx --options 'm365-app,app-registration,bot-framework-registration' --folder ./myapp`, + description: + "Remove the acquired Microsoft 365 Application using environment in Teams Toolkit generated project", }, { - command: `${process.env.TEAMSFX_CLI_BIN_NAME} uninstall --manifest-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, - description: "Remove the acquired M365 App by Manifest ID", + command: `${process.env.TEAMSFX_CLI_BIN_NAME} uninstall`, + description: "Uninstall in interactive mode", }, ], telemetry: { event: TelemetryEvent.M365Unacquire, }, - defaultInteractiveOption: false, + defaultInteractiveOption: true, handler: async (ctx) => { - const packageService = new PackageService(sideloadingServiceEndpoint, logger); - let titleId = ctx.optionValues["title-id"] as string; - const manifestId = ctx.optionValues["manifest-id"] as string; - if (titleId === undefined && manifestId === undefined) { - return err( - new MissingRequiredOptionError(ctx.command.fullName, `--title-id or --manifest-id`) - ); - } - const tokenAndUpn = await m365utils.getTokenAndUpn(); - if (titleId === undefined) { - titleId = await packageService.retrieveTitleId(tokenAndUpn[0], manifestId); + const inputs = ctx.optionValues as UninstallInputs; + const core = getFxCore(); + const res = await core.uninstall(inputs); + if (res.isErr()) { + return err(res.error); } - await packageService.unacquire(tokenAndUpn[0], titleId); return ok(undefined); }, }; diff --git a/packages/cli/src/resource/commands.json b/packages/cli/src/resource/commands.json index 842f0e3df5..472f8d7ded 100644 --- a/packages/cli/src/resource/commands.json +++ b/packages/cli/src/resource/commands.json @@ -144,10 +144,14 @@ "description": "Run the publish stage in teamsapp.yml." }, "uninstall": { - "description": "Remove an acquired M365 App.", + "description": "Clean up resources associated with Manifest ID, Title ID, or an environment in Teams Toolkit generated project. Resources include app registration in Teams Developer Portal, bot registration in Bot Framework Portal, and uploaded custom apps in Microsoft 365 apps.", "options": { - "title-id": "Title ID of the acquired M365 App.", - "manifest-id": "Manifest ID of the acquired M365 App." + "mode": "Choose a way to clean up resources.", + "title-id": "Title ID of the App.", + "manifest-id": "Manifest ID of the App.", + "env": "The specific environment in the project created by Teams Toolkit.", + "folder": "Project path to uninstall, only used in env mode.", + "options": "Selected resourecs to uninstall. example: --options m365-app,app-registration,bot-framework-registration" } }, "update": { diff --git a/packages/cli/tests/unit/commands.tests.ts b/packages/cli/tests/unit/commands.tests.ts index dba14f3015..db0b30e85c 100644 --- a/packages/cli/tests/unit/commands.tests.ts +++ b/packages/cli/tests/unit/commands.tests.ts @@ -1,4 +1,4 @@ -import { CLIContext, err, ok } from "@microsoft/teamsfx-api"; +import { CLIContext, SystemError, err, ok } from "@microsoft/teamsfx-api"; import { CollaborationStateResult, FeatureFlags, @@ -775,8 +775,8 @@ describe("CLI commands", () => { beforeEach(() => { sandbox.stub(logger, "warning"); }); - it("MissingRequiredOptionError", async () => { - sandbox.stub(m365utils, "getTokenAndUpn").resolves(["token", "upn"]); + it("success", async () => { + sandbox.stub(FxCore.prototype, "uninstall").resolves(ok(undefined)); const ctx: CLIContext = { command: { ...m365UnacquireCommand, fullName: "teamsfx" }, optionValues: {}, @@ -785,34 +785,19 @@ describe("CLI commands", () => { telemetryProperties: {}, }; const res = await m365UnacquireCommand.handler!(ctx); - assert.isTrue(res.isErr()); - }); - it("success retrieveTitleId", async () => { - sandbox.stub(m365utils, "getTokenAndUpn").resolves(["token", "upn"]); - sandbox.stub(PackageService.prototype, "retrieveTitleId").resolves("id"); - sandbox.stub(PackageService.prototype, "unacquire").resolves(); - const ctx: CLIContext = { - command: { ...m365UnacquireCommand, fullName: "teamsfx" }, - optionValues: { "manifest-id": "aaa" }, - globalOptionValues: {}, - argumentValues: [], - telemetryProperties: {}, - }; - const res = await m365UnacquireCommand.handler!(ctx); assert.isTrue(res.isOk()); }); - it("success", async () => { - sandbox.stub(m365utils, "getTokenAndUpn").resolves(["token", "upn"]); - sandbox.stub(PackageService.prototype, "unacquire").resolves(); + it("failed", async () => { + sandbox.stub(FxCore.prototype, "uninstall").resolves(err(new SystemError("", "", ""))); const ctx: CLIContext = { command: { ...m365UnacquireCommand, fullName: "teamsfx" }, - optionValues: { "title-id": "aaa" }, + optionValues: {}, globalOptionValues: {}, argumentValues: [], telemetryProperties: {}, }; const res = await m365UnacquireCommand.handler!(ctx); - assert.isTrue(res.isOk()); + assert.isTrue(res.isErr()); }); }); diff --git a/packages/fx-core/resource/package.nls.json b/packages/fx-core/resource/package.nls.json index 72d329fb5a..aeb6cce4d3 100644 --- a/packages/fx-core/resource/package.nls.json +++ b/packages/fx-core/resource/package.nls.json @@ -529,6 +529,33 @@ "core.copilot.addAPI.success": "%s have(has) been successfully added to %s", "core.copilot.addAPI.InjectAPIKeyActionFailed": "Inject API key action to teamsapp.yaml file unsuccessful, make sure the file contains teamsApp/create action in provision section.", "core.copilot.addAPI.InjectOAuthActionFailed": "Inject OAuth action to teamsapp.yaml file unsuccessful, make sure the file contains teamsApp/create action in provision section.", + "core.uninstall.botNotFound": "Cannot find bot using the manifest ID %s", + "core.uninstall.confirm.tdp": "App registration of manifest ID: %s will be removed. Please confirm.", + "core.uninstall.confirm.m365App": "Microsoft 365 Application of Title ID: %s will be uninstalled. Please confirm.", + "core.uninstall.confirm.bot": "Bot framework registration of bot ID: %s will be removed. Please confirm.", + "core.uninstall.confirm.cancel.tdp": "Removal of app registration is canceled.", + "core.uninstall.confirm.cancel.m365App": "Uninstallation of Microsoft 365 Application is canceled.", + "core.uninstall.confirm.cancel.bot": "Removal of Bot framework registration is canceled.", + "core.uninstall.success.tdp": "App registration of manifest ID: %s successfully removed.", + "core.uninstall.success.m365App": "Microsoft 365 Application of Title ID: %s successfully uninstalled.", + "core.uninstall.success.delayWarning": "The uninstallation of the Microsoft 365 Application may be delayed.", + "core.uninstall.success.bot": "Bot framework registration of bot ID: %s successfully removed.", + "core.uninstall.failed.titleId": "Unable to find the Title ID. This app is probably not installed.", + "core.uninstallQuestion.manifestId": "Manifest ID", + "core.uninstallQuestion.env": "Environment", + "core.uninstallQuestion.titleId": "Title ID", + "core.uninstallQuestion.chooseMode": "Choose a way to clean up resources", + "core.uninstallQuestion.manifestIdMode": "Manifest ID", + "core.uninstallQuestion.manifestIdMode.detail": "Clean up resources associated with Manifest ID. This includes app registration in Teams Developer Portal, bot registration in Bot Framework Portal, and custom apps uploaded to Microsoft 365. You can find the Manifest ID in the environment file (default environment key: Teams_App_ID) in the project created by Teams Toolkit.", + "core.uninstallQuestion.envMode": "Environment in Teams Toolkit Created Project", + "core.uninstallQuestion.envMode.detail": "Clean up resources associated with a specific environment in the Teams Toolkit created project. Resources include app registration in Teams Developer Portal, bot registration in Bot Framework Portal, and custom apps uploaded in Microsoft 365 apps.", + "core.uninstallQuestion.titleIdMode": "Title ID", + "core.uninstallQuestion.titleIdMode.detail": "Uninstall the uploaded custom app associated with Title ID. The Title ID can be found in the environment file in the Teams Toolkit created project.", + "core.uninstallQuestion.chooseOption": "Choose resources to uninstall", + "core.uninstallQuestion.m365Option": "Microsoft 365 Application", + "core.uninstallQuestion.tdpOption": "App registration", + "core.uninstallQuestion.botOption": "Bot framework registration", + "core.uninstallQuestion.projectPath": "Project path", "ui.select.LoadingOptionsPlaceholder": "Loading options ...", "ui.select.LoadingDefaultPlaceholder": "Loading default value ...", "error.aad.manifest.NameIsMissing": "name is missing\n", diff --git a/packages/fx-core/src/client/teamsDevPortalClient.ts b/packages/fx-core/src/client/teamsDevPortalClient.ts index 80afd319a7..a92be95b8e 100644 --- a/packages/fx-core/src/client/teamsDevPortalClient.ts +++ b/packages/fx-core/src/client/teamsDevPortalClient.ts @@ -274,7 +274,15 @@ export class TeamsDevPortalClient { } throw new Error(`Cannot get the app definition with app ID ${teamsAppId}`); } - + @hooks([ErrorContextMW({ source: "Teams", component: "TeamsDevPortalClient" })]) + async getBotId(token: string, teamsAppId: string): Promise { + const app = await this.getApp(token, teamsAppId); + if (app?.bots?.length && app.bots.length > 0) { + return app.bots[0].botId; + } + TOOLS.logProvider?.error(`botId not found. Input: ${teamsAppId}`); + return undefined; + } @hooks([ErrorContextMW({ source: "Teams", component: "TeamsDevPortalClient" })]) async getAppPackage(token: string, teamsAppId: string): Promise { TOOLS.logProvider?.info("Downloading app package for app " + teamsAppId); diff --git a/packages/fx-core/src/core/FxCore.ts b/packages/fx-core/src/core/FxCore.ts index a8e01f30ad..348bb950b4 100644 --- a/packages/fx-core/src/core/FxCore.ts +++ b/packages/fx-core/src/core/FxCore.ts @@ -38,7 +38,7 @@ import * as path from "path"; import "reflect-metadata"; import { Container } from "typedi"; import { pathToFileURL } from "url"; -import { VSCodeExtensionCommand } from "../common/constants"; +import { VSCodeExtensionCommand, AppStudioScopes } from "../common/constants"; import { ErrorContextMW, TOOLS, @@ -121,6 +121,7 @@ import { MissingRequiredInputError, MultipleAuthError, MultipleServerError, + UnhandledError, UserCancelError, assembleError, } from "../error/common"; @@ -155,6 +156,10 @@ import { } from "./middleware/utils/v3MigrationUtils"; import { CoreTelemetryComponentName, CoreTelemetryEvent, CoreTelemetryProperty } from "./telemetry"; import { CoreHookContext, PreProvisionResForVS, VersionCheckRes } from "./types"; +import { UninstallInputs } from "../question"; +import { PackageService } from "../component/m365/packageService"; +import { MosServiceEndpoint, MosServiceScope } from "../component/m365/serviceConstant"; +import { teamsDevPortalClient } from "../client/teamsDevPortalClient"; export class FxCore { constructor(tools: Tools) { @@ -293,6 +298,329 @@ export class FxCore { } catch (e) {} } } + + /** + * none lifecycle command, uninstall provisioned resources + */ + @hooks([ + ErrorContextMW({ component: "FxCore", stage: "uninstall", reset: true }), + ErrorHandlerMW, + ProjectMigratorMWV3, + QuestionMW("uninstall"), + ]) + async uninstall(inputs: UninstallInputs): Promise> { + switch (inputs[QuestionNames.UninstallMode as string]) { + case QuestionNames.UninstallModeManifestId: + return await this.uninstallByManifestId(inputs); + case QuestionNames.UninstallModeEnv: + return await this.uninstallByEnv(inputs); + case QuestionNames.UninstallModeTitleId: + return await this.uninstallByTitleId(inputs); + default: + return err(new UnhandledError(new Error("Uninstall mode not supported"), "FxCore")); + } + } + + /** + * uninstall provisioned resources by manifest ID + */ + @hooks([ + ErrorContextMW({ component: "FxCore", stage: "uninstallByManifestId", reset: true }), + ErrorHandlerMW, + ]) + async uninstallByManifestId(inputs: UninstallInputs): Promise> { + const manifestId = inputs[QuestionNames.ManifestId as string] as string; + if (!manifestId) { + return err(new MissingRequiredInputError("manifest-id", "FxCore")); + } + const uninstallOptions = inputs[QuestionNames.UninstallOptions as string]; + const m356AppOption = uninstallOptions?.includes(QuestionNames.UninstallOptionM365); + const tdpOption = uninstallOptions?.includes(QuestionNames.UninstallOptionTDP); + const botOption = uninstallOptions?.includes(QuestionNames.UninstallOptionBot); + + if (m356AppOption) { + const res = await this.uninstallM365App(undefined, manifestId); + if (res.isErr()) { + return err(res.error); + } + } + if (botOption) { + const res = await this.uninstallBotFrameworRegistration(undefined, manifestId); + if (res.isErr()) { + return err(res.error); + } + } + // App registraion should be the last to remove, because we might need to query some metadata from TDP. + if (tdpOption) { + const res = await this.uninstallAppRegistration(manifestId); + if (res.isErr()) { + return err(res.error); + } + } + + return ok(undefined); + } + + /** + * uninstall provisioned resources by a given environment + */ + @hooks([ + ErrorContextMW({ component: "FxCore", stage: "uninstallByEnv", reset: true }), + ErrorHandlerMW, + EnvLoaderMW(true, true), + ConcurrentLockerMW, + ContextInjectorMW, + EnvWriterMW, + ]) + async uninstallByEnv( + inputs: UninstallInputs, + ctx?: CoreHookContext + ): Promise> { + if (!inputs.env) { + return err(new MissingRequiredInputError("env", "FxCore")); + } + const teamsappYamlPath = pathUtils.getYmlFilePath(inputs.projectPath!, inputs.env); + const yamlProjectModel = await metadataUtil.parse(teamsappYamlPath, inputs.env); + if (yamlProjectModel.isErr()) { + return err(yamlProjectModel.error); + } + const projectModel = yamlProjectModel.value; + + let teamsAppId; + let botId; + let m365TitleId; + let teamsAppIdKeyName = ""; + let botIdKeyName = ""; + let m365TitleIdKeyName = ""; + for (const action of projectModel.provision?.driverDefs ?? []) { + if (action.uses === "teamsApp/create") { + teamsAppIdKeyName = action.writeToEnvironmentFile?.teamsAppId || "TEAMS_APP_ID"; + teamsAppId = process.env[teamsAppIdKeyName]; + } else if (action.uses === "botFramework/create") { + botIdKeyName = action.writeToEnvironmentFile?.botId || "BOT_ID"; + botId = process.env[botIdKeyName]; + } else if (action.uses === "teamsApp/extendToM365") { + m365TitleIdKeyName = action.writeToEnvironmentFile?.titleId || "M365_TITLE_ID"; + m365TitleId = process.env[m365TitleIdKeyName]; + } + } + + const uninstallOptions = inputs[QuestionNames.UninstallOptions as string]; + const m356AppOption = uninstallOptions?.includes(QuestionNames.UninstallOptionM365); + const tdpOption = uninstallOptions?.includes(QuestionNames.UninstallOptionTDP); + const botOption = uninstallOptions?.includes(QuestionNames.UninstallOptionBot); + + if ((teamsAppId || m365TitleId) && m356AppOption) { + const res = await this.uninstallM365App(m365TitleId, teamsAppId); + if (res.isErr()) { + return err(res.error); + } + this.resetEnvVar(teamsAppIdKeyName, ctx); + this.resetEnvVar(m365TitleIdKeyName, ctx); + } + if (botId && botOption) { + const res = await this.uninstallBotFrameworRegistration(botId); + if (res.isErr()) { + return err(res.error); + } + this.resetEnvVar(botIdKeyName, ctx); + } + // App registraion should be the last to remove, because we might need to query some metadata from TDP. + if (teamsAppId && tdpOption) { + const res = await this.uninstallAppRegistration(teamsAppId); + if (res.isErr()) { + return err(res.error); + } + this.resetEnvVar(teamsAppIdKeyName, ctx); + } + return ok(undefined); + } + resetEnvVar(key: string, ctx?: CoreHookContext, skipIfNotExist = true, resetValue = ""): void { + if (!ctx) { + return; + } + if (!ctx.envVars) { + ctx.envVars = {}; + } + if (skipIfNotExist && !ctx.envVars[key]) { + return; + } + ctx.envVars[key] = resetValue; + return; + } + /** + * uninstall provisioned resources by title ID. Titlle mode only uninstalls M365 app. + */ + @hooks([ + ErrorContextMW({ component: "FxCore", stage: "uninstallByTitleId", reset: true }), + ErrorHandlerMW, + ]) + async uninstallByTitleId(inputs: UninstallInputs): Promise> { + const titleId = inputs[QuestionNames.TitleId as string] as string; + if (!titleId) { + return err(new MissingRequiredInputError("title-id", "FxCore")); + } + const res = await this.uninstallM365App(titleId); + if (res.isErr()) { + return err(res.error); + } + return ok(undefined); + } + + /** + * uninstall sideloaded appps in M365 + */ + @hooks([ + ErrorContextMW({ component: "FxCore", stage: "uninstallM365App", reset: true }), + ErrorHandlerMW, + ]) + async uninstallM365App( + titleId?: string, + manifestId?: string + ): Promise> { + if (titleId === undefined && manifestId === undefined) { + return err(new MissingRequiredInputError("title id or manifest id", "FxCore")); + } + const sideloadingServiceEndpoint = + process.env.SIDELOADING_SERVICE_ENDPOINT ?? MosServiceEndpoint; + const sideloadingServiceScope = process.env.SIDELOADING_SERVICE_SCOPE ?? MosServiceScope; + const sideloadingTokenRes = await TOOLS.tokenProvider.m365TokenProvider.getAccessToken({ + scopes: [sideloadingServiceScope], + }); + if (sideloadingTokenRes.isErr()) { + return err(sideloadingTokenRes.error); + } + const packageService = new PackageService(sideloadingServiceEndpoint, TOOLS.logProvider); + if (titleId === undefined) { + try { + titleId = await packageService.retrieveTitleId(sideloadingTokenRes.value, manifestId ?? ""); + } catch (err: any) { + await TOOLS.ui.showMessage( + "info", + getLocalizedString("core.uninstall.failed.titleId"), + false + ); + throw assembleError(err); + } + } + const confirmRes = await TOOLS.ui.confirm?.({ + name: "uninstallM365App", + title: getLocalizedString("core.uninstall.confirm.m365App", titleId), + default: true, + }); + if (confirmRes?.isOk() && confirmRes.value.result === true) { + await packageService.unacquire(sideloadingTokenRes.value, titleId); + await TOOLS.ui.showMessage( + "info", + getLocalizedString("core.uninstall.success.m365App", titleId), + false + ); + await TOOLS.ui.showMessage( + "info", + getLocalizedString("core.uninstall.success.delayWarning"), + false + ); + } else { + await TOOLS.ui.showMessage( + "info", + getLocalizedString("core.uninstall.confirm.cancel.m365App"), + false + ); + return err(new UserCancelError("Uninstall M365 App")); + } + return ok(undefined); + } + + /** + * uninstall sideloaded apps in Teams Developer Portal + */ + @hooks([ + ErrorContextMW({ component: "FxCore", stage: "uninstallAppRegistration", reset: true }), + ErrorHandlerMW, + ]) + async uninstallAppRegistration(manifestId: string): Promise> { + const appStudioTokenRes = await TOOLS.tokenProvider.m365TokenProvider.getAccessToken({ + scopes: AppStudioScopes, + }); + if (appStudioTokenRes.isErr()) { + return err(appStudioTokenRes.error); + } + const confirmRes = await TOOLS.ui.confirm?.({ + name: "uninstallAppRegistration", + title: getLocalizedString("core.uninstall.confirm.tdp", manifestId), + default: true, + }); + if (confirmRes?.isOk() && confirmRes.value.result === true) { + const token = appStudioTokenRes.value; + await teamsDevPortalClient.deleteApp(token, manifestId); + await TOOLS.ui.showMessage( + "info", + getLocalizedString("core.uninstall.success.tdp", manifestId), + false + ); + return ok(undefined); + } else { + await TOOLS.ui.showMessage( + "info", + getLocalizedString("core.uninstall.confirm.cancel.tdp"), + false + ); + return err(new UserCancelError("Uninstall App Registration")); + } + } + + /** + * uninstall bots created in dev.botframework.com + */ + @hooks([ + ErrorContextMW({ component: "FxCore", stage: "uninstallBotFrameworRegistration", reset: true }), + ErrorHandlerMW, + ]) + async uninstallBotFrameworRegistration( + botId?: string, + manifestId?: string + ): Promise> { + if (!botId && !manifestId) { + return err(new MissingRequiredInputError("bot id or manifest id", "FxCore")); + } + const appStudioTokenRes = await TOOLS.tokenProvider.m365TokenProvider.getAccessToken({ + scopes: AppStudioScopes, + }); + if (appStudioTokenRes.isErr()) { + return err(appStudioTokenRes.error); + } + const token = appStudioTokenRes.value; + if (!botId) { + const botIdRes = await teamsDevPortalClient.getBotId(token, manifestId!); + if (!botIdRes) { + const msg = getLocalizedString("core.uninstall.botNotFound", manifestId!); + return err(new UserError("FxCore", "Uninstall", msg, msg)); + } + botId = botIdRes; + } + const confirmRes = await TOOLS.ui.confirm?.({ + name: "uninstallBotFrameworRegistration", + title: getLocalizedString("core.uninstall.confirm.bot", botId), + default: true, + }); + if (confirmRes?.isOk() && confirmRes.value.result === true) { + await teamsDevPortalClient.deleteBot(token, botId); + await TOOLS.ui.showMessage( + "info", + getLocalizedString("core.uninstall.success.bot", botId), + false + ); + } else { + await TOOLS.ui.showMessage( + "info", + getLocalizedString("core.uninstall.confirm.cancel.bot"), + false + ); + return err(new UserCancelError("Uninstall Bot Framework Registration")); + } + return ok(undefined); + } + /** * lifecycle commands: deploy */ diff --git a/packages/fx-core/src/question/constants.ts b/packages/fx-core/src/question/constants.ts index 030de0725a..cc20131a24 100644 --- a/packages/fx-core/src/question/constants.ts +++ b/packages/fx-core/src/question/constants.ts @@ -77,9 +77,19 @@ export enum QuestionNames { M365Host = "m365-host", ManifestPath = "manifest-path", - + ManifestId = "manifest-id", + TitleId = "title-id", UserEmail = "email", + UninstallMode = "mode", + UninstallModeManifestId = "manifest-id", + UninstallModeEnv = "env", + UninstallModeTitleId = "title-id", + UninstallOptions = "options", + UninstallOptionM365 = "m365-app", + UninstallOptionTDP = "app-registration", + UninstallOptionBot = "bot-framework-registration", + collaborationAppType = "collaborationType", DestinationApiSpecFilePath = "destination-api-spec-location", PluginAvailability = "plugin-availability", diff --git a/packages/fx-core/src/question/generator.ts b/packages/fx-core/src/question/generator.ts index aa89e9719f..52dab57560 100644 --- a/packages/fx-core/src/question/generator.ts +++ b/packages/fx-core/src/question/generator.ts @@ -454,6 +454,9 @@ async function batchGenerate() { await generateCliOptions(questionNodes.addPlugin(), "AddPlugin"); await generateInputs(questionNodes.addPlugin(), "AddPlugin"); + + await generateCliOptions(questionNodes.uninstall(), "Uninstall"); + await generateInputs(questionNodes.uninstall(), "Uninstall"); } void batchGenerate(); diff --git a/packages/fx-core/src/question/index.ts b/packages/fx-core/src/question/index.ts index 0d31eb62bb..7765861f18 100644 --- a/packages/fx-core/src/question/index.ts +++ b/packages/fx-core/src/question/index.ts @@ -19,6 +19,7 @@ import { oauthQuestion, previewWithTeamsAppManifestQuestionNode, selectTeamsAppManifestQuestionNode, + uninstallQuestionNode, validateTeamsAppQuestionNode, } from "./other"; export * from "./constants"; @@ -72,6 +73,9 @@ export class QuestionNodes { addPlugin(): IQTreeNode { return addPluginQuestionNode(); } + uninstall(): IQTreeNode { + return uninstallQuestionNode(); + } } export const questionNodes = new QuestionNodes(); diff --git a/packages/fx-core/src/question/inputs/UninstallInputs.ts b/packages/fx-core/src/question/inputs/UninstallInputs.ts new file mode 100644 index 0000000000..f2e92fb2d3 --- /dev/null +++ b/packages/fx-core/src/question/inputs/UninstallInputs.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/**************************************************************************************** + * NOTICE: AUTO-GENERATED * + **************************************************************************************** + * This file is automatically generated by script "./src/question/generator.ts". * + * Please don't manually change its contents, as any modifications will be overwritten! * + ***************************************************************************************/ + +import { Inputs } from "@microsoft/teamsfx-api"; + +export interface UninstallInputs extends Inputs { + /** @description Choose a way to clean up resources */ + mode?: "manifest-id" | "env" | "title-id"; + /** @description Manifest ID */ + "manifest-id"?: string; + /** @description Environment */ + env?: string; + /** @description Project path */ + projectPath?: string; + /** @description Choose resources to uninstall */ + options?: "m365-app" | "app-registration" | "bot-framework-registration"[]; + /** @description Title ID */ + "title-id"?: string; +} diff --git a/packages/fx-core/src/question/inputs/index.ts b/packages/fx-core/src/question/inputs/index.ts index f62876795d..767423e260 100644 --- a/packages/fx-core/src/question/inputs/index.ts +++ b/packages/fx-core/src/question/inputs/index.ts @@ -11,3 +11,4 @@ export * from "./PermissionGrantInputs"; export * from "./PermissionListInputs"; export * from "./DeployAadManifestInputs"; export * from "./AddPluginInputs"; +export * from "./UninstallInputs"; diff --git a/packages/fx-core/src/question/options/UninstallOptions.ts b/packages/fx-core/src/question/options/UninstallOptions.ts new file mode 100644 index 0000000000..5b5c0fc167 --- /dev/null +++ b/packages/fx-core/src/question/options/UninstallOptions.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/**************************************************************************************** + * NOTICE: AUTO-GENERATED * + **************************************************************************************** + * This file is automatically generated by script "./src/question/generator.ts". * + * Please don't manually change its contents, as any modifications will be overwritten! * + ***************************************************************************************/ + +import { CLICommandOption, CLICommandArgument } from "@microsoft/teamsfx-api"; + +export const UninstallOptions: CLICommandOption[] = [ + { + name: "mode", + type: "string", + description: "Choose a way to clean up resources", + required: true, + default: "manifest-id", + choices: ["manifest-id", "env", "title-id"], + }, + { + name: "manifest-id", + type: "string", + description: "Manifest ID", + }, + { + name: "env", + type: "string", + description: "Environment", + }, + { + name: "projectPath", + type: "string", + description: "Project Path for uninstall", + default: "./", + }, + { + name: "options", + type: "array", + description: "Choose resources to uninstall", + choices: ["m365-app", "app-registration", "bot-framework-registration"], + }, + { + name: "title-id", + type: "string", + description: "Title ID", + }, +]; +export const UninstallArguments: CLICommandArgument[] = []; diff --git a/packages/fx-core/src/question/options/index.ts b/packages/fx-core/src/question/options/index.ts index 227db9cf16..07ebb6e24c 100644 --- a/packages/fx-core/src/question/options/index.ts +++ b/packages/fx-core/src/question/options/index.ts @@ -11,3 +11,4 @@ export * from "./PermissionGrantOptions"; export * from "./PermissionListOptions"; export * from "./DeployAadManifestOptions"; export * from "./AddPluginOptions"; +export * from "./UninstallOptions"; diff --git a/packages/fx-core/src/question/other.ts b/packages/fx-core/src/question/other.ts index a8802efbe5..15ee3ce0fa 100644 --- a/packages/fx-core/src/question/other.ts +++ b/packages/fx-core/src/question/other.ts @@ -14,6 +14,7 @@ import { SingleFileQuestion, SingleSelectQuestion, TextInputQuestion, + FolderQuestion, } from "@microsoft/teamsfx-api"; import fs from "fs-extra"; import * as path from "path"; @@ -42,6 +43,7 @@ import { apiOperationQuestion, apiSpecLocationQuestion, } from "./create"; +import { UninstallInputs } from "./inputs"; export function listCollaboratorQuestionNode(): IQTreeNode { const selectTeamsAppNode = selectTeamsAppManifestQuestionNode(); @@ -874,6 +876,129 @@ export function oauthQuestion(): IQTreeNode { }; } +export function uninstallQuestionNode(): IQTreeNode { + return { + data: { + type: "group", + }, + children: [ + { + data: uninstallModeQuestion(), + condition: () => { + return true; + }, + children: [ + { + data: { + type: "text", + name: QuestionNames.ManifestId, + title: getLocalizedString("core.uninstallQuestion.manifestId"), + }, + condition: (input: UninstallInputs) => { + return input[QuestionNames.UninstallMode] === QuestionNames.UninstallModeManifestId; + }, + }, + { + data: { + type: "text", + name: QuestionNames.Env, + title: getLocalizedString("core.uninstallQuestion.env"), + }, + condition: (input: UninstallInputs) => { + return input[QuestionNames.UninstallMode] === QuestionNames.UninstallModeEnv; + }, + children: [ + { + data: uninstallProjectPathQuestion(), + condition: () => { + return true; + }, + }, + ], + }, + { + data: uninstallOptionQuestion(), + condition: (input: UninstallInputs) => { + return ( + input[QuestionNames.UninstallMode] === QuestionNames.UninstallModeManifestId || + input[QuestionNames.UninstallMode] === QuestionNames.UninstallModeEnv + ); + }, + }, + { + data: { + type: "text", + name: QuestionNames.TitleId, + title: getLocalizedString("core.uninstallQuestion.titleId"), + }, + condition: (input: UninstallInputs) => { + return input[QuestionNames.UninstallMode] === QuestionNames.UninstallModeTitleId; + }, + }, + ], + }, + ], + }; +} + +function uninstallModeQuestion(): SingleSelectQuestion { + return { + name: QuestionNames.UninstallMode, + title: getLocalizedString("core.uninstallQuestion.chooseMode"), + type: "singleSelect", + staticOptions: [ + { + id: QuestionNames.UninstallModeManifestId, + label: getLocalizedString("core.uninstallQuestion.manifestIdMode"), + detail: getLocalizedString("core.uninstallQuestion.manifestIdMode.detail"), + }, + { + id: QuestionNames.UninstallModeEnv, + label: getLocalizedString("core.uninstallQuestion.envMode"), + detail: getLocalizedString("core.uninstallQuestion.envMode.detail"), + }, + { + id: QuestionNames.UninstallModeTitleId, + label: getLocalizedString("core.uninstallQuestion.titleIdMode"), + detail: getLocalizedString("core.uninstallQuestion.titleIdMode.detail"), + }, + ], + default: QuestionNames.UninstallModeManifestId, + }; +} + +function uninstallOptionQuestion(): MultiSelectQuestion { + return { + name: QuestionNames.UninstallOptions, + title: getLocalizedString("core.uninstallQuestion.chooseOption"), + type: "multiSelect", + staticOptions: [ + { + id: QuestionNames.UninstallOptionM365, + label: getLocalizedString("core.uninstallQuestion.m365Option"), + }, + { + id: QuestionNames.UninstallOptionTDP, + label: getLocalizedString("core.uninstallQuestion.tdpOption"), + }, + { + id: QuestionNames.UninstallOptionBot, + label: getLocalizedString("core.uninstallQuestion.botOption"), + }, + ], + }; +} +function uninstallProjectPathQuestion(): FolderQuestion { + return { + type: "folder", + name: QuestionNames.ProjectPath, + title: getLocalizedString("core.uninstallQuestion.projectPath"), + cliDescription: "Project Path for uninstall", + placeholder: "./", + default: "./", + }; +} + function oauthClientIdQuestion(): TextInputQuestion { return { type: "text", diff --git a/packages/fx-core/tests/client/tdpClient.test.ts b/packages/fx-core/tests/client/tdpClient.test.ts index a94edc4d6c..e75217b24a 100644 --- a/packages/fx-core/tests/client/tdpClient.test.ts +++ b/packages/fx-core/tests/client/tdpClient.test.ts @@ -1940,4 +1940,53 @@ describe("TeamsDevPortalClient Test", () => { chai.assert.isUndefined(res); }); }); + describe("getBotId", () => { + afterEach(() => { + sandbox.restore(); + }); + it("happy", async () => { + sandbox.stub(teamsDevPortalClient, "getApp").resolves({ + bots: [ + { + botId: "mocked-bot-id", + needsChannelSelector: false, + isNotificationOnly: false, + supportsFiles: false, + supportsCalling: false, + supportsVideo: false, + scopes: [], + teamCommands: [], + personalCommands: [], + groupChatCommands: [], + }, + ], + }); + try { + const res = await teamsDevPortalClient.getBotId("token", "anything"); + chai.assert.equal(res, "mocked-bot-id"); + } catch (e) { + chai.assert.fail(Messages.ShouldNotReachHere); + } + }); + it("empty bots", async () => { + sandbox.stub(teamsDevPortalClient, "getApp").resolves({ + bots: [], + }); + try { + const res = await teamsDevPortalClient.getBotId("token", "anything"); + chai.assert.isUndefined(res); + } catch (e) { + chai.assert.fail(Messages.ShouldNotReachHere); + } + }); + it("no bots", async () => { + sandbox.stub(teamsDevPortalClient, "getApp").resolves({}); + try { + const res = await teamsDevPortalClient.getBotId("token", "anything"); + chai.assert.isUndefined(res); + } catch (e) { + chai.assert.fail(Messages.ShouldNotReachHere); + } + }); + }); }); diff --git a/packages/fx-core/tests/core/FxCore.test.ts b/packages/fx-core/tests/core/FxCore.test.ts index b389cb4164..42c575d875 100644 --- a/packages/fx-core/tests/core/FxCore.test.ts +++ b/packages/fx-core/tests/core/FxCore.test.ts @@ -13,6 +13,7 @@ import { DeclarativeCopilotManifestSchema, FxError, IQTreeNode, + InputResult, Inputs, LogProvider, Ok, @@ -25,7 +26,7 @@ import { err, ok, } from "@microsoft/teamsfx-api"; -import { assert } from "chai"; +import { assert, expect } from "chai"; import fs from "fs-extra"; import jsyaml from "js-yaml"; import "mocha"; @@ -33,7 +34,7 @@ import mockedEnv, { RestoreFn } from "mocked-env"; import * as os from "os"; import * as path from "path"; import sinon from "sinon"; -import { FxCore, getUuid } from "../../src"; +import { FxCore, PackageService, getUuid, teamsDevPortalClient } from "../../src"; import { FeatureFlagName } from "../../src/common/featureFlags"; import { LaunchHelper } from "../../src/component/m365/launchHelper"; import { @@ -48,6 +49,7 @@ import { ILifecycle, LifecycleName, Output, + ProjectModel, UnresolvedPlaceholders, } from "../../src/component/configManager/interface"; import { YamlParser } from "../../src/component/configManager/parser"; @@ -93,6 +95,8 @@ import { import { HubOptions, PluginAvailabilityOptions } from "../../src/question/constants"; import { validationUtils } from "../../src/ui/validationUtils"; import { MockTools, randomAppName } from "./utils"; +import { UninstallInputs } from "../../build"; +import { CoreHookContext } from "../../src/core/types"; import * as projectHelper from "../../src/common/projectSettingsHelper"; import * as migrationUtil from "../../src/core/middleware/utils/v3MigrationUtils"; import * as projMigrator from "../../src/core/middleware/projectMigratorV3"; @@ -618,6 +622,500 @@ describe("Core basic APIs", () => { restore(); } }); + it("uninstall with empty input", async () => { + const core = new FxCore(tools); + const inputs: UninstallInputs = { + platform: Platform.CLI, + }; + const res = await core.uninstall(inputs); + assert.isTrue(res.isErr()); + }); + it("uninstall with invalid mode", async () => { + const core = new FxCore(tools); + const inputs = { + platform: Platform.CLI, + mode: "invalid", + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isErr()); + }); + it("uninstall by manifest ID - success", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(ok("mocked-token")); + sandbox.stub(teamsDevPortalClient, "deleteApp").resolves(true); + sandbox.stub(teamsDevPortalClient, "getBotId").resolves("mocked-bot-id"); + sandbox.stub(teamsDevPortalClient, "deleteBot").resolves(); + sandbox.stub(PackageService.prototype, "retrieveTitleId").resolves("mocked-title-id"); + sandbox.stub(PackageService.prototype, "unacquire").resolves(); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + [QuestionNames.ManifestId]: "valid-manifest-id", + [QuestionNames.UninstallOptions]: [ + "m365-app", + "app-registration", + "bot-framework-registration", + ], + nonInteractive: true, + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isOk()); + }); + it("uninstall by manifest ID - missing manifest ID", async () => { + const core = new FxCore(tools); + const inputs: UninstallInputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + nonInteractive: true, + }; + const res = await core.uninstall(inputs); + assert.isTrue(res.isErr()); + }); + it("uninstall by manifest ID - empty options", async () => { + const core = new FxCore(tools); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + [QuestionNames.ManifestId]: "valid-manifest-id", + nonInteractive: true, + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isOk()); + }); + it("uninstall by manifest ID - failed to get token", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(err(new SystemError("mockedSource", "mockedError", "mockedMessage"))); + const inputs1 = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + [QuestionNames.ManifestId]: "valid-manifest-id", + [QuestionNames.UninstallOptions]: ["m365-app"], + nonInteractive: true, + }; + const res1 = await core.uninstall(inputs1 as UninstallInputs); + assert.isTrue(res1.isErr()); + + const inputs2 = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + [QuestionNames.ManifestId]: "valid-manifest-id", + [QuestionNames.UninstallOptions]: ["app-registration"], + nonInteractive: true, + }; + const res2 = await core.uninstall(inputs2 as UninstallInputs); + assert.isTrue(res2.isErr()); + + const inputs3 = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + [QuestionNames.ManifestId]: "valid-manifest-id", + [QuestionNames.UninstallOptions]: ["bot-framework-registration"], + nonInteractive: true, + }; + const res3 = await core.uninstall(inputs3 as UninstallInputs); + assert.isTrue(res3.isErr()); + }); + it("uninstall by manifest ID - failed to get title ID", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(ok("mocked-token")); + sandbox.stub(PackageService.prototype, "retrieveTitleId").throws("error"); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + [QuestionNames.ManifestId]: "valid-manifest-id", + [QuestionNames.UninstallOptions]: [ + "m365-app", + "app-registration", + "bot-framework-registration", + ], + nonInteractive: true, + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isErr()); + }); + it("uninstall by manifest ID - failed to get bot ID", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(ok("mocked-token")); + sandbox.stub(teamsDevPortalClient, "getBotId").resolves(undefined); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + [QuestionNames.ManifestId]: "valid-manifest-id", + [QuestionNames.UninstallOptions]: ["bot-framework-registration"], + nonInteractive: true, + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isErr()); + }); + it("uninstall by manifest ID - M365 App user cancel", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(ok("mocked-token")); + sandbox.stub(tools.ui, "confirm").resolves(ok({ result: false } as InputResult)); + sandbox.stub(teamsDevPortalClient, "deleteApp").throws("error"); + sandbox.stub(teamsDevPortalClient, "getBotId").resolves("mocked-bot-id"); + sandbox.stub(teamsDevPortalClient, "deleteBot").resolves(); + sandbox.stub(PackageService.prototype, "retrieveTitleId").resolves("mocked-title-id"); + sandbox.stub(PackageService.prototype, "unacquire").throws("error"); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + [QuestionNames.ManifestId]: "valid-manifest-id", + [QuestionNames.UninstallOptions]: ["m365-app"], + nonInteractive: true, + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isErr()); + if (res.isErr()) { + assert.isTrue(res.error instanceof UserCancelError); + } + }); + it("uninstall by manifest ID - TDP user cancel", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(ok("mocked-token")); + sandbox.stub(tools.ui, "confirm").resolves(ok({ result: false } as InputResult)); + sandbox.stub(teamsDevPortalClient, "deleteApp").throws("error"); + sandbox.stub(teamsDevPortalClient, "getBotId").resolves("mocked-bot-id"); + sandbox.stub(teamsDevPortalClient, "deleteBot").resolves(); + sandbox.stub(PackageService.prototype, "retrieveTitleId").resolves("mocked-title-id"); + sandbox.stub(PackageService.prototype, "unacquire").throws("error"); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + [QuestionNames.ManifestId]: "valid-manifest-id", + [QuestionNames.UninstallOptions]: ["app-registration"], + nonInteractive: true, + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isErr()); + if (res.isErr()) { + assert.isTrue(res.error instanceof UserCancelError); + } + }); + it("uninstall by manifest ID - Bot user cancel", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(ok("mocked-token")); + sandbox.stub(tools.ui, "confirm").resolves(ok({ result: false } as InputResult)); + sandbox.stub(teamsDevPortalClient, "deleteApp").throws("error"); + sandbox.stub(teamsDevPortalClient, "getBotId").resolves("mocked-bot-id"); + sandbox.stub(teamsDevPortalClient, "deleteBot").resolves(); + sandbox.stub(PackageService.prototype, "retrieveTitleId").resolves("mocked-title-id"); + sandbox.stub(PackageService.prototype, "unacquire").throws("error"); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeManifestId, + [QuestionNames.ManifestId]: "valid-manifest-id", + [QuestionNames.UninstallOptions]: ["bot-framework-registration"], + nonInteractive: true, + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isErr()); + if (res.isErr()) { + assert.isTrue(res.error instanceof UserCancelError); + } + }); + it("uninstall by env - success", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(ok("mocked-token")); + sandbox.stub(teamsDevPortalClient, "deleteApp").resolves(true); + sandbox.stub(teamsDevPortalClient, "getBotId").resolves("mocked-bot-id"); + sandbox.stub(teamsDevPortalClient, "deleteBot").resolves(); + sandbox.stub(PackageService.prototype, "retrieveTitleId").resolves("mocked-title-id"); + sandbox.stub(PackageService.prototype, "unacquire").resolves(); + const appName = await mockCliUninstallProject(); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeEnv, + projectPath: path.join(os.tmpdir(), appName), + env: "dev", + [QuestionNames.UninstallOptions]: [ + "m365-app", + "app-registration", + "bot-framework-registration", + ], + nonInteractive: true, + }; + + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isOk()); + + const envRes = await envUtil.readEnv(path.join(os.tmpdir(), appName), "dev", false); + assert.isTrue(envRes.isOk()); + if (envRes.isOk()) { + const envVars = envRes.value; + assert.isTrue(envVars["TEAMS_APP_ID"] === ""); + assert.isTrue(envVars["M365_TITLE_ID"] === ""); + assert.isTrue(envVars["BOT_ID"] === ""); + } + await deleteTestProject(appName); + }); + it("uninstall by env - missing env", async () => { + const core = new FxCore(tools); + const appName = await mockCliUninstallProject(); + + const inputs: UninstallInputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeEnv, + projectPath: path.join(os.tmpdir(), appName), + nonInteractive: true, + }; + + const res = await core.uninstall(inputs); + assert.isTrue(res.isErr()); + await deleteTestProject(appName); + }); + it("uninstall by env - empty options", async () => { + const core = new FxCore(tools); + const appName = await mockCliUninstallProject(); + + const inputs: UninstallInputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeEnv, + projectPath: path.join(os.tmpdir(), appName), + nonInteractive: true, + env: "dev", + }; + + const res = await core.uninstall(inputs); + assert.isTrue(res.isOk()); + await deleteTestProject(appName); + }); + it("uninstall by env - invalid yaml", async () => { + const core = new FxCore(tools); + const appName = await mockCliUninstallProject(); + sandbox.stub(metadataUtil, "parse").resolves(err(new SystemError("", "", ""))); + const inputs: UninstallInputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeEnv, + projectPath: path.join(os.tmpdir(), appName), + nonInteractive: true, + env: "dev", + }; + const res = await core.uninstall(inputs); + assert.isTrue(res.isErr()); + await deleteTestProject(appName); + }); + it("uninstall by env - empty provision actions", async () => { + const core = new FxCore(tools); + const appName = await mockCliUninstallProject(); + sandbox.stub(metadataUtil, "parse").resolves(ok({} as ProjectModel)); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(err(new SystemError("mockedSource", "mockedError", "mockedMessage"))); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeEnv, + projectPath: path.join(os.tmpdir(), appName), + nonInteractive: true, + env: "dev", + [QuestionNames.UninstallOptions]: [ + "m365-app", + "app-registration", + "bot-framework-registration", + ], + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isOk()); + await deleteTestProject(appName); + }); + it("uninstall by env - empty env key name", async () => { + const core = new FxCore(tools); + sandbox.stub(metadataUtil, "parse").resolves( + ok({ + provision: { + name: "provision", + driverDefs: [ + { + uses: "teamsApp/create", + }, + { + uses: "botFramework/create", + }, + { + uses: "teamsApp/extendToM365", + }, + ], + }, + } as ProjectModel) + ); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(ok("mocked-token")); + sandbox.stub(teamsDevPortalClient, "deleteApp").resolves(true); + sandbox.stub(teamsDevPortalClient, "getBotId").resolves("mocked-bot-id"); + sandbox.stub(teamsDevPortalClient, "deleteBot").resolves(); + sandbox.stub(PackageService.prototype, "retrieveTitleId").resolves("mocked-title-id"); + sandbox.stub(PackageService.prototype, "unacquire").resolves(); + const appName = await mockCliUninstallProject(); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeEnv, + projectPath: path.join(os.tmpdir(), appName), + env: "dev", + [QuestionNames.UninstallOptions]: [ + "m365-app", + "app-registration", + "bot-framework-registration", + ], + nonInteractive: true, + }; + + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isOk()); + + const envRes = await envUtil.readEnv(path.join(os.tmpdir(), appName), "dev", false); + assert.isTrue(envRes.isOk()); + if (envRes.isOk()) { + const envVars = envRes.value; + assert.isTrue(envVars["TEAMS_APP_ID"] === ""); + assert.isTrue(envVars["M365_TITLE_ID"] === ""); + assert.isTrue(envVars["BOT_ID"] === ""); + } + await deleteTestProject(appName); + }); + it("uninstall by env - failed to get token", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(err(new SystemError("mockedSource", "mockedError", "mockedMessage"))); + sandbox.stub(teamsDevPortalClient, "deleteApp").resolves(true); + sandbox.stub(teamsDevPortalClient, "getBotId").resolves("mocked-bot-id"); + sandbox.stub(teamsDevPortalClient, "deleteBot").resolves(); + sandbox.stub(PackageService.prototype, "retrieveTitleId").resolves("mocked-title-id"); + sandbox.stub(PackageService.prototype, "unacquire").resolves(); + const appName = await mockCliUninstallProject(); + const inputs1 = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeEnv, + projectPath: path.join(os.tmpdir(), appName), + env: "dev", + [QuestionNames.UninstallOptions]: ["m365-app"], + nonInteractive: true, + }; + + const res1 = await core.uninstall(inputs1 as UninstallInputs); + assert.isTrue(res1.isErr()); + + const inputs2 = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeEnv, + projectPath: path.join(os.tmpdir(), appName), + env: "dev", + [QuestionNames.UninstallOptions]: ["app-registration"], + nonInteractive: true, + }; + + const res2 = await core.uninstall(inputs2 as UninstallInputs); + assert.isTrue(res2.isErr()); + + const inputs3 = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeEnv, + projectPath: path.join(os.tmpdir(), appName), + env: "dev", + [QuestionNames.UninstallOptions]: ["bot-framework-registration"], + nonInteractive: true, + }; + + const res3 = await core.uninstall(inputs3 as UninstallInputs); + assert.isTrue(res3.isErr()); + }); + it("uninstall by title ID - success", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(ok("mocked-token")); + sandbox.stub(PackageService.prototype, "unacquire").resolves(); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeTitleId, + [QuestionNames.TitleId]: "mocked-title-id", + nonInteractive: true, + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isOk()); + }); + it("uninstall by title ID - missing title ID", async () => { + const core = new FxCore(tools); + sandbox + .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") + .resolves(ok("mocked-token")); + sandbox.stub(PackageService.prototype, "unacquire").resolves(); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeTitleId, + nonInteractive: true, + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isErr()); + }); + it("uninstall by title ID - failed", async () => { + const core = new FxCore(tools); + sandbox.stub(core, "uninstallM365App").resolves(err(new SystemError("", "", ""))); + const inputs = { + platform: Platform.CLI, + [QuestionNames.UninstallMode]: QuestionNames.UninstallModeTitleId, + nonInteractive: true, + [QuestionNames.TitleId]: "mocked-title-id", + }; + const res = await core.uninstall(inputs as UninstallInputs); + assert.isTrue(res.isErr()); + }); + it("uninstall M365 App - invalid input", async () => { + const core = new FxCore(tools); + const res = await core.uninstallM365App(undefined, undefined); + assert.isTrue(res.isErr()); + }); + it("uninstall Bot Framework Registration - invalid input", async () => { + const core = new FxCore(tools); + const res = await core.uninstallBotFrameworRegistration(undefined, undefined); + assert.isTrue(res.isErr()); + }); + it("reset env var - happy path", async () => { + const core = new FxCore(tools); + const ctx: CoreHookContext = { arguments: [], envVars: { testKey: "oldValue" } }; + core.resetEnvVar("testKey", ctx); + expect(ctx.envVars).to.deep.equal({ testKey: "" }); + }); + it("reset env var - undefine ctx", async () => { + const core = new FxCore(tools); + const ctx: CoreHookContext | undefined = undefined; + core.resetEnvVar("testKey", ctx); + assert.isUndefined(ctx); + }); + it("reset env var - initialize envVars if it is undefined", async () => { + const core = new FxCore(tools); + const ctx: CoreHookContext = { arguments: [], envVars: undefined }; + core.resetEnvVar("testKey", ctx, false); + expect(ctx.envVars).to.deep.equal({ testKey: "" }); + }); + it("reset env var - skipIfNotExist is true", async () => { + const core = new FxCore(tools); + const ctx: CoreHookContext = { arguments: [], envVars: { existingKey: "value" } }; + core.resetEnvVar("testKey", ctx); + expect(ctx.envVars).to.deep.equal({ existingKey: "value" }); + }); + it("reset env var - skipIfNotExist is false", async () => { + const core = new FxCore(tools); + const ctx: CoreHookContext = { arguments: [], envVars: { existingKey: "value" } }; + core.resetEnvVar("testKey", ctx, false); + expect(ctx.envVars).to.deep.equal({ existingKey: "value", testKey: "" }); + }); }); describe("apply yaml template", async () => { @@ -895,6 +1393,13 @@ async function mockV2Project(): Promise { return appName; } +async function mockCliUninstallProject(): Promise { + const appName = randomAppName(); + const projectPath = path.join(os.tmpdir(), appName); + await fs.copy(path.join(__dirname, "../samples/uninstall/"), path.join(projectPath)); + return appName; +} + async function deleteTestProject(appName: string) { await fs.remove(path.join(os.tmpdir(), appName)); } diff --git a/packages/fx-core/tests/samples/uninstall/env/.env.dev b/packages/fx-core/tests/samples/uninstall/env/.env.dev new file mode 100644 index 0000000000..8e56da549c --- /dev/null +++ b/packages/fx-core/tests/samples/uninstall/env/.env.dev @@ -0,0 +1,17 @@ +# This file includes environment variables that will be committed to git by default. + +# Built-in environment variables +TEAMSFX_ENV=dev +APP_NAME_SUFFIX=dev + +# Updating AZURE_SUBSCRIPTION_ID or AZURE_RESOURCE_GROUP_NAME after provision may also require an update to RESOURCE_SUFFIX, because some services require a globally unique name across subscriptions/resource groups. +AZURE_SUBSCRIPTION_ID= +AZURE_RESOURCE_GROUP_NAME= +RESOURCE_SUFFIX= + +# Generated during provision, you can also add your own variables. +TEAMS_APP_ID=123 +M365_TITLE_ID=456 +BOT_ID=789 +TAB_AZURE_STORAGE_RESOURCE_ID= +TAB_ENDPOINT= diff --git a/packages/fx-core/tests/samples/uninstall/teamsapp.yml b/packages/fx-core/tests/samples/uninstall/teamsapp.yml new file mode 100644 index 0000000000..3a4f3f84f0 --- /dev/null +++ b/packages/fx-core/tests/samples/uninstall/teamsapp.yml @@ -0,0 +1,150 @@ +# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.5/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.5 + +environmentFolderPath: ./env + +# Triggered when 'teamsapp provision' is executed +provision: + # Creates a Teams app + - uses: teamsApp/create + with: + # Teams app name + name: ut-test${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + - uses: arm/deploy # Deploy given ARM templates parallelly. + with: + # AZURE_SUBSCRIPTION_ID is a built-in environment variable, + # if its value is empty, TeamsFx will prompt you to select a subscription. + # Referencing other environment variables with empty values + # will skip the subscription selection prompt. + subscriptionId: ${{AZURE_SUBSCRIPTION_ID}} + # AZURE_RESOURCE_GROUP_NAME is a built-in environment variable, + # if its value is empty, TeamsFx will prompt you to select or create one + # resource group. + # Referencing other environment variables with empty values + # will skip the resource group selection prompt. + resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}} + templates: + - path: ./infra/azure.bicep # Relative path to this file + # Relative path to this yaml file. + # Placeholders will be replaced with corresponding environment + # variable before ARM deployment. + parameters: ./infra/azure.parameters.json + # Required when deploying ARM template + deploymentName: Create-resources-for-tab + # Teams Toolkit will download this bicep CLI version from github for you, + # will use bicep CLI in PATH if you remove this config. + bicepCliVersion: v0.9.1 + + # Validate using manifest schema + - uses: teamsApp/validateManifest + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + # Build Teams app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputJsonPath: ./appPackage/build/manifest.${{TEAMSFX_ENV}}.json + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Apply the Teams app manifest to an existing Teams app in + # Teams Developer Portal. + # Will use the app id in manifest file to determine which Teams app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Extend your Teams app to Outlook and the Microsoft 365 app + - uses: teamsApp/extendToM365 + with: + # Relative path to the build app package. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + titleId: M365_TITLE_ID + appId: M365_APP_ID + # Create or update the bot registration on dev.botframework.com + - uses: botFramework/create + with: + botId: ${{BOT_ID}} + name: ut-test + messagingEndpoint: ${{BOT_ENDPOINT}}/api/messages + description: "" + channels: + - name: msteams + +# Triggered when 'teamsapp deploy' is executed +deploy: + # Run npm command + - uses: cli/runNpmCommand + name: install dependencies + with: + args: install + - uses: cli/runNpmCommand + name: build app + with: + args: run build --if-present + # Deploy your application to Azure App Service using the zip deploy feature. + # For additional details, refer to https://aka.ms/zip-deploy-to-app-services. + - uses: azureAppService/zipDeploy + with: + # Deploy base folder + artifactFolder: . + # Ignore file location, leave blank will ignore nothing + ignoreFile: .webappignore + # The resource id of the cloud resource to be deployed to. + # This key will be generated by arm/deploy action automatically. + # You can replace it with your existing Azure Resource id + # or add it to your environment variable file. + resourceId: ${{TAB_AZURE_APP_SERVICE_RESOURCE_ID}} + +# Triggered when 'teamsapp publish' is executed +publish: + # Validate using manifest schema + - uses: teamsApp/validateManifest + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + # Build Teams app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputJsonPath: ./appPackage/build/manifest.${{TEAMSFX_ENV}}.json + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Apply the Teams app manifest to an existing Teams app in + # Teams Developer Portal. + # Will use the app id in manifest file to determine which Teams app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Publish the app to + # Teams Admin Center (https://admin.teams.microsoft.com/policies/manage-apps) + # for review and approval + - uses: teamsApp/publishAppPackage + with: + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + publishedAppId: TEAMS_APP_PUBLISHED_APP_ID +projectId: 3daab8cd-e801-4280-829a-a07cb10fe329