diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index b2e1a4317..0254c559b 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -132,6 +132,9 @@ "User cancelled environment switch": "User cancelled environment switch", "User cancelled folder selection": "User cancelled folder selection", "Download failed: {0}": "Download failed: {0}", + "Metadata diff import URI is missing the required 'filePath' query parameter.": "Metadata diff import URI is missing the required 'filePath' query parameter.", + "Metadata diff import URI must reference an absolute file path without '..' segments.": "Metadata diff import URI must reference an absolute file path without '..' segments.", + "Metadata diff import failed: {0}": "Metadata diff import failed: {0}", "Site download cancelled. Authentication is required to proceed.": "Site download cancelled. Authentication is required to proceed.", "Site download cancelled. Correct environment connection is required.": "Site download cancelled. Correct environment connection is required.", "Site download cancelled. No folder selected.": "Site download cancelled. No folder selected.", @@ -435,6 +438,7 @@ "Export Site Comparison": "Export Site Comparison", "Importing comparison...": "Importing comparison...", "Import Site Comparison": "Import Site Comparison", + "Metadata diff file must be a .json file.": "Metadata diff file must be a .json file.", "Invalid file format. The file does not contain valid metadata diff data.": "Invalid file format. The file does not contain valid metadata diff data.", "Unsupported version. This file was created with a newer version of the extension.": "Unsupported version. This file was created with a newer version of the extension.", "This file was exported with a newer version of the extension. Please update your extension to import this file.": "This file was exported with a newer version of the extension. Please update your extension to import this file.", @@ -613,6 +617,12 @@ "Error message when import fails. {0} is the error message." ] }, + "Metadata diff file not found: {0}/Error message when the supplied import file is missing. {0} is the file path.": { + "message": "Metadata diff file not found: {0}", + "comment": [ + "Error message when the supplied import file is missing. {0} is the file path." + ] + }, "Refreshing comparison for {0} ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel \"Show download output\")).../This is a markdown formatting which must persist across translations.": { "message": "Refreshing comparison for {0} ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel \"Show download output\"))...", "comment": [ diff --git a/loc/translations-export/vscode-powerplatform.xlf b/loc/translations-export/vscode-powerplatform.xlf index 4e5da15b4..15b962bee 100644 --- a/loc/translations-export/vscode-powerplatform.xlf +++ b/loc/translations-export/vscode-powerplatform.xlf @@ -825,6 +825,22 @@ Return to this chat and @powerpages can help you write and edit your website cod Maximum 30 characters allowed + + Metadata diff file must be a .json file. + + + Metadata diff file not found: {0} + Error message when the supplied import file is missing. {0} is the file path. + + + Metadata diff import URI is missing the required 'filePath' query parameter. + + + Metadata diff import URI must reference an absolute file path without '..' segments. + + + Metadata diff import failed: {0} + Microsoft wants your feedback diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index 02e8c3ac1..d0da31bad 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -386,7 +386,9 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider importMetadataDiff()), vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_RESYNC, resyncMetadataDiff(this._pacTerminal, this._context)), MetadataDiffDecorationProvider.getInstance().register(), ReadOnlyContentProvider.getInstance().register() diff --git a/src/client/power-pages/actions-hub/Constants.ts b/src/client/power-pages/actions-hub/Constants.ts index 993ac8318..7d0e02f97 100644 --- a/src/client/power-pages/actions-hub/Constants.ts +++ b/src/client/power-pages/actions-hub/Constants.ts @@ -269,6 +269,7 @@ export const Constants = { METADATA_DIFF_EXPORT_TITLE: vscode.l10n.t("Export Site Comparison"), METADATA_DIFF_IMPORT_PROGRESS: vscode.l10n.t("Importing comparison..."), METADATA_DIFF_IMPORT_TITLE: vscode.l10n.t("Import Site Comparison"), + METADATA_DIFF_IMPORT_NOT_JSON: vscode.l10n.t("Metadata diff file must be a .json file."), METADATA_DIFF_EXPORT_INVALID_FILE: vscode.l10n.t("Invalid file format. The file does not contain valid metadata diff data."), METADATA_DIFF_EXPORT_UNSUPPORTED_VERSION: vscode.l10n.t("Unsupported version. This file was created with a newer version of the extension."), METADATA_DIFF_EXPORT_NEWER_EXTENSION_VERSION: vscode.l10n.t("This file was exported with a newer version of the extension. Please update your extension to import this file."), @@ -520,6 +521,15 @@ export const Constants = { args: [errorMessage], comment: ["Error message when import fails. {0} is the error message."] }), + /** + * Returns the error message when a pre-supplied import file does not exist + */ + METADATA_DIFF_IMPORT_FILE_NOT_FOUND: (filePath: string) => + vscode.l10n.t({ + message: "Metadata diff file not found: {0}", + args: [filePath], + comment: ["Error message when the supplied import file is missing. {0} is the file path."] + }), /** * Returns the progress message when resyncing a site comparison */ @@ -708,6 +718,7 @@ export const Constants = { ACTIONS_HUB_METADATA_DIFF_IMPORT_CALLED: "ActionsHubMetadataDiffImportCalled", ACTIONS_HUB_METADATA_DIFF_IMPORT_SUCCESS: "ActionsHubMetadataDiffImportSuccess", ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED: "ActionsHubMetadataDiffImportFailed", + ACTIONS_HUB_METADATA_DIFF_IMPORT_AUTO_OPEN_FAILED: "ActionsHubMetadataDiffImportAutoOpenFailed", ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED: "ActionsHubMetadataDiffResyncCalled", ACTIONS_HUB_METADATA_DIFF_RESYNC_COMPLETED: "ActionsHubMetadataDiffResyncCompleted", ACTIONS_HUB_METADATA_DIFF_RESYNC_FAILED: "ActionsHubMetadataDiffResyncFailed", diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts index 51666bd31..01cb0675e 100644 --- a/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts @@ -8,9 +8,10 @@ import * as fs from "fs"; import path from "path"; import { Constants } from "../../Constants"; import { traceError, traceInfo } from "../../TelemetryHelper"; -import { IMetadataDiffExport, METADATA_DIFF_EXPORT_VERSION } from "../../models/IMetadataDiffExport"; +import { IExportableFileComparisonResult, IMetadataDiffExport, METADATA_DIFF_EXPORT_VERSION } from "../../models/IMetadataDiffExport"; import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult"; import MetadataDiffContext from "../../MetadataDiffContext"; +import { MetadataDiffFileTreeItem } from "../../tree-items/metadata-diff/MetadataDiffFileTreeItem"; import { getExtensionVersion } from "../../../../../common/utilities/Utils"; /** @@ -112,26 +113,59 @@ function validateImportData(data: unknown): string | undefined { /** * Imports a metadata diff from a JSON file */ -export async function importMetadataDiff(): Promise { +export async function importMetadataDiff( + presuppliedFileUri?: vscode.Uri, + options?: { openFirstFile?: boolean } +): Promise { traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_CALLED, { - methodName: importMetadataDiff.name + methodName: importMetadataDiff.name, + source: presuppliedFileUri ? "uri_handler" : "command_palette" }); try { - // Show open dialog first (before progress) - const openUris = await vscode.window.showOpenDialog({ - canSelectMany: false, - filters: { - [Constants.Strings.METADATA_DIFF_EXPORT_FILTER_NAME]: ["json"] - }, - title: Constants.Strings.METADATA_DIFF_IMPORT_TITLE - }); + let fileUri: vscode.Uri; - if (!openUris || openUris.length === 0) { - return; // User cancelled - } + if (presuppliedFileUri) { + // URI-handler path: validate the supplied file before proceeding. + if (!fs.existsSync(presuppliedFileUri.fsPath)) { + vscode.window.showErrorMessage( + Constants.StringFunctions.METADATA_DIFF_IMPORT_FILE_NOT_FOUND(presuppliedFileUri.fsPath) + ); + traceError( + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED, + new Error("Pre-supplied file path does not exist"), + { methodName: importMetadataDiff.name, reason: "file_not_found" } + ); + return; + } + if (!presuppliedFileUri.fsPath.toLowerCase().endsWith(".json")) { + vscode.window.showErrorMessage( + Constants.Strings.METADATA_DIFF_IMPORT_NOT_JSON + ); + traceError( + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED, + new Error("Pre-supplied file is not .json"), + { methodName: importMetadataDiff.name, reason: "wrong_extension" } + ); + return; + } + fileUri = presuppliedFileUri; + } else { + // Command path: existing file-picker flow (unchanged). + const openUris = await vscode.window.showOpenDialog({ + canSelectMany: false, + filters: { + [Constants.Strings.METADATA_DIFF_EXPORT_FILTER_NAME]: ["json"] + }, + title: Constants.Strings.METADATA_DIFF_IMPORT_TITLE + }); + + if (!openUris || openUris.length === 0) { + return; // User cancelled + } - const fileUri = openUris[0]; + fileUri = openUris[0]; + } // Read and parse the file first to validate before showing progress let importData: IMetadataDiffExport; @@ -189,6 +223,10 @@ export async function importMetadataDiff(): Promise { return; } + // Hoisted so the success path can locate the first viewable file after the + // progress task completes (used for the optional auto-open below). + const comparisonResults: IFileComparisonResult[] = []; + // Now show progress while doing the actual file writing work await vscode.window.withProgress( { @@ -215,8 +253,6 @@ export async function importMetadataDiff(): Promise { fs.mkdirSync(importedDiffsPath, { recursive: true }); // Write the file contents to the storage - const comparisonResults: IFileComparisonResult[] = []; - for (const file of importData.files) { const localPath = path.join(importedDiffsPath, "local", file.relativePath); const remotePath = path.join(importedDiffsPath, "remote", file.relativePath); @@ -291,6 +327,63 @@ export async function importMetadataDiff(): Promise { vscode.window.showInformationMessage( Constants.StringFunctions.METADATA_DIFF_IMPORT_SUCCESS(displayName) ); + + // Optional auto-open (opt-in, e.g. from the URI deep-link flow): surface the + // first viewable file diff so the user lands directly on a comparison. + if (options?.openFirstFile) { + // A file can be opened when openMetadataDiffFile has content to show for its + // status: modified -> diff (needs both sides), added -> local, deleted -> remote. + const isOpenable = (f: IExportableFileComparisonResult): boolean => { + switch (f.status) { + case FileComparisonStatus.MODIFIED: + return f.localContent != null && f.remoteContent != null; + case FileComparisonStatus.ADDED: + return f.localContent != null; + case FileComparisonStatus.DELETED: + return f.remoteContent != null; + default: + return false; + } + }; + // Prefer a modified file (shows a real diff); otherwise open the first + // openable file of any status so at least one file surfaces. + const files = importData.files ?? []; + const viewableFile = + files.find(f => f.status === FileComparisonStatus.MODIFIED && isOpenable(f)) ?? + files.find(isOpenable); + const comparisonResult = viewableFile + ? comparisonResults.find(r => r.relativePath === viewableFile.relativePath) + : undefined; + + if (comparisonResult) { + // [DIAG] Temporary diagnostics to find why auto-open silently no-ops. + let diag = `viewable=${viewableFile?.relativePath}\nstatus=${comparisonResult.status}` + + `\nlocalExists=${fs.existsSync(comparisonResult.localPath)}` + + `\nremoteExists=${fs.existsSync(comparisonResult.remotePath)}` + + `\nlocal=${comparisonResult.localPath}\nremote=${comparisonResult.remotePath}`; + try { + const fileTreeItem = new MetadataDiffFileTreeItem(comparisonResult, displayName, true); + await vscode.commands.executeCommand( + Constants.Commands.METADATA_DIFF_OPEN_FILE, + fileTreeItem + ); + diag += `\nexecuteCommand=OK`; + } catch (error) { + // Best-effort: import already succeeded; auto-open failure is non-fatal. + diag += `\nERROR=${error instanceof Error ? (error.stack || error.message) : String(error)}`; + traceError( + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_AUTO_OPEN_FAILED, + error as Error, + { methodName: importMetadataDiff.name } + ); + } + vscode.window.showInformationMessage(`[auto-open diag]\n${diag}`, { modal: true }); + } else { + // [DIAG] No file matched the openable criteria. + const summary = (importData.files ?? []).map(f => `${f.status}:${f.relativePath} L=${f.localContent != null} R=${f.remoteContent != null}`).join("\n"); + vscode.window.showInformationMessage(`[auto-open diag] no openable file selected.\n${summary}`, { modal: true }); + } + } } catch (error) { traceError( Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED, diff --git a/src/client/test/integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts b/src/client/test/integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts index a7e769926..6292720b8 100644 --- a/src/client/test/integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts +++ b/src/client/test/integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts @@ -4,10 +4,17 @@ */ import * as vscode from "vscode"; +// Use the CommonJS require form so sinon stubs the real `fs` singleton. A +// namespace import (`import * as fs`) is compiled to a `__importStar` wrapper +// whose properties are non-configurable getters, which sinon cannot replace +// ("Descriptor for property existsSync is non-configurable and non-writable"). +import fs = require("fs"); import { expect } from "chai"; import sinon from "sinon"; import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper"; import MetadataDiffContext from "../../../../../../power-pages/actions-hub/MetadataDiffContext"; +import { Constants } from "../../../../../../power-pages/actions-hub/Constants"; +import { MetadataDiffFileTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem"; describe("ImportMetadataDiffHandler", () => { let sandbox: sinon.SinonSandbox; @@ -105,6 +112,190 @@ describe("ImportMetadataDiffHandler", () => { }); }); + describe("importMetadataDiff with a pre-supplied URI", () => { + it("should skip the open dialog when a URI is supplied", async () => { + const existsSyncStub = sandbox.stub(fs, "existsSync").returns(true); + // Short-circuit the read so we don't run the full import flow. + sandbox.stub(fs, "readFileSync").throws(new Error("stop after dialog check")); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(vscode.Uri.file("/tmp/diff.json")); + + expect(showOpenDialogStub.called).to.be.false; + expect(existsSyncStub.called).to.be.true; + }); + + it("should show an error when the supplied file does not exist", async () => { + sandbox.stub(fs, "existsSync").returns(false); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(vscode.Uri.file("/tmp/missing.json")); + + expect(showOpenDialogStub.called).to.be.false; + expect(showErrorMessageStub.calledOnce).to.be.true; + expect(showErrorMessageStub.firstCall.args[0]).to.match(/not found/i); + }); + + it("should show an error when the supplied file is not a .json file", async () => { + sandbox.stub(fs, "existsSync").returns(true); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(vscode.Uri.file("/tmp/diff.txt")); + + expect(showOpenDialogStub.called).to.be.false; + expect(showErrorMessageStub.calledOnce).to.be.true; + expect(showErrorMessageStub.firstCall.args[0]).to.match(/\.json/i); + }); + + it("should record the URI handler source in telemetry", async () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + sandbox.stub(fs, "existsSync").returns(false); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(vscode.Uri.file("/tmp/missing.json")); + + expect(traceInfoStub.firstCall.args[0]).to.equal("ActionsHubMetadataDiffImportCalled"); + expect(traceInfoStub.firstCall.args[1]).to.include({ source: "uri_handler" }); + }); + + it("should still open the dialog when no URI is supplied", async () => { + showOpenDialogStub.resolves(undefined); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(); + + expect(showOpenDialogStub.calledOnce).to.be.true; + }); + }); + + describe("importMetadataDiff auto-open (openFirstFile)", () => { + const FAKE_STORAGE = vscode.Uri.file("/tmp/pp-metadata-diff-test-storage"); + let executeCommandStub: sinon.SinonStub; + + const makeExport = (files: unknown[]) => ({ + version: "1.0", + extensionVersion: "1.0.0", + exportedAt: "2024-01-15T10:30:00Z", + localWebsiteId: "local-id", + remoteWebsiteId: "remote-id", + localWebsiteName: "Local Site", + remoteWebsiteName: "Remote Site", + environmentId: "env-id", + environmentName: "Test Environment", + files + }); + + const modifiedFile = (relativePath: string) => ({ + relativePath, + status: "modified", + localContent: Buffer.from("local").toString("base64"), + remoteContent: Buffer.from("remote").toString("base64") + }); + + // Wires up fs + context + command stubs so the supplied URI drives a full, + // successful import without touching disk or opening real editors. + const stubSuccessfulImport = (inputUri: vscode.Uri, exportData: unknown) => { + const inputPath = inputUri.fsPath; + // Only the input file "exists"; storage paths report missing so the + // write loop just creates dirs/files (all stubbed) without cleanup. + sandbox.stub(fs, "existsSync").callsFake((p: fs.PathLike) => p === inputPath); + sandbox.stub(fs, "readFileSync").returns(JSON.stringify(exportData)); + sandbox.stub(fs, "mkdirSync"); + sandbox.stub(fs, "writeFileSync"); + sandbox.stub(fs, "rmSync"); + sandbox.stub(Object.getPrototypeOf(MetadataDiffContext), "extensionContext") + .get(() => ({ globalStorageUri: FAKE_STORAGE } as unknown as vscode.ExtensionContext)); + executeCommandStub = sandbox.stub(vscode.commands, "executeCommand").resolves(); + }; + + const openFileCall = () => + executeCommandStub.getCalls().find(c => c.args[0] === Constants.Commands.METADATA_DIFF_OPEN_FILE); + + it("prefers a modified file with both contents over added/deleted", async () => { + const uri = vscode.Uri.file("/tmp/diff.json"); + stubSuccessfulImport(uri, makeExport([ + { relativePath: "added.json", status: "added", localContent: Buffer.from("x").toString("base64") }, + modifiedFile("modified.json") + ])); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(uri, { openFirstFile: true }); + + const call = openFileCall(); + expect(call, "expected the open-file command to be invoked").to.exist; + expect(call!.args[1]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(call!.args[1].comparisonResult.relativePath).to.equal("modified.json"); + expect(showInformationMessageStub.called).to.be.true; + }); + + it("opens an added/deleted file when no modified file is available", async () => { + const uri = vscode.Uri.file("/tmp/diff.json"); + stubSuccessfulImport(uri, makeExport([ + { relativePath: "added.json", status: "added", localContent: Buffer.from("x").toString("base64") }, + { relativePath: "deleted.json", status: "deleted", remoteContent: Buffer.from("y").toString("base64") } + ])); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(uri, { openFirstFile: true }); + + const call = openFileCall(); + expect(call, "expected an openable file to be opened").to.exist; + expect(call!.args[1].comparisonResult.relativePath).to.equal("added.json"); + expect(showInformationMessageStub.called).to.be.true; + }); + + it("skips files with no openable content for their status", async () => { + const uri = vscode.Uri.file("/tmp/diff.json"); + stubSuccessfulImport(uri, makeExport([ + // modified but missing remote side, and an added entry with no local content + { relativePath: "modified.json", status: "modified", localContent: Buffer.from("local").toString("base64"), remoteContent: null }, + { relativePath: "added.json", status: "added", localContent: null } + ])); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(uri, { openFirstFile: true }); + + expect(openFileCall()).to.be.undefined; + expect(showInformationMessageStub.called).to.be.true; + }); + + it("does not auto-open when openFirstFile is not requested", async () => { + const uri = vscode.Uri.file("/tmp/diff.json"); + stubSuccessfulImport(uri, makeExport([modifiedFile("modified.json")])); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(uri); // no options + + expect(openFileCall()).to.be.undefined; + expect(showInformationMessageStub.called).to.be.true; + }); + + it("does not fail the import when auto-open throws", async () => { + const traceErrorStub = TelemetryHelper.traceError as sinon.SinonStub; + const uri = vscode.Uri.file("/tmp/diff.json"); + stubSuccessfulImport(uri, makeExport([modifiedFile("modified.json")])); + executeCommandStub.rejects(new Error("open failed")); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(uri, { openFirstFile: true }); + + // Import already succeeded: success message shown, no error surfaced. + expect(showInformationMessageStub.called).to.be.true; + expect(showErrorMessageStub.called).to.be.false; + expect(traceErrorStub.calledWith("ActionsHubMetadataDiffImportAutoOpenFailed")).to.be.true; + }); + }); + describe("IMetadataDiffExport format", () => { it("should support new format with localWebsiteId and remoteWebsiteId", () => { // This test verifies that the interface supports the new field names diff --git a/src/client/test/integration/uriHandler.test.ts b/src/client/test/integration/uriHandler.test.ts index bbdc868cb..d5dc4c7b5 100644 --- a/src/client/test/integration/uriHandler.test.ts +++ b/src/client/test/integration/uriHandler.test.ts @@ -3,16 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. */ +import * as vscode from "vscode"; import { expect } from "chai"; import * as sinon from "sinon"; -import * as vscode from "vscode"; import { UriHandler } from "../../uriHandler/uriHandler"; +import * as ImportMetadataDiffHandler from "../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"; +import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; import { URI_CONSTANTS } from "../../uriHandler/constants/uriConstants"; import { PacWrapper } from "../../pac/PacWrapper"; import { AgenticCreateHandler } from "../../uriHandler/handlers/agenticCreateHandler"; import { PacCreateHandler } from "../../uriHandler/handlers/pacCreateHandler"; -// Shape used to stub the private route targets on the prototype without resorting to `any`. type UriHandlerRoutes = { pcfInit: () => Promise; handleOpenPowerPages: (uri: vscode.Uri) => Promise; @@ -20,17 +21,32 @@ type UriHandlerRoutes = { describe("UriHandler routing", () => { let sandbox: sinon.SinonSandbox; + let importStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; let pcfInitStub: sinon.SinonStub; let openStub: sinon.SinonStub; let agenticCreateStub: sinon.SinonStub; let pacCreateStub: sinon.SinonStub; let handler: UriHandler; + const buildUri = (filePath: string) => + vscode.Uri.parse( + `vscode://microsoft-IsvExpTools.powerplatform-vscode/metadataDiffImport?filePath=${encodeURIComponent(filePath)}` + ); + const makeUri = (path: string): vscode.Uri => vscode.Uri.parse(`vscode://${URI_CONSTANTS.EXTENSION_ID}${path}`); beforeEach(() => { sandbox = sinon.createSandbox(); + importStub = sandbox.stub(ImportMetadataDiffHandler, "importMetadataDiff").resolves(); + showErrorMessageStub = sandbox.stub(vscode.window, "showErrorMessage"); + sandbox.stub(oneDSLoggerWrapper, "getLogger").returns({ + traceInfo: () => { /* no-op */ }, + traceError: () => { /* no-op */ }, + traceWarning: () => { /* no-op */ }, + featureUsage: () => { /* no-op */ } + } as unknown as ReturnType); const prototype = UriHandler.prototype as unknown as UriHandlerRoutes; pcfInitStub = sandbox.stub(prototype, "pcfInit").resolves(); openStub = sandbox.stub(prototype, "handleOpenPowerPages").resolves(); @@ -43,6 +59,35 @@ describe("UriHandler routing", () => { sandbox.restore(); }); + it("routes /metadataDiffImport and calls importMetadataDiff with the file URI and { openFirstFile: true }", async () => { + const absolutePath = "/abs/path/diff.json"; + + await handler.handleUri(buildUri(absolutePath)); + + expect(importStub.calledOnce).to.be.true; + const [fileUriArg, optionsArg] = importStub.firstCall.args; + expect(fileUriArg.fsPath).to.equal(vscode.Uri.file(absolutePath).fsPath); + expect(optionsArg).to.deep.equal({ openFirstFile: true }); + expect(showErrorMessageStub.called).to.be.false; + }); + + it("does not call importMetadataDiff when filePath is missing", async () => { + await handler.handleUri( + vscode.Uri.parse("vscode://microsoft-IsvExpTools.powerplatform-vscode/metadataDiffImport") + ); + + expect(importStub.called).to.be.false; + expect(showErrorMessageStub.calledOnce).to.be.true; + }); + + it("does not call importMetadataDiff when filePath is relative or contains '..'", async () => { + await handler.handleUri(buildUri("relative/diff.json")); + await handler.handleUri(buildUri("/abs/../etc/diff.json")); + + expect(importStub.called).to.be.false; + expect(showErrorMessageStub.callCount).to.equal(2); + }); + it("dispatches /pcfInit to the PCF init handler", async () => { await handler.handleUri(makeUri(URI_CONSTANTS.PATHS.PCF_INIT)); diff --git a/src/client/test/unit/uriHandler.test.ts b/src/client/test/unit/uriHandler.test.ts index 1fbadef5c..bbb021613 100644 --- a/src/client/test/unit/uriHandler.test.ts +++ b/src/client/test/unit/uriHandler.test.ts @@ -4,6 +4,7 @@ */ import { expect } from "chai"; +import { resolveMetadataDiffImportFilePath } from "../../uriHandler/utils/metadataDiffImportValidation"; // Test URI handling functionality describe('UriHandler Schema Parameter Tests', () => { @@ -211,3 +212,47 @@ describe('UriHandler Schema Parameter Tests', () => { }); }); }); + +describe('Metadata Diff Import filePath validation', () => { + it('reports "missing" when filePath is null', () => { + const result = resolveMetadataDiffImportFilePath(null); + expect(result.ok).to.be.false; + expect(result).to.deep.equal({ ok: false, reason: "missing" }); + }); + + it('reports "missing" when filePath is undefined', () => { + const result = resolveMetadataDiffImportFilePath(undefined); + expect(result).to.deep.equal({ ok: false, reason: "missing" }); + }); + + it('reports "missing" when filePath is an empty string', () => { + const result = resolveMetadataDiffImportFilePath(""); + expect(result).to.deep.equal({ ok: false, reason: "missing" }); + }); + + it('reports "invalid" for a relative path', () => { + const result = resolveMetadataDiffImportFilePath("relative/path/diff.json"); + expect(result).to.deep.equal({ ok: false, reason: "invalid" }); + }); + + it('reports "invalid" for a path containing ".." segments', () => { + const result = resolveMetadataDiffImportFilePath("/Users/test/../../etc/diff.json"); + expect(result).to.deep.equal({ ok: false, reason: "invalid" }); + }); + + it('reports "invalid" when ".." is hidden behind URL-encoding', () => { + // %2E%2E decodes to "..", so traversal attempts must be caught after decoding. + const result = resolveMetadataDiffImportFilePath("%2F..%2Fetc%2Fdiff.json"); + expect(result).to.deep.equal({ ok: false, reason: "invalid" }); + }); + + it('accepts an absolute path and returns the decoded path', () => { + const result = resolveMetadataDiffImportFilePath("/Users/test/diff.json"); + expect(result).to.deep.equal({ ok: true, filePath: "/Users/test/diff.json" }); + }); + + it('decodes percent-encoded absolute paths', () => { + const result = resolveMetadataDiffImportFilePath("%2FUsers%2Ftest%20user%2Fdiff.json"); + expect(result).to.deep.equal({ ok: true, filePath: "/Users/test user/diff.json" }); + }); +}); diff --git a/src/client/uriHandler/constants/uriConstants.ts b/src/client/uriHandler/constants/uriConstants.ts index 36cb1473f..daab38835 100644 --- a/src/client/uriHandler/constants/uriConstants.ts +++ b/src/client/uriHandler/constants/uriConstants.ts @@ -11,6 +11,7 @@ export const URI_CONSTANTS = { PATHS: { PCF_INIT: '/pcfInit', OPEN: '/open', + METADATA_DIFF_IMPORT: '/metadataDiffImport', AGENTIC_CREATE: '/agenticCreate', PAC_CREATE: '/pacCreate' }, @@ -23,6 +24,7 @@ export const URI_CONSTANTS = { WEBSITE_NAME: 'websitename', SITE_URL: 'siteurl', WEBSITE_PREVIEW_URL: 'websitepreviewurl', + FILE_PATH: 'filePath', REGION: 'region', TENANT_ID: 'tenantid', SOURCE: 'source', @@ -58,6 +60,7 @@ export const URI_CONSTANTS = { export const enum UriPath { PcfInit = '/pcfInit', Open = '/open', + MetadataDiffImport = '/metadataDiffImport', AgenticCreate = '/agenticCreate', PacCreate = '/pacCreate', } diff --git a/src/client/uriHandler/constants/uriStrings.ts b/src/client/uriHandler/constants/uriStrings.ts index e83b88254..9e70e2ff6 100644 --- a/src/client/uriHandler/constants/uriStrings.ts +++ b/src/client/uriHandler/constants/uriStrings.ts @@ -19,7 +19,10 @@ export const URI_HANDLER_STRINGS = { ENV_SWITCH_FAILED: vscode.l10n.t("Failed to switch to the required environment. Please sign in with an account that has access to the target environment using 'pac auth create' command."), USER_CANCELLED_ENV_SWITCH: vscode.l10n.t("User cancelled environment switch"), USER_CANCELLED_FOLDER_SELECTION: vscode.l10n.t("User cancelled folder selection"), - DOWNLOAD_FAILED: vscode.l10n.t("Download failed: {0}") + DOWNLOAD_FAILED: vscode.l10n.t("Download failed: {0}"), + METADATA_DIFF_IMPORT_MISSING_FILE_PATH: vscode.l10n.t("Metadata diff import URI is missing the required 'filePath' query parameter."), + METADATA_DIFF_IMPORT_INVALID_FILE_PATH: vscode.l10n.t("Metadata diff import URI must reference an absolute file path without '..' segments."), + METADATA_DIFF_IMPORT_FAILED: vscode.l10n.t("Metadata diff import failed: {0}") }, INFO: { DOWNLOAD_CANCELLED_AUTH: vscode.l10n.t("Site download cancelled. Authentication is required to proceed."), diff --git a/src/client/uriHandler/telemetry/uriHandlerTelemetryEvents.ts b/src/client/uriHandler/telemetry/uriHandlerTelemetryEvents.ts index a448f2e60..622532edd 100644 --- a/src/client/uriHandler/telemetry/uriHandlerTelemetryEvents.ts +++ b/src/client/uriHandler/telemetry/uriHandlerTelemetryEvents.ts @@ -17,6 +17,7 @@ export enum uriHandlerTelemetryEventNames { URI_HANDLER_DOWNLOAD_COMPLETED = "UriHandlerDownloadCompleted", URI_HANDLER_FOLDER_OPENED = "UriHandlerFolderOpened", URI_HANDLER_PCF_INIT_TRIGGERED = "UriHandlerPcfInitTriggered", + URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED = "UriHandlerMetadataDiffImportTriggered", URI_HANDLER_AGENTIC_CREATE_TRIGGERED = "UriHandlerAgenticCreateTriggered", URI_HANDLER_AGENTIC_CREATE_DISABLED = "UriHandlerAgenticCreateDisabled", URI_HANDLER_AGENTIC_CREATE_FAILED = "UriHandlerAgenticCreateFailed", diff --git a/src/client/uriHandler/uriHandler.ts b/src/client/uriHandler/uriHandler.ts index 5b5cbc314..27f7aea74 100644 --- a/src/client/uriHandler/uriHandler.ts +++ b/src/client/uriHandler/uriHandler.ts @@ -6,10 +6,12 @@ import * as vscode from "vscode"; import { PacWrapper } from "../pac/PacWrapper"; import { oneDSLoggerWrapper } from "../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; -import { UriPath } from "./constants/uriConstants"; +import { UriPath, URI_CONSTANTS } from "./constants/uriConstants"; import { URI_HANDLER_STRINGS } from "./constants/uriStrings"; import { uriHandlerTelemetryEventNames } from "./telemetry/uriHandlerTelemetryEvents"; import { UriHandlerUtils, UriParameters } from "./utils/uriHandlerUtils"; +import { resolveMetadataDiffImportFilePath } from "./utils/metadataDiffImportValidation"; +import { importMetadataDiff } from "../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"; import { AuthEnvironmentService } from "./utils/authEnvironment"; import { AgenticCreateHandler } from "./handlers/agenticCreateHandler"; import { PacCreateHandler } from "./handlers/pacCreateHandler"; @@ -47,6 +49,7 @@ export class UriHandler implements vscode.UriHandler { return new Map([ [UriPath.PcfInit, () => this.pcfInit()], [UriPath.Open, (uri) => this.handleOpenPowerPages(uri)], + [UriPath.MetadataDiffImport, (uri) => this.handleMetadataDiffImport(uri)], [UriPath.AgenticCreate, (uri) => this.agenticCreateHandler.handle(uri)], [UriPath.PacCreate, (uri) => this.pacCreateHandler.handle(uri)], ]); @@ -65,6 +68,54 @@ export class UriHandler implements vscode.UriHandler { // Unrecognized paths are intentionally ignored for forward compatibility. } + // vscode://microsoft-IsvExpTools.powerplatform-vscode/metadataDiffImport?filePath= + async handleMetadataDiffImport(uri: vscode.Uri): Promise { + const startTime = Date.now(); + try { + const params = new URLSearchParams(uri.query); + const rawFilePath = params.get(URI_CONSTANTS.PARAMETERS.FILE_PATH); + + oneDSLoggerWrapper.getLogger().traceInfo( + uriHandlerTelemetryEventNames.URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED, + { timestamp: startTime.toString(), hasFilePath: rawFilePath ? "true" : "false" } + ); + + const pathResult = resolveMetadataDiffImportFilePath(rawFilePath); + if (!pathResult.ok) { + const message = pathResult.reason === "missing" + ? URI_HANDLER_STRINGS.ERRORS.METADATA_DIFF_IMPORT_MISSING_FILE_PATH + : URI_HANDLER_STRINGS.ERRORS.METADATA_DIFF_IMPORT_INVALID_FILE_PATH; + vscode.window.showErrorMessage(message); + oneDSLoggerWrapper.getLogger().traceInfo( + uriHandlerTelemetryEventNames.URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED, + { success: "false", reason: pathResult.reason, duration: (Date.now() - startTime).toString() } + ); + return; + } + + const fileUri = vscode.Uri.file(pathResult.filePath); + await importMetadataDiff(fileUri, { openFirstFile: true }); + + oneDSLoggerWrapper.getLogger().traceInfo( + uriHandlerTelemetryEventNames.URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED, + { success: "true", duration: (Date.now() - startTime).toString() } + ); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + uriHandlerTelemetryEventNames.URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED, + "Metadata diff import via URI failed", + error instanceof Error ? error : new Error(String(error)), + { duration: (Date.now() - startTime).toString() } + ); + vscode.window.showErrorMessage( + URI_HANDLER_STRINGS.ERRORS.METADATA_DIFF_IMPORT_FAILED.replace( + "{0}", + error instanceof Error ? error.message : String(error) + ) + ); + } + } + async pcfInit(): Promise { const startTime = Date.now(); diff --git a/src/client/uriHandler/utils/metadataDiffImportValidation.ts b/src/client/uriHandler/utils/metadataDiffImportValidation.ts new file mode 100644 index 000000000..27fa92b73 --- /dev/null +++ b/src/client/uriHandler/utils/metadataDiffImportValidation.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as path from "path"; + +/** + * Result of validating the `filePath` query parameter of a + * `/metadataDiffImport` URI. + * + * Kept free of any `vscode` dependency so the validation logic can be + * exercised by the plain-mocha unit test runner (which cannot load the + * `vscode` runtime module). + */ +export type MetadataDiffImportPathResult = + | { ok: true; filePath: string } + | { ok: false; reason: "missing" | "invalid" }; + +/** + * Validates and decodes the `filePath` supplied on a metadata diff import URI. + * + * Defense-in-depth: only absolute paths without `..` segments are accepted. + * Relative paths and path-traversal attempts are rejected. The user is + * implicitly trusting whoever generated the URL — but we shouldn't make path + * tricks easy. + * + * @param rawFilePath The raw, still-encoded value of the `filePath` query parameter. + * @returns `{ ok: true, filePath }` with the decoded absolute path, or + * `{ ok: false, reason }` describing why it was rejected. + */ +export function resolveMetadataDiffImportFilePath( + rawFilePath: string | null | undefined +): MetadataDiffImportPathResult { + if (!rawFilePath) { + return { ok: false, reason: "missing" }; + } + + const decoded = decodeURIComponent(rawFilePath); + if (!path.isAbsolute(decoded) || decoded.includes("..")) { + return { ok: false, reason: "invalid" }; + } + + return { ok: true, filePath: decoded }; +}