diff --git a/src/client/test/integration/authEnvironment.test.ts b/src/client/test/integration/authEnvironment.test.ts new file mode 100644 index 000000000..78f327d36 --- /dev/null +++ b/src/client/test/integration/authEnvironment.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; +import { AuthEnvironmentService } from "../../uriHandler/utils/authEnvironment"; +import { UriParameters } from "../../uriHandler/utils/uriHandlerUtils"; +import { PacWrapper } from "../../pac/PacWrapper"; + +type ProgressReporter = vscode.Progress<{ message?: string; increment?: number }>; +type ProgressTask = (progress: ProgressReporter, token: vscode.CancellationToken) => Thenable; + +// Minimal stubbed surface of PacWrapper that the service depends on. +interface PacWrapperStub { + activeOrg: sinon.SinonStub; + orgSelect: sinon.SinonStub; + authCreateNewAuthProfileForOrg: sinon.SinonStub; + resetPacProcess: sinon.SinonStub; +} + +describe("AuthEnvironmentService", () => { + let sandbox: sinon.SinonSandbox; + let pacWrapperStub: PacWrapperStub; + let service: AuthEnvironmentService; + let warningStub: sinon.SinonStub; + + const uriParams = { + environmentId: "env-1", + orgUrl: "https://org.crm.dynamics.com/" + } as unknown as UriParameters; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + pacWrapperStub = { + activeOrg: sandbox.stub(), + orgSelect: sandbox.stub().resolves(), + authCreateNewAuthProfileForOrg: sandbox.stub().resolves(), + resetPacProcess: sandbox.stub().resolves() + }; + + // Run the progress task immediately with a no-op reporter. + sandbox.stub(vscode.window, "withProgress").callsFake( + ((_options: vscode.ProgressOptions, task: ProgressTask): Thenable => + task({ report: () => undefined }, new vscode.CancellationTokenSource().token) + ) as unknown as typeof vscode.window.withProgress + ); + + warningStub = sandbox.stub(vscode.window, "showWarningMessage"); + sandbox.stub(vscode.window, "showInformationMessage"); + + service = new AuthEnvironmentService(pacWrapperStub as unknown as PacWrapper); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("does not prompt when already authenticated to the requested environment", async () => { + pacWrapperStub.activeOrg.resolves({ + Status: "Success", + Results: { EnvironmentId: "env-1" } + }); + + await service.prepareAuthenticationAndEnvironment(uriParams, {}); + + expect(warningStub.called).to.be.false; + expect(pacWrapperStub.orgSelect.called).to.be.false; + expect(pacWrapperStub.authCreateNewAuthProfileForOrg.called).to.be.false; + }); + + it("switches environment when the active org points at a different environment", async () => { + pacWrapperStub.activeOrg + .onFirstCall().resolves({ Status: "Success", Results: { EnvironmentId: "env-1" } }) + .onSecondCall().resolves({ Status: "Success", Results: { EnvironmentId: "other-env" } }) + .onThirdCall().resolves({ Status: "Success", Results: { EnvironmentId: "env-1" } }); + warningStub.resolves("Yes"); + + await service.prepareAuthenticationAndEnvironment(uriParams, {}); + + expect(pacWrapperStub.orgSelect.calledOnceWith("https://org.crm.dynamics.com/")).to.be.true; + }); + + it("resetPacProcessSafely swallows reset errors", async () => { + pacWrapperStub.resetPacProcess.rejects(new Error("reset boom")); + + await service.resetPacProcessSafely({}); + + expect(pacWrapperStub.resetPacProcess.calledOnce).to.be.true; + }); + + it("resetPacProcessAndThrow resets and rethrows the original error", async () => { + const original = new Error("boom"); + let thrown: unknown; + + try { + await service.resetPacProcessAndThrow(original, {}, "message", "error_type"); + } catch (error) { + thrown = error; + } + + expect(thrown).to.equal(original); + expect(pacWrapperStub.resetPacProcess.calledOnce).to.be.true; + }); +}); diff --git a/src/client/uriHandler/uriHandler.ts b/src/client/uriHandler/uriHandler.ts index 2de3285ff..9f7975528 100644 --- a/src/client/uriHandler/uriHandler.ts +++ b/src/client/uriHandler/uriHandler.ts @@ -10,6 +10,7 @@ import { UriPath } from "./constants/uriConstants"; import { URI_HANDLER_STRINGS } from "./constants/uriStrings"; import { uriHandlerTelemetryEventNames } from "./telemetry/uriHandlerTelemetryEvents"; import { UriHandlerUtils, UriParameters } from "./utils/uriHandlerUtils"; +import { AuthEnvironmentService } from "./utils/authEnvironment"; /** * Signature for a deep-link route handler. Each registered URI path maps to one handler. @@ -24,9 +25,11 @@ export function RegisterUriHandler(pacWrapper: PacWrapper): vscode.Disposable { export class UriHandler implements vscode.UriHandler { private readonly pacWrapper: PacWrapper; private readonly routes: ReadonlyMap; + private readonly authEnvironmentService: AuthEnvironmentService; constructor(pacWrapper: PacWrapper) { this.pacWrapper = pacWrapper; + this.authEnvironmentService = new AuthEnvironmentService(pacWrapper); this.routes = this.buildRoutes(); } @@ -124,7 +127,7 @@ export class UriHandler implements vscode.UriHandler { this.validateRequiredParameters(uriParams, telemetryData); // Prepare authentication and environment - await this.prepareAuthenticationAndEnvironment(uriParams, telemetryData); + await this.authEnvironmentService.prepareAuthenticationAndEnvironment(uriParams, telemetryData); // Handle the download process await this.handleSiteDownload(uriParams, telemetryData, startTime); @@ -230,170 +233,6 @@ export class UriHandler implements vscode.UriHandler { } } - /** - * Handle authentication and environment setup - */ - private async prepareAuthenticationAndEnvironment(uriParams: UriParameters, telemetryData: Record): Promise { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: URI_HANDLER_STRINGS.TITLES.POWER_PAGES, - cancellable: false - }, - async (progress) => { - progress.report({ - message: URI_HANDLER_STRINGS.PROGRESS.PREPARING, - increment: 10 - }); - - progress.report({ - message: URI_HANDLER_STRINGS.PROGRESS.VALIDATING_AUTH, - increment: 20 - }); - - // Check and handle authentication - await this.ensureAuthentication(uriParams, telemetryData, progress); - - progress.report({ - message: URI_HANDLER_STRINGS.PROGRESS.CHECKING_ENV, - increment: 20 - }); - - // Check and handle environment switching - await this.ensureCorrectEnvironment(uriParams, telemetryData, progress); - - progress.report({ - message: URI_HANDLER_STRINGS.PROGRESS.READY_TO_SELECT, - increment: 30 - }); - - // Brief delay to let user see the final progress message - await new Promise(resolve => setTimeout(resolve, 500)); - } - ); - } - - /** - * Ensure user is authenticated with PAC CLI - */ - private async ensureAuthentication(uriParams: UriParameters, telemetryData: Record, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { - let authInfo; - try { - authInfo = await this.pacWrapper.activeOrg(); - } catch (error) { - await this.resetPacProcessAndThrow(error, telemetryData, 'Failed to check authentication status', 'auth_check_failed'); - } - - if (!authInfo || authInfo.Status !== "Success") { - oneDSLoggerWrapper.getLogger().traceInfo( - uriHandlerTelemetryEventNames.URI_HANDLER_AUTH_REQUIRED, - { ...telemetryData, authStatus: authInfo?.Status || 'none' } - ); - - progress.report({ - message: URI_HANDLER_STRINGS.PROGRESS.AUTH_REQUIRED, - increment: 10 - }); - - const authRequired = await vscode.window.showWarningMessage( - URI_HANDLER_STRINGS.PROMPTS.AUTH_REQUIRED, - { modal: true }, - URI_HANDLER_STRINGS.BUTTONS.YES, - URI_HANDLER_STRINGS.BUTTONS.NO - ); - - if (authRequired === URI_HANDLER_STRINGS.BUTTONS.YES) { - try { - progress.report({ - message: URI_HANDLER_STRINGS.PROGRESS.AUTHENTICATING, - increment: 10 - }); - - await this.pacWrapper.authCreateNewAuthProfileForOrg(uriParams.orgUrl!); - - const newAuthInfo = await this.pacWrapper.activeOrg(); - if (!newAuthInfo || newAuthInfo.Status !== "Success") { - throw new Error(URI_HANDLER_STRINGS.ERRORS.AUTH_FAILED); - } - - oneDSLoggerWrapper.getLogger().traceInfo( - uriHandlerTelemetryEventNames.URI_HANDLER_AUTH_COMPLETED, - { ...telemetryData, newAuthStatus: newAuthInfo.Status } - ); - } catch (authError) { - await this.resetPacProcessAndThrow(authError, telemetryData, 'Authentication operation failed', 'auth_operation_failed'); - } - } else { - vscode.window.showInformationMessage(URI_HANDLER_STRINGS.INFO.DOWNLOAD_CANCELLED_AUTH); - oneDSLoggerWrapper.getLogger().traceInfo( - uriHandlerTelemetryEventNames.URI_HANDLER_OPEN_POWER_PAGES_FAILED, - { ...telemetryData, reason: 'user_cancelled_auth' } - ); - throw new Error(URI_HANDLER_STRINGS.ERRORS.USER_CANCELLED_AUTH); - } - } - } - - /** - * Ensure we're connected to the correct environment - */ - private async ensureCorrectEnvironment(uriParams: UriParameters, telemetryData: Record, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { - let currentAuthInfo; - try { - currentAuthInfo = await this.pacWrapper.activeOrg(); - } catch (error) { - await this.resetPacProcessAndThrow(error, telemetryData, 'Failed to check current environment', 'env_check_failed'); - } - - if (currentAuthInfo?.Status === "Success" && currentAuthInfo.Results?.EnvironmentId !== uriParams.environmentId) { - oneDSLoggerWrapper.getLogger().traceInfo( - uriHandlerTelemetryEventNames.URI_HANDLER_ENV_SWITCH_REQUIRED, - { - ...telemetryData, - currentEnvId: currentAuthInfo.Results?.EnvironmentId || 'unknown', - requestedEnvId: uriParams.environmentId - } - ); - - const switchEnv = await vscode.window.showWarningMessage( - URI_HANDLER_STRINGS.PROMPTS.ENV_SWITCH_REQUIRED, - { modal: true }, - URI_HANDLER_STRINGS.BUTTONS.YES, - URI_HANDLER_STRINGS.BUTTONS.NO - ); - - if (switchEnv === URI_HANDLER_STRINGS.BUTTONS.YES) { - try { - progress.report({ - message: URI_HANDLER_STRINGS.PROGRESS.SWITCHING_ENV, - increment: 10 - }); - - await this.pacWrapper.orgSelect(uriParams.orgUrl!); - - const verifyAuthInfo = await this.pacWrapper.activeOrg(); - if (verifyAuthInfo?.Status !== "Success" || verifyAuthInfo.Results?.EnvironmentId !== uriParams.environmentId) { - throw new Error(URI_HANDLER_STRINGS.ERRORS.ENV_SWITCH_FAILED); - } - - oneDSLoggerWrapper.getLogger().traceInfo( - uriHandlerTelemetryEventNames.URI_HANDLER_ENV_SWITCH_COMPLETED, - { ...telemetryData, switchedToEnvId: verifyAuthInfo.Results?.EnvironmentId } - ); - } catch (error) { - await this.resetPacProcessAndThrow(error, telemetryData, 'Error switching environment', 'env_switch_error'); - } - } else { - vscode.window.showInformationMessage(URI_HANDLER_STRINGS.INFO.DOWNLOAD_CANCELLED_ENV); - oneDSLoggerWrapper.getLogger().traceInfo( - uriHandlerTelemetryEventNames.URI_HANDLER_OPEN_POWER_PAGES_FAILED, - { ...telemetryData, reason: 'user_cancelled_env_switch' } - ); - throw new Error(URI_HANDLER_STRINGS.ERRORS.USER_CANCELLED_ENV_SWITCH); - } - } - } - /** * Handle the site download process */ @@ -478,44 +317,9 @@ export class UriHandler implements vscode.UriHandler { { ...telemetryData, error: 'download_failed' } ); - await this.resetPacProcessSafely(telemetryData); + await this.authEnvironmentService.resetPacProcessSafely(telemetryData); throw error; } } - /** - * Reset PAC process and throw error - */ - private async resetPacProcessAndThrow(error: unknown, telemetryData: Record, message: string, errorType: string): Promise { - oneDSLoggerWrapper.getLogger().traceError( - uriHandlerTelemetryEventNames.URI_HANDLER_OPEN_POWER_PAGES_FAILED, - message, - error instanceof Error ? error : new Error(String(error)), - { ...telemetryData, error: errorType } - ); - - await this.resetPacProcessSafely(telemetryData); - throw error; - } - - /** - * Safely reset PAC process without throwing - */ - private async resetPacProcessSafely(telemetryData: Record): Promise { - try { - await this.pacWrapper.resetPacProcess(); - oneDSLoggerWrapper.getLogger().traceInfo( - uriHandlerTelemetryEventNames.URI_HANDLER_OPEN_POWER_PAGES_FAILED, - { ...telemetryData, message: 'PAC process reset after failure' } - ); - } catch (resetError) { - oneDSLoggerWrapper.getLogger().traceError( - uriHandlerTelemetryEventNames.URI_HANDLER_OPEN_POWER_PAGES_FAILED, - 'Failed to reset PAC process after failure', - resetError instanceof Error ? resetError : new Error(String(resetError)), - { ...telemetryData, error: 'pac_reset_failed' } - ); - } - } - } diff --git a/src/client/uriHandler/utils/authEnvironment.ts b/src/client/uriHandler/utils/authEnvironment.ts new file mode 100644 index 000000000..d0cc94b18 --- /dev/null +++ b/src/client/uriHandler/utils/authEnvironment.ts @@ -0,0 +1,224 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { PacWrapper } from "../../pac/PacWrapper"; +import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; +import { uriHandlerTelemetryEventNames } from "../telemetry/uriHandlerTelemetryEvents"; +import { URI_HANDLER_STRINGS } from "../constants/uriStrings"; +import { UriParameters } from "./uriHandlerUtils"; + +/** + * Encapsulates the PAC CLI authentication and environment-selection steps shared by the + * Power Pages deep-link flows. Extracted from `UriHandler` so that each deep-link handler + * (`/open` today, `/pacCreate` and `/agenticCreate` in follow-ups) can reuse the exact same + * auth/environment/reset behavior without duplicating it. + */ +export class AuthEnvironmentService { + private readonly pacWrapper: PacWrapper; + + constructor(pacWrapper: PacWrapper) { + this.pacWrapper = pacWrapper; + } + + /** + * Handle authentication and environment setup, reporting progress to the user. + */ + public async prepareAuthenticationAndEnvironment(uriParams: UriParameters, telemetryData: Record): Promise { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: URI_HANDLER_STRINGS.TITLES.POWER_PAGES, + cancellable: false + }, + async (progress) => { + progress.report({ + message: URI_HANDLER_STRINGS.PROGRESS.PREPARING, + increment: 10 + }); + + progress.report({ + message: URI_HANDLER_STRINGS.PROGRESS.VALIDATING_AUTH, + increment: 20 + }); + + // Check and handle authentication + await this.ensureAuthentication(uriParams, telemetryData, progress); + + progress.report({ + message: URI_HANDLER_STRINGS.PROGRESS.CHECKING_ENV, + increment: 20 + }); + + // Check and handle environment switching + await this.ensureCorrectEnvironment(uriParams, telemetryData, progress); + + progress.report({ + message: URI_HANDLER_STRINGS.PROGRESS.READY_TO_SELECT, + increment: 30 + }); + + // Brief delay to let user see the final progress message + await new Promise(resolve => setTimeout(resolve, 500)); + } + ); + } + + /** + * Ensure user is authenticated with PAC CLI + */ + private async ensureAuthentication(uriParams: UriParameters, telemetryData: Record, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { + let authInfo; + try { + authInfo = await this.pacWrapper.activeOrg(); + } catch (error) { + await this.resetPacProcessAndThrow(error, telemetryData, 'Failed to check authentication status', 'auth_check_failed'); + } + + if (!authInfo || authInfo.Status !== "Success") { + oneDSLoggerWrapper.getLogger().traceInfo( + uriHandlerTelemetryEventNames.URI_HANDLER_AUTH_REQUIRED, + { ...telemetryData, authStatus: authInfo?.Status || 'none' } + ); + + progress.report({ + message: URI_HANDLER_STRINGS.PROGRESS.AUTH_REQUIRED, + increment: 10 + }); + + const authRequired = await vscode.window.showWarningMessage( + URI_HANDLER_STRINGS.PROMPTS.AUTH_REQUIRED, + { modal: true }, + URI_HANDLER_STRINGS.BUTTONS.YES, + URI_HANDLER_STRINGS.BUTTONS.NO + ); + + if (authRequired === URI_HANDLER_STRINGS.BUTTONS.YES) { + try { + progress.report({ + message: URI_HANDLER_STRINGS.PROGRESS.AUTHENTICATING, + increment: 10 + }); + + await this.pacWrapper.authCreateNewAuthProfileForOrg(uriParams.orgUrl!); + + const newAuthInfo = await this.pacWrapper.activeOrg(); + if (!newAuthInfo || newAuthInfo.Status !== "Success") { + throw new Error(URI_HANDLER_STRINGS.ERRORS.AUTH_FAILED); + } + + oneDSLoggerWrapper.getLogger().traceInfo( + uriHandlerTelemetryEventNames.URI_HANDLER_AUTH_COMPLETED, + { ...telemetryData, newAuthStatus: newAuthInfo.Status } + ); + } catch (authError) { + await this.resetPacProcessAndThrow(authError, telemetryData, 'Authentication operation failed', 'auth_operation_failed'); + } + } else { + vscode.window.showInformationMessage(URI_HANDLER_STRINGS.INFO.DOWNLOAD_CANCELLED_AUTH); + oneDSLoggerWrapper.getLogger().traceInfo( + uriHandlerTelemetryEventNames.URI_HANDLER_OPEN_POWER_PAGES_FAILED, + { ...telemetryData, reason: 'user_cancelled_auth' } + ); + throw new Error(URI_HANDLER_STRINGS.ERRORS.USER_CANCELLED_AUTH); + } + } + } + + /** + * Ensure we're connected to the correct environment + */ + private async ensureCorrectEnvironment(uriParams: UriParameters, telemetryData: Record, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { + let currentAuthInfo; + try { + currentAuthInfo = await this.pacWrapper.activeOrg(); + } catch (error) { + await this.resetPacProcessAndThrow(error, telemetryData, 'Failed to check current environment', 'env_check_failed'); + } + + if (currentAuthInfo?.Status === "Success" && currentAuthInfo.Results?.EnvironmentId !== uriParams.environmentId) { + oneDSLoggerWrapper.getLogger().traceInfo( + uriHandlerTelemetryEventNames.URI_HANDLER_ENV_SWITCH_REQUIRED, + { + ...telemetryData, + currentEnvId: currentAuthInfo.Results?.EnvironmentId || 'unknown', + requestedEnvId: uriParams.environmentId + } + ); + + const switchEnv = await vscode.window.showWarningMessage( + URI_HANDLER_STRINGS.PROMPTS.ENV_SWITCH_REQUIRED, + { modal: true }, + URI_HANDLER_STRINGS.BUTTONS.YES, + URI_HANDLER_STRINGS.BUTTONS.NO + ); + + if (switchEnv === URI_HANDLER_STRINGS.BUTTONS.YES) { + try { + progress.report({ + message: URI_HANDLER_STRINGS.PROGRESS.SWITCHING_ENV, + increment: 10 + }); + + await this.pacWrapper.orgSelect(uriParams.orgUrl!); + + const verifyAuthInfo = await this.pacWrapper.activeOrg(); + if (verifyAuthInfo?.Status !== "Success" || verifyAuthInfo.Results?.EnvironmentId !== uriParams.environmentId) { + throw new Error(URI_HANDLER_STRINGS.ERRORS.ENV_SWITCH_FAILED); + } + + oneDSLoggerWrapper.getLogger().traceInfo( + uriHandlerTelemetryEventNames.URI_HANDLER_ENV_SWITCH_COMPLETED, + { ...telemetryData, switchedToEnvId: verifyAuthInfo.Results?.EnvironmentId } + ); + } catch (error) { + await this.resetPacProcessAndThrow(error, telemetryData, 'Error switching environment', 'env_switch_error'); + } + } else { + vscode.window.showInformationMessage(URI_HANDLER_STRINGS.INFO.DOWNLOAD_CANCELLED_ENV); + oneDSLoggerWrapper.getLogger().traceInfo( + uriHandlerTelemetryEventNames.URI_HANDLER_OPEN_POWER_PAGES_FAILED, + { ...telemetryData, reason: 'user_cancelled_env_switch' } + ); + throw new Error(URI_HANDLER_STRINGS.ERRORS.USER_CANCELLED_ENV_SWITCH); + } + } + } + + /** + * Reset PAC process and throw error + */ + public async resetPacProcessAndThrow(error: unknown, telemetryData: Record, message: string, errorType: string): Promise { + oneDSLoggerWrapper.getLogger().traceError( + uriHandlerTelemetryEventNames.URI_HANDLER_OPEN_POWER_PAGES_FAILED, + message, + error instanceof Error ? error : new Error(String(error)), + { ...telemetryData, error: errorType } + ); + + await this.resetPacProcessSafely(telemetryData); + throw error; + } + + /** + * Safely reset PAC process without throwing + */ + public async resetPacProcessSafely(telemetryData: Record): Promise { + try { + await this.pacWrapper.resetPacProcess(); + oneDSLoggerWrapper.getLogger().traceInfo( + uriHandlerTelemetryEventNames.URI_HANDLER_OPEN_POWER_PAGES_FAILED, + { ...telemetryData, message: 'PAC process reset after failure' } + ); + } catch (resetError) { + oneDSLoggerWrapper.getLogger().traceError( + uriHandlerTelemetryEventNames.URI_HANDLER_OPEN_POWER_PAGES_FAILED, + 'Failed to reset PAC process after failure', + resetError instanceof Error ? resetError : new Error(String(resetError)), + { ...telemetryData, error: 'pac_reset_failed' } + ); + } + } +}