Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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": [
Expand Down
16 changes: 16 additions & 0 deletions loc/translations-export/vscode-powerplatform.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,22 @@ Return to this chat and @powerpages can help you write and edit your website cod
<trans-unit id="++CODE++8a194d5287e3da019883144787cd5f282753f5ce892d933be0833b3818c25503">
<source xml:lang="en">Maximum 30 characters allowed</source>
</trans-unit>
<trans-unit id="++CODE++3ab101664903513d135cf6e3a5e2f260910f63b67cdf40df9266777777989cd9">
<source xml:lang="en">Metadata diff file must be a .json file.</source>
</trans-unit>
<trans-unit id="++CODE++c9e90f17b8b088e896b5de437e61ed7b0df4ccd3fdcbf82c82bfda388f3859bb">
<source xml:lang="en">Metadata diff file not found: {0}</source>
<note>Error message when the supplied import file is missing. {0} is the file path.</note>
</trans-unit>
<trans-unit id="++CODE++83e1d90c7af88ff613efd0f32526d98bb9f37f4401422ea5b7ac512a2a86857a">
<source xml:lang="en">Metadata diff import URI is missing the required 'filePath' query parameter.</source>
</trans-unit>
<trans-unit id="++CODE++63de590b02582ef032b1d20773a96c3341b92bcd46ccb62914b85b219914ae72">
<source xml:lang="en">Metadata diff import URI must reference an absolute file path without '..' segments.</source>
</trans-unit>
<trans-unit id="++CODE++b70ce9905812faa5fb6d0b257f49dab5ea4b7671ff776b0ba480550dd7b5669e">
<source xml:lang="en">Metadata diff import failed: {0}</source>
</trans-unit>
<trans-unit id="++CODE++b1c8258fb401975727a82d9ce473af0a600f076cc7b73dbd16524e815cf4153d">
<source xml:lang="en">Microsoft wants your feedback</source>
</trans-unit>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,9 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider<Actio
vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_DISCARD_SITE, discardSiteChanges),
vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_GENERATE_HTML_REPORT, generateHtmlReport),
vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_EXPORT, exportMetadataDiff),
vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_IMPORT, importMetadataDiff),
// Invoked from tree-item menus, which pass the tree node as the first
// argument; drop it so the command always uses the file-picker flow.
vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_IMPORT, () => importMetadataDiff()),
vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_RESYNC, resyncMetadataDiff(this._pacTerminal, this._context)),
MetadataDiffDecorationProvider.getInstance().register(),
ReadOnlyContentProvider.getInstance().register()
Expand Down
10 changes: 10 additions & 0 deletions src/client/power-pages/actions-hub/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,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."),
Expand Down Expand Up @@ -519,6 +520,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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,26 +112,56 @@ function validateImportData(data: unknown): string | undefined {
/**
* Imports a metadata diff from a JSON file
*/
export async function importMetadataDiff(): Promise<void> {
export async function importMetadataDiff(presuppliedFileUri?: vscode.Uri): Promise<void> {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import * as vscode from "vscode";
import * as fs from "fs";
import { expect } from "chai";
import sinon from "sinon";
import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper";
Expand Down Expand Up @@ -105,6 +106,67 @@ 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("IMetadataDiffExport format", () => {
it("should support new format with localWebsiteId and remoteWebsiteId", () => {
// This test verifies that the interface supports the new field names
Expand Down
45 changes: 45 additions & 0 deletions src/client/test/unit/uriHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { expect } from "chai";
import { resolveMetadataDiffImportFilePath } from "../../uriHandler/utils/metadataDiffImportValidation";

// Test URI handling functionality
describe('UriHandler Schema Parameter Tests', () => {
Expand Down Expand Up @@ -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" });
});
});
7 changes: 5 additions & 2 deletions src/client/uriHandler/constants/uriConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const URI_CONSTANTS = {
EXTENSION_ID: 'microsoft-IsvExpTools.powerplatform-vscode',
PATHS: {
PCF_INIT: '/pcfInit',
OPEN: '/open'
OPEN: '/open',
METADATA_DIFF_IMPORT: '/metadataDiffImport'
},
PARAMETERS: {
WEBSITE_ID: 'websiteid',
Expand All @@ -20,7 +21,8 @@ export const URI_CONSTANTS = {
SITE_NAME: 'sitename',
WEBSITE_NAME: 'websitename',
SITE_URL: 'siteurl',
WEBSITE_PREVIEW_URL: 'websitepreviewurl'
WEBSITE_PREVIEW_URL: 'websitepreviewurl',
FILE_PATH: 'filePath'
},
SCHEMA_VALUES: {
PORTAL_SCHEMA_V2: 'portalschemav2'
Expand All @@ -40,4 +42,5 @@ export const URI_CONSTANTS = {
export const enum UriPath {
PcfInit = '/pcfInit',
Open = '/open',
MetadataDiffImport = '/metadataDiffImport',
}
5 changes: 4 additions & 1 deletion src/client/uriHandler/constants/uriStrings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export enum uriHandlerTelemetryEventNames {
URI_HANDLER_DOWNLOAD_STARTED = "UriHandlerDownloadStarted",
URI_HANDLER_DOWNLOAD_COMPLETED = "UriHandlerDownloadCompleted",
URI_HANDLER_FOLDER_OPENED = "UriHandlerFolderOpened",
URI_HANDLER_PCF_INIT_TRIGGERED = "UriHandlerPcfInitTriggered"
URI_HANDLER_PCF_INIT_TRIGGERED = "UriHandlerPcfInitTriggered",
URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED = "UriHandlerMetadataDiffImportTriggered"
}
Loading
Loading