[PM-5876] Adjust the `LpFilelessImporter.supressDownload` method to inject through the `executeScript` API instead within manifest v3 (#7787)
* [PM-5876] Adjust LP Fileless Importer to Suppress Download with DOM Append in Manifest v3 * [PM-5876] Incorporating jest tests for affected logic * [PM-5876] Fixing jest test that leverages rxjs * [PM-5876] Updating documentation within BrowserApi.executeScriptInTab * [PM-5876] Implementing jest tests for the new LP suppress download content scripts * [PM-5876] Adding a change to webpack to ensure we do not package the mv2 side script for `lp-suppress-import-download.mv2.ts` if building the extension for mv3 * [PM-5876] Implementing changes based on feedback during code review * [PM-5876] Implementing changes based on feedback during code review * [PM-5876] Implementing changes based on feedback during code review * [PM-5876] Implementing changes based on feedback during code review * [PM-5876] Implementing a configuration to feed the script injection of the Fileless Importer CSV download supression script
This commit is contained in:
parent
101e1a4f2b
commit
16c5fe65ca
|
@ -108,6 +108,7 @@
|
||||||
},
|
},
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
"content/fido2/page-script.js",
|
"content/fido2/page-script.js",
|
||||||
|
"content/lp-suppress-import-download.js",
|
||||||
"notification/bar.html",
|
"notification/bar.html",
|
||||||
"images/icon38.png",
|
"images/icon38.png",
|
||||||
"images/icon38_locked.png",
|
"images/icon38_locked.png",
|
||||||
|
|
|
@ -31,6 +31,12 @@
|
||||||
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
||||||
"run_at": "document_start"
|
"run_at": "document_start"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"all_frames": false,
|
||||||
|
"js": ["content/lp-fileless-importer.js"],
|
||||||
|
"matches": ["https://lastpass.com/export.php"],
|
||||||
|
"run_at": "document_start"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"css": ["content/autofill.css"],
|
"css": ["content/autofill.css"],
|
||||||
|
|
|
@ -320,6 +320,60 @@ describe("BrowserApi", () => {
|
||||||
},
|
},
|
||||||
files: [injectDetails.file],
|
files: [injectDetails.file],
|
||||||
injectImmediately: true,
|
injectImmediately: true,
|
||||||
|
world: "ISOLATED",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(executeScriptResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects the script into a specified frameId when the extension is built for manifest v3", async () => {
|
||||||
|
const tabId = 1;
|
||||||
|
const frameId = 2;
|
||||||
|
const injectDetails = mock<chrome.tabs.InjectDetails>({
|
||||||
|
file: "file.js",
|
||||||
|
allFrames: true,
|
||||||
|
runAt: "document_start",
|
||||||
|
frameId,
|
||||||
|
});
|
||||||
|
jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
|
||||||
|
(chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult);
|
||||||
|
|
||||||
|
await BrowserApi.executeScriptInTab(tabId, injectDetails);
|
||||||
|
|
||||||
|
expect(chrome.scripting.executeScript).toHaveBeenCalledWith({
|
||||||
|
target: {
|
||||||
|
tabId: tabId,
|
||||||
|
allFrames: injectDetails.allFrames,
|
||||||
|
frameIds: [frameId],
|
||||||
|
},
|
||||||
|
files: [injectDetails.file],
|
||||||
|
injectImmediately: true,
|
||||||
|
world: "ISOLATED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects the script into the MAIN world context when injecting a script for manifest v3", async () => {
|
||||||
|
const tabId = 1;
|
||||||
|
const injectDetails = mock<chrome.tabs.InjectDetails>({
|
||||||
|
file: null,
|
||||||
|
allFrames: true,
|
||||||
|
runAt: "document_start",
|
||||||
|
frameId: null,
|
||||||
|
});
|
||||||
|
const scriptingApiDetails = { world: "MAIN" as chrome.scripting.ExecutionWorld };
|
||||||
|
jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
|
||||||
|
(chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult);
|
||||||
|
|
||||||
|
const result = await BrowserApi.executeScriptInTab(tabId, injectDetails, scriptingApiDetails);
|
||||||
|
|
||||||
|
expect(chrome.scripting.executeScript).toHaveBeenCalledWith({
|
||||||
|
target: {
|
||||||
|
tabId: tabId,
|
||||||
|
allFrames: injectDetails.allFrames,
|
||||||
|
frameIds: null,
|
||||||
|
},
|
||||||
|
files: null,
|
||||||
|
injectImmediately: true,
|
||||||
|
world: "MAIN",
|
||||||
});
|
});
|
||||||
expect(result).toEqual(executeScriptResult);
|
expect(result).toEqual(executeScriptResult);
|
||||||
});
|
});
|
||||||
|
|
|
@ -475,12 +475,19 @@ export class BrowserApi {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension API helper method used to execute a script in a tab.
|
* Extension API helper method used to execute a script in a tab.
|
||||||
|
*
|
||||||
* @see https://developer.chrome.com/docs/extensions/reference/tabs/#method-executeScript
|
* @see https://developer.chrome.com/docs/extensions/reference/tabs/#method-executeScript
|
||||||
* @param {number} tabId
|
* @param tabId - The id of the tab to execute the script in.
|
||||||
* @param {chrome.tabs.InjectDetails} details
|
* @param details {@link "InjectDetails" https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/extensionTypes/InjectDetails}
|
||||||
* @returns {Promise<unknown>}
|
* @param scriptingApiDetails {@link "ExecutionWorld" https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld}
|
||||||
*/
|
*/
|
||||||
static executeScriptInTab(tabId: number, details: chrome.tabs.InjectDetails) {
|
static executeScriptInTab(
|
||||||
|
tabId: number,
|
||||||
|
details: chrome.tabs.InjectDetails,
|
||||||
|
scriptingApiDetails?: {
|
||||||
|
world: chrome.scripting.ExecutionWorld;
|
||||||
|
},
|
||||||
|
): Promise<unknown> {
|
||||||
if (BrowserApi.manifestVersion === 3) {
|
if (BrowserApi.manifestVersion === 3) {
|
||||||
return chrome.scripting.executeScript({
|
return chrome.scripting.executeScript({
|
||||||
target: {
|
target: {
|
||||||
|
@ -490,6 +497,7 @@ export class BrowserApi {
|
||||||
},
|
},
|
||||||
files: details.file ? [details.file] : null,
|
files: details.file ? [details.file] : null,
|
||||||
injectImmediately: details.runAt === "document_start",
|
injectImmediately: details.runAt === "document_start",
|
||||||
|
world: scriptingApiDetails?.world || "ISOLATED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
import { FilelessImportTypeKeys } from "../../enums/fileless-import.enums";
|
import { FilelessImportTypeKeys } from "../../enums/fileless-import.enums";
|
||||||
|
|
||||||
|
type SuppressDownloadScriptInjectionConfig = {
|
||||||
|
file: string;
|
||||||
|
scriptingApiDetails?: { world: chrome.scripting.ExecutionWorld };
|
||||||
|
};
|
||||||
|
|
||||||
type FilelessImportPortMessage = {
|
type FilelessImportPortMessage = {
|
||||||
command?: string;
|
command?: string;
|
||||||
importType?: FilelessImportTypeKeys;
|
importType?: FilelessImportTypeKeys;
|
||||||
|
@ -27,6 +32,7 @@ interface FilelessImporterBackground {
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
SuppressDownloadScriptInjectionConfig,
|
||||||
FilelessImportPortMessage,
|
FilelessImportPortMessage,
|
||||||
ImportNotificationMessageHandlers,
|
ImportNotificationMessageHandlers,
|
||||||
LpImporterMessageHandlers,
|
LpImporterMessageHandlers,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
|
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
|
||||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
|
@ -14,10 +15,20 @@ import {
|
||||||
sendPortMessage,
|
sendPortMessage,
|
||||||
triggerRuntimeOnConnectEvent,
|
triggerRuntimeOnConnectEvent,
|
||||||
} from "../../autofill/spec/testing-utils";
|
} from "../../autofill/spec/testing-utils";
|
||||||
|
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||||
import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import.enums";
|
import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import.enums";
|
||||||
|
|
||||||
import FilelessImporterBackground from "./fileless-importer.background";
|
import FilelessImporterBackground from "./fileless-importer.background";
|
||||||
|
|
||||||
|
jest.mock("rxjs", () => {
|
||||||
|
const rxjs = jest.requireActual("rxjs");
|
||||||
|
const { firstValueFrom } = rxjs;
|
||||||
|
return {
|
||||||
|
...rxjs,
|
||||||
|
firstValueFrom: jest.fn(firstValueFrom),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("FilelessImporterBackground ", () => {
|
describe("FilelessImporterBackground ", () => {
|
||||||
let filelessImporterBackground: FilelessImporterBackground;
|
let filelessImporterBackground: FilelessImporterBackground;
|
||||||
const configService = mock<ConfigService>();
|
const configService = mock<ConfigService>();
|
||||||
|
@ -51,14 +62,17 @@ describe("FilelessImporterBackground ", () => {
|
||||||
|
|
||||||
describe("handle ports onConnect", () => {
|
describe("handle ports onConnect", () => {
|
||||||
let lpImporterPort: chrome.runtime.Port;
|
let lpImporterPort: chrome.runtime.Port;
|
||||||
|
let manifestVersionSpy: jest.SpyInstance;
|
||||||
|
let executeScriptInTabSpy: jest.SpyInstance;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
lpImporterPort = createPortSpyMock(FilelessImportPort.LpImporter);
|
lpImporterPort = createPortSpyMock(FilelessImportPort.LpImporter);
|
||||||
|
manifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get");
|
||||||
|
executeScriptInTabSpy = jest.spyOn(BrowserApi, "executeScriptInTab").mockResolvedValue(null);
|
||||||
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
|
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||||
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
|
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
|
||||||
jest
|
jest.spyOn(filelessImporterBackground as any, "removeIndividualVault");
|
||||||
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
|
(firstValueFrom as jest.Mock).mockResolvedValue(false);
|
||||||
.mockResolvedValue(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores the port connection if the port name is not present in the set of filelessImportNames", async () => {
|
it("ignores the port connection if the port name is not present in the set of filelessImportNames", async () => {
|
||||||
|
@ -83,9 +97,7 @@ describe("FilelessImporterBackground ", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("posts a message to the port indicating that the fileless import feature is disabled if the user's policy removes individual vaults", async () => {
|
it("posts a message to the port indicating that the fileless import feature is disabled if the user's policy removes individual vaults", async () => {
|
||||||
jest
|
(firstValueFrom as jest.Mock).mockResolvedValue(true);
|
||||||
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
|
|
||||||
.mockResolvedValue(true);
|
|
||||||
|
|
||||||
triggerRuntimeOnConnectEvent(lpImporterPort);
|
triggerRuntimeOnConnectEvent(lpImporterPort);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
@ -117,6 +129,35 @@ describe("FilelessImporterBackground ", () => {
|
||||||
filelessImportEnabled: true,
|
filelessImportEnabled: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("triggers an injection of the `lp-suppress-import-download.js` script in manifest v3", async () => {
|
||||||
|
manifestVersionSpy.mockReturnValue(3);
|
||||||
|
|
||||||
|
triggerRuntimeOnConnectEvent(lpImporterPort);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(executeScriptInTabSpy).toHaveBeenCalledWith(
|
||||||
|
lpImporterPort.sender.tab.id,
|
||||||
|
{ file: "content/lp-suppress-import-download.js", runAt: "document_start" },
|
||||||
|
{ world: "MAIN" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers an injection of the `lp-suppress-import-download-script-append-mv2.js` script in manifest v2", async () => {
|
||||||
|
manifestVersionSpy.mockReturnValue(2);
|
||||||
|
|
||||||
|
triggerRuntimeOnConnectEvent(lpImporterPort);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(executeScriptInTabSpy).toHaveBeenCalledWith(
|
||||||
|
lpImporterPort.sender.tab.id,
|
||||||
|
{
|
||||||
|
file: "content/lp-suppress-import-download-script-append-mv2.js",
|
||||||
|
runAt: "document_start",
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("port messages", () => {
|
describe("port messages", () => {
|
||||||
|
@ -126,9 +167,7 @@ describe("FilelessImporterBackground ", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
|
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||||
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
|
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
|
||||||
jest
|
(firstValueFrom as jest.Mock).mockResolvedValue(false);
|
||||||
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
|
|
||||||
.mockResolvedValue(false);
|
|
||||||
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.NotificationBar));
|
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.NotificationBar));
|
||||||
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.LpImporter));
|
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.LpImporter));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { ImportServiceAbstraction } from "@bitwarden/importer/core";
|
||||||
|
|
||||||
import NotificationBackground from "../../autofill/background/notification.background";
|
import NotificationBackground from "../../autofill/background/notification.background";
|
||||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||||
|
import { FilelessImporterInjectedScriptsConfig } from "../config/fileless-importer-injected-scripts";
|
||||||
import {
|
import {
|
||||||
FilelessImportPort,
|
FilelessImportPort,
|
||||||
FilelessImportType,
|
FilelessImportType,
|
||||||
|
@ -22,6 +23,7 @@ import {
|
||||||
LpImporterMessageHandlers,
|
LpImporterMessageHandlers,
|
||||||
FilelessImporterBackground as FilelessImporterBackgroundInterface,
|
FilelessImporterBackground as FilelessImporterBackgroundInterface,
|
||||||
FilelessImportPortMessage,
|
FilelessImportPortMessage,
|
||||||
|
SuppressDownloadScriptInjectionConfig,
|
||||||
} from "./abstractions/fileless-importer.background";
|
} from "./abstractions/fileless-importer.background";
|
||||||
|
|
||||||
class FilelessImporterBackground implements FilelessImporterBackgroundInterface {
|
class FilelessImporterBackground implements FilelessImporterBackgroundInterface {
|
||||||
|
@ -108,6 +110,23 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
|
||||||
await this.notificationBackground.requestFilelessImport(tab, importType);
|
await this.notificationBackground.requestFilelessImport(tab, importType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects the script used to suppress the download of the LP importer export file.
|
||||||
|
*
|
||||||
|
* @param sender - The sender of the message.
|
||||||
|
* @param injectionConfig - The configuration for the injection.
|
||||||
|
*/
|
||||||
|
private async injectScriptConfig(
|
||||||
|
sender: chrome.runtime.MessageSender,
|
||||||
|
injectionConfig: SuppressDownloadScriptInjectionConfig,
|
||||||
|
) {
|
||||||
|
await BrowserApi.executeScriptInTab(
|
||||||
|
sender.tab.id,
|
||||||
|
{ file: injectionConfig.file, runAt: "document_start" },
|
||||||
|
injectionConfig.scriptingApiDetails,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers the download of the CSV file from the LP importer. This is triggered
|
* Triggers the download of the CSV file from the LP importer. This is triggered
|
||||||
* when the user opts to not save the export to Bitwarden within the notification bar.
|
* when the user opts to not save the export to Bitwarden within the notification bar.
|
||||||
|
@ -200,6 +219,12 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
|
||||||
switch (port.name) {
|
switch (port.name) {
|
||||||
case FilelessImportPort.LpImporter:
|
case FilelessImportPort.LpImporter:
|
||||||
this.lpImporterPort = port;
|
this.lpImporterPort = port;
|
||||||
|
await this.injectScriptConfig(
|
||||||
|
port.sender,
|
||||||
|
BrowserApi.manifestVersion === 3
|
||||||
|
? FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv3
|
||||||
|
: FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv2,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case FilelessImportPort.NotificationBar:
|
case FilelessImportPort.NotificationBar:
|
||||||
this.importNotificationsPort = port;
|
this.importNotificationsPort = port;
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { SuppressDownloadScriptInjectionConfig } from "../background/abstractions/fileless-importer.background";
|
||||||
|
|
||||||
|
type FilelessImporterInjectedScriptsConfigurations = {
|
||||||
|
LpSuppressImportDownload: {
|
||||||
|
mv2: SuppressDownloadScriptInjectionConfig;
|
||||||
|
mv3: SuppressDownloadScriptInjectionConfig;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const FilelessImporterInjectedScriptsConfig: FilelessImporterInjectedScriptsConfigurations = {
|
||||||
|
LpSuppressImportDownload: {
|
||||||
|
mv2: {
|
||||||
|
file: "content/lp-suppress-import-download-script-append-mv2.js",
|
||||||
|
},
|
||||||
|
mv3: {
|
||||||
|
file: "content/lp-suppress-import-download.js",
|
||||||
|
scriptingApiDetails: { world: "MAIN" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export { FilelessImporterInjectedScriptsConfig };
|
|
@ -43,20 +43,6 @@ describe("LpFilelessImporter", () => {
|
||||||
expect(portSpy.disconnect).toHaveBeenCalled();
|
expect(portSpy.disconnect).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("injects a script element that suppresses the download of the LastPass export", () => {
|
|
||||||
const script = document.createElement("script");
|
|
||||||
jest.spyOn(document, "createElement").mockReturnValue(script);
|
|
||||||
jest.spyOn(document.documentElement, "appendChild");
|
|
||||||
|
|
||||||
lpFilelessImporter.handleFeatureFlagVerification({ filelessImportEnabled: true });
|
|
||||||
|
|
||||||
expect(document.createElement).toHaveBeenCalledWith("script");
|
|
||||||
expect(document.documentElement.appendChild).toHaveBeenCalled();
|
|
||||||
expect(script.textContent).toContain(
|
|
||||||
"const defaultAppendChild = Element.prototype.appendChild;",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets up an event listener for DOMContentLoaded that triggers the importer when the document ready state is `loading`", () => {
|
it("sets up an event listener for DOMContentLoaded that triggers the importer when the document ready state is `loading`", () => {
|
||||||
Object.defineProperty(document, "readyState", {
|
Object.defineProperty(document, "readyState", {
|
||||||
value: "loading",
|
value: "loading",
|
||||||
|
|
|
@ -36,7 +36,6 @@ class LpFilelessImporter implements LpFilelessImporterInterface {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.suppressDownload();
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", this.loadImporter);
|
document.addEventListener("DOMContentLoaded", this.loadImporter);
|
||||||
return;
|
return;
|
||||||
|
@ -52,46 +51,6 @@ class LpFilelessImporter implements LpFilelessImporterInterface {
|
||||||
this.postWindowMessage({ command: "triggerCsvDownload" });
|
this.postWindowMessage({ command: "triggerCsvDownload" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Suppresses the download of the CSV file by overriding the `download` attribute of the
|
|
||||||
* anchor element that is created by the LP importer. This is done by injecting a script
|
|
||||||
* into the page that overrides the `appendChild` method of the `Element` prototype.
|
|
||||||
*/
|
|
||||||
private suppressDownload() {
|
|
||||||
const script = document.createElement("script");
|
|
||||||
script.textContent = `
|
|
||||||
let csvDownload = '';
|
|
||||||
let csvHref = '';
|
|
||||||
const defaultAppendChild = Element.prototype.appendChild;
|
|
||||||
Element.prototype.appendChild = function (newChild) {
|
|
||||||
if (newChild.nodeName.toLowerCase() === 'a' && newChild.download) {
|
|
||||||
csvDownload = newChild.download;
|
|
||||||
csvHref = newChild.href;
|
|
||||||
newChild.setAttribute('href', 'javascript:void(0)');
|
|
||||||
newChild.setAttribute('download', '');
|
|
||||||
Element.prototype.appendChild = defaultAppendChild;
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultAppendChild.call(this, newChild);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', (event) => {
|
|
||||||
const command = event.data?.command;
|
|
||||||
if (event.source !== window || command !== 'triggerCsvDownload') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.setAttribute('href', csvHref);
|
|
||||||
anchor.setAttribute('download', csvDownload);
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
document.body.removeChild(anchor);
|
|
||||||
});
|
|
||||||
`;
|
|
||||||
document.documentElement.appendChild(script);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the importing mechanism used to import the CSV file into Bitwarden.
|
* Initializes the importing mechanism used to import the CSV file into Bitwarden.
|
||||||
* This is done by observing the DOM for the addition of the LP importer element.
|
* This is done by observing the DOM for the addition of the LP importer element.
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
describe("LP Suppress Import Download for Manifest v2", () => {
|
||||||
|
it("appends the `lp-suppress-import-download.js` script to the document element", () => {
|
||||||
|
let createdScriptElement: HTMLScriptElement;
|
||||||
|
jest.spyOn(window.document, "createElement");
|
||||||
|
jest.spyOn(window.document.documentElement, "appendChild").mockImplementation((node) => {
|
||||||
|
createdScriptElement = node as HTMLScriptElement;
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
require("./lp-suppress-import-download-script-append.mv2");
|
||||||
|
|
||||||
|
expect(window.document.createElement).toHaveBeenCalledWith("script");
|
||||||
|
expect(chrome.runtime.getURL).toHaveBeenCalledWith("content/lp-suppress-import-download.js");
|
||||||
|
expect(window.document.documentElement.appendChild).toHaveBeenCalledWith(
|
||||||
|
expect.any(HTMLScriptElement),
|
||||||
|
);
|
||||||
|
expect(createdScriptElement.src).toBe(
|
||||||
|
"chrome-extension://id/content/lp-suppress-import-download.js",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* This script handles injection of the LP suppress import download script into the document.
|
||||||
|
* This is required for manifest v2, but will be removed when we migrate fully to manifest v3.
|
||||||
|
*/
|
||||||
|
(function (globalContext) {
|
||||||
|
const script = globalContext.document.createElement("script");
|
||||||
|
script.src = chrome.runtime.getURL("content/lp-suppress-import-download.js");
|
||||||
|
globalContext.document.documentElement.appendChild(script);
|
||||||
|
})(window);
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { flushPromises, postWindowMessage } from "../../autofill/spec/testing-utils";
|
||||||
|
|
||||||
|
describe("LP Suppress Import Download", () => {
|
||||||
|
const downloadAttribute = "file.csv";
|
||||||
|
const hrefAttribute = "https://example.com/file.csv";
|
||||||
|
const overridenHrefAttribute = "javascript:void(0)";
|
||||||
|
let anchor: HTMLAnchorElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Element.prototype, "appendChild");
|
||||||
|
jest.spyOn(window, "addEventListener");
|
||||||
|
|
||||||
|
require("./lp-suppress-import-download");
|
||||||
|
|
||||||
|
anchor = document.createElement("a");
|
||||||
|
anchor.download = downloadAttribute;
|
||||||
|
anchor.href = hrefAttribute;
|
||||||
|
anchor.click = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the automatic download anchor", () => {
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
|
||||||
|
expect(anchor.href).toBe(overridenHrefAttribute);
|
||||||
|
expect(anchor.download).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers the CSVDownload when receiving a `triggerCsvDownload` window message", async () => {
|
||||||
|
window.document.createElement = jest.fn(() => anchor);
|
||||||
|
jest.spyOn(window, "removeEventListener");
|
||||||
|
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
|
||||||
|
// Precondition - Ensure the anchor in the document has overridden href and download attributes
|
||||||
|
expect(anchor.href).toBe(overridenHrefAttribute);
|
||||||
|
expect(anchor.download).toBe("");
|
||||||
|
|
||||||
|
postWindowMessage({ command: "triggerCsvDownload" });
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(anchor.click).toHaveBeenCalled();
|
||||||
|
expect(anchor.href).toEqual(hrefAttribute);
|
||||||
|
expect(anchor.download).toEqual(downloadAttribute);
|
||||||
|
expect(window.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips subsequent calls to trigger a CSVDownload", async () => {
|
||||||
|
window.document.createElement = jest.fn(() => anchor);
|
||||||
|
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
|
||||||
|
postWindowMessage({ command: "triggerCsvDownload" });
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
postWindowMessage({ command: "triggerCsvDownload" });
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(anchor.click).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips triggering the CSV download for window messages that do not have the correct command", () => {
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
|
||||||
|
postWindowMessage({ command: "notTriggerCsvDownload" });
|
||||||
|
|
||||||
|
expect(anchor.click).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips triggering the CSV download for window messages that do not have a data value", () => {
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
|
||||||
|
postWindowMessage(null);
|
||||||
|
|
||||||
|
expect(anchor.click).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Handles intercepting the injection of the CSV download link, and ensures the
|
||||||
|
* download of the script is suppressed until the user opts to download the file.
|
||||||
|
* The download is triggered by a window message sent from the LpFilelessImporter
|
||||||
|
* content script.
|
||||||
|
*/
|
||||||
|
(function (globalContext) {
|
||||||
|
let csvDownload = "";
|
||||||
|
let csvHref = "";
|
||||||
|
let isCsvDownloadTriggered = false;
|
||||||
|
const defaultAppendChild = Element.prototype.appendChild;
|
||||||
|
Element.prototype.appendChild = function (newChild: Node) {
|
||||||
|
if (isAnchorElement(newChild) && newChild.download) {
|
||||||
|
csvDownload = newChild.download;
|
||||||
|
csvHref = newChild.href;
|
||||||
|
newChild.setAttribute("href", "javascript:void(0)");
|
||||||
|
newChild.setAttribute("download", "");
|
||||||
|
Element.prototype.appendChild = defaultAppendChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultAppendChild.call(this, newChild);
|
||||||
|
};
|
||||||
|
|
||||||
|
function isAnchorElement(node: Node): node is HTMLAnchorElement {
|
||||||
|
return node.nodeName.toLowerCase() === "a";
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWindowMessage = (event: MessageEvent) => {
|
||||||
|
const command = event.data?.command;
|
||||||
|
if (
|
||||||
|
event.source !== globalContext ||
|
||||||
|
command !== "triggerCsvDownload" ||
|
||||||
|
isCsvDownloadTriggered
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCsvDownloadTriggered = true;
|
||||||
|
globalContext.removeEventListener("message", handleWindowMessage);
|
||||||
|
|
||||||
|
const anchor = globalContext.document.createElement("a");
|
||||||
|
anchor.setAttribute("href", csvHref);
|
||||||
|
anchor.setAttribute("download", csvDownload);
|
||||||
|
globalContext.document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
globalContext.document.body.removeChild(anchor);
|
||||||
|
};
|
||||||
|
|
||||||
|
globalContext.addEventListener("message", handleWindowMessage);
|
||||||
|
})(window);
|
|
@ -179,6 +179,7 @@ const mainConfig = {
|
||||||
"overlay/list": "./src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts",
|
"overlay/list": "./src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts",
|
||||||
"encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts",
|
"encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts",
|
||||||
"content/lp-fileless-importer": "./src/tools/content/lp-fileless-importer.ts",
|
"content/lp-fileless-importer": "./src/tools/content/lp-fileless-importer.ts",
|
||||||
|
"content/lp-suppress-import-download": "./src/tools/content/lp-suppress-import-download.ts",
|
||||||
},
|
},
|
||||||
optimization: {
|
optimization: {
|
||||||
minimize: ENV !== "development",
|
minimize: ENV !== "development",
|
||||||
|
@ -276,6 +277,8 @@ if (manifestVersion == 2) {
|
||||||
// Manifest V2 background pages can be run through the regular build pipeline.
|
// Manifest V2 background pages can be run through the regular build pipeline.
|
||||||
// Since it's a standard webpage.
|
// Since it's a standard webpage.
|
||||||
mainConfig.entry.background = "./src/platform/background.ts";
|
mainConfig.entry.background = "./src/platform/background.ts";
|
||||||
|
mainConfig.entry["content/lp-suppress-import-download-script-append-mv2"] =
|
||||||
|
"./src/tools/content/lp-suppress-import-download-script-append.mv2.ts";
|
||||||
|
|
||||||
configs.push(mainConfig);
|
configs.push(mainConfig);
|
||||||
} else {
|
} else {
|
||||||
|
|
Loading…
Reference in New Issue