mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-08 00:01:28 +01:00
[PM-4032] Detect LastPass export page and suppress download prompt (#6398)
* Import libs/importer and instantiate ImportService * Create ImportApi and ImportService factories * Add libs/importer to desktop * [PM-4075] Setup Feature Flag for Browser Fileless Import * [PM-4032] Detect Lastpass Export Page and Supress Download Prompt * [PM-4032] Implementing jest tests for the FilelessImporterBackground class * [PM-4075] Setup Feature Flag for Browser Fileless Import (#6391) * [PM-4032] Implementing jest tests for the FilelessImporterBackground class * [PM-4032] Implementing jest tests for the LpFilelessImporter class * [PM-4032] Modifying variable name * [PM-4032] Modifying verbiage on jest tests to feature present tense actions and updating behavior within FilelessImporterBackground to ensure logic is triggered on expected port names only * [PM-4032] Modifying documentation present above handlePortOnConnect method * [PM-4032] Modifying documentation present above handlePortOnConnect method * [PM-4032] Applying early return if the user has a policy that removes the individual vault * [PM-4032] Reverting change made to notification bar * [PM-4032] Applying a static declaration to the filelessImporterPortNames property * [PM-4032] Modifying test for handlPortOnConnect method to follow provided feedback * [PM-4032] Applying feedback to jest test messages * [PM-4032] Reworking LpFilelessImporter to simplify testing structure and leverage public facing methods rather than testing private methods * [PM-4032] Reworking FilelessImporterBackground class * [PM-4032] Adding implementation details that facilitate triggering onMessage listeners within a chrome.runtime.connection port * [PM-4032] Implementing reworked jest tests for FilelessImporterBackground * [PM-4032] Adjusting naming of jest test * [PM-4032] Reworking test setup implementation to better fit approach discussed within overlay PR work * [PM-4032] Running prettier * [PM-4032] Removing added line break * [PM-4033] Display import prompt to user (#6407) * [PM-4033] Display Import Prompt to the User * [PM-4033] Adding jest tests for LpFilelessImporter * [PM-4033] Finalization of Jest tests * [PM-4033] Finalization of Jest tests * [PM-4033] Removing values from the whitelist capital letters file * [PM-4033] Addressing jest test coverage in LpFilelessImporter * [PM-4033] Addressing jest test coverage in LpFilelessImporter * [PM-4033] Adding documentation to the added methods within the NotificationBackground class * [PM-4033] Adding documentation to the added methods within the bar.ts content script * [PM-4033] Removing unnecessary method within lp-fileless-importer content script * [PM-4033] Removing method that is not currently used within FilelessImporterBackground * [PM-4033] Adding jest tests for the implementation * [PM-4035] Import LastPass Export Page Data (#6408) * [PM-4035] Import LastPass Export Page Data * [PM-4035] Import LastPass Export Page Data * [PM-4035] Adding jest tests for the LPFilelessImporter class * [PM-4035] Adding jest tests for the FilelessImporterBackground class * [PM-4035] Fixing references to innerHtml and updating them to reference textContent * [PM-4035] Removing eslint-disable and adding logging service to bar.ts * [PM-4035] Adding typing information to LpImporter classes * [PM-4035] Adding typing information to LpImporter classes * [PM-4035] Adding typing information for FilelessImportType * [PM-4035] Updating type reference for the filelessImportType * [PM-4032] Refactoring implementation for NotificationBackground.doNotificationQueueCheck to remove repetition * [PM-4032] Refactoring early returns within NotificationBackground * [PM-4032] Adding context for a test case for the FilelessImporterBackground * [PM-4032] Removing magic number by referencing the notification port within a test for cancelFilelessImport * [PM-4032] Adding documentation to the constructor * [PM-4032] Switching logic to use a switch statement rather than multiple if statements * [PM-4032] Removing unnecessary early return from the mutation observer implementation within `lp-fileless-importer.ts * [PM-4032] Adding return to the mutation observer when we have found the expected text node * [PM-4032] Refactoring implementation of the handleMutation method * [PM-4032] Modifying implementation for pushUnlocKVaultToQueue to send a notification message instead of adding the item to the queue * [PM-4032] Modifying implementation for how we handle mutations within the lp-fileless-importer * [PM-4032] Adding space between the iteration over added nodes within LpFilelessImporter.handleMutation * [PM-4032] Reworking logic within the doNotificationCheck method to remove for loop --------- Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
This commit is contained in:
parent
a1e649e809
commit
6ca303f4ae
@ -2497,6 +2497,38 @@
|
|||||||
"message": "Toggle collapse",
|
"message": "Toggle collapse",
|
||||||
"description": "Toggling an expand/collapse state."
|
"description": "Toggling an expand/collapse state."
|
||||||
},
|
},
|
||||||
|
"filelessImport": {
|
||||||
|
"message": "Import your data to Bitwarden?",
|
||||||
|
"description": "Default notification title for triggering a fileless import."
|
||||||
|
},
|
||||||
|
"lpFilelessImport": {
|
||||||
|
"message": "Protect your LastPass data and import to Bitwarden?",
|
||||||
|
"description": "LastPass specific notification title for triggering a fileless import."
|
||||||
|
},
|
||||||
|
"lpCancelFilelessImport": {
|
||||||
|
"message": "Save as unencrypted file",
|
||||||
|
"description": "LastPass specific notification button text for cancelling a fileless import."
|
||||||
|
},
|
||||||
|
"startFilelessImport": {
|
||||||
|
"message": "Import to Bitwarden",
|
||||||
|
"description": "Notification button text for starting a fileless import."
|
||||||
|
},
|
||||||
|
"importing": {
|
||||||
|
"message": "Importing...",
|
||||||
|
"description": "Notification message for when an import is in progress."
|
||||||
|
},
|
||||||
|
"dataSuccessfullyImported": {
|
||||||
|
"message": "Data successfully imported!",
|
||||||
|
"description": "Notification message for when an import has completed successfully."
|
||||||
|
},
|
||||||
|
"dataImportFailed": {
|
||||||
|
"message": "Error importing. Check console for details.",
|
||||||
|
"description": "Notification message for when an import has failed."
|
||||||
|
},
|
||||||
|
"importNetworkError": {
|
||||||
|
"message": "Network error encountered during import.",
|
||||||
|
"description": "Notification message for when an import has failed due to a network error."
|
||||||
|
},
|
||||||
"aliasDomain": {
|
"aliasDomain": {
|
||||||
"message": "Alias domain"
|
"message": "Alias domain"
|
||||||
},
|
},
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
import AddChangePasswordQueueMessage from "../../notification/models/add-change-password-queue-message";
|
||||||
|
import AddLoginQueueMessage from "../../notification/models/add-login-queue-message";
|
||||||
|
import AddRequestFilelessImportQueueMessage from "../../notification/models/add-request-fileless-import-queue-message";
|
||||||
|
import AddUnlockVaultQueueMessage from "../../notification/models/add-unlock-vault-queue-message";
|
||||||
|
|
||||||
|
type NotificationQueueMessageItem =
|
||||||
|
| AddLoginQueueMessage
|
||||||
|
| AddChangePasswordQueueMessage
|
||||||
|
| AddUnlockVaultQueueMessage
|
||||||
|
| AddRequestFilelessImportQueueMessage;
|
||||||
|
|
||||||
|
export { NotificationQueueMessageItem };
|
@ -20,18 +20,20 @@ import { NOTIFICATION_BAR_LIFESPAN_MS } from "../constants";
|
|||||||
import AddChangePasswordQueueMessage from "../notification/models/add-change-password-queue-message";
|
import AddChangePasswordQueueMessage from "../notification/models/add-change-password-queue-message";
|
||||||
import AddLoginQueueMessage from "../notification/models/add-login-queue-message";
|
import AddLoginQueueMessage from "../notification/models/add-login-queue-message";
|
||||||
import AddLoginRuntimeMessage from "../notification/models/add-login-runtime-message";
|
import AddLoginRuntimeMessage from "../notification/models/add-login-runtime-message";
|
||||||
|
import AddRequestFilelessImportQueueMessage from "../notification/models/add-request-fileless-import-queue-message";
|
||||||
import AddUnlockVaultQueueMessage from "../notification/models/add-unlock-vault-queue-message";
|
import AddUnlockVaultQueueMessage from "../notification/models/add-unlock-vault-queue-message";
|
||||||
import ChangePasswordRuntimeMessage from "../notification/models/change-password-runtime-message";
|
import ChangePasswordRuntimeMessage from "../notification/models/change-password-runtime-message";
|
||||||
import LockedVaultPendingNotificationsItem from "../notification/models/locked-vault-pending-notifications-item";
|
import LockedVaultPendingNotificationsItem from "../notification/models/locked-vault-pending-notifications-item";
|
||||||
import { NotificationQueueMessageType } from "../notification/models/notification-queue-message-type";
|
import {
|
||||||
|
NotificationQueueMessageType,
|
||||||
|
NotificationTypes,
|
||||||
|
} from "../notification/models/notification-queue-message-type";
|
||||||
import { AutofillService } from "../services/abstractions/autofill.service";
|
import { AutofillService } from "../services/abstractions/autofill.service";
|
||||||
|
|
||||||
|
import { NotificationQueueMessageItem } from "./abstractions/notification.background";
|
||||||
|
|
||||||
export default class NotificationBackground {
|
export default class NotificationBackground {
|
||||||
private notificationQueue: (
|
private notificationQueue: NotificationQueueMessageItem[] = [];
|
||||||
| AddLoginQueueMessage
|
|
||||||
| AddChangePasswordQueueMessage
|
|
||||||
| AddUnlockVaultQueueMessage
|
|
||||||
)[] = [];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private autofillService: AutofillService,
|
private autofillService: AutofillService,
|
||||||
@ -162,53 +164,49 @@ export default class NotificationBackground {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async doNotificationQueueCheck(tab: chrome.tabs.Tab): Promise<void> {
|
private async doNotificationQueueCheck(tab: chrome.tabs.Tab): Promise<void> {
|
||||||
if (tab == null) {
|
const tabDomain = Utils.getDomain(tab?.url);
|
||||||
|
if (!tabDomain) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabDomain = Utils.getDomain(tab.url);
|
const queueMessage = this.notificationQueue.find(
|
||||||
if (tabDomain == null) {
|
(message) => message.tab.id === tab.id && message.domain === tabDomain,
|
||||||
return;
|
);
|
||||||
|
if (queueMessage) {
|
||||||
|
this.sendNotificationQueueMessage(tab, queueMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendNotificationQueueMessage(
|
||||||
|
tab: chrome.tabs.Tab,
|
||||||
|
notificationQueueMessage: NotificationQueueMessageItem,
|
||||||
|
) {
|
||||||
|
const notificationType = notificationQueueMessage.type;
|
||||||
|
const webVaultURL =
|
||||||
|
notificationType !== NotificationQueueMessageType.UnlockVault
|
||||||
|
? this.environmentService.getWebVaultUrl()
|
||||||
|
: null;
|
||||||
|
const typeData: Record<string, any> = {
|
||||||
|
isVaultLocked: notificationQueueMessage.wasVaultLocked,
|
||||||
|
theme: await this.getCurrentTheme(),
|
||||||
|
webVaultURL,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (notificationType) {
|
||||||
|
case NotificationQueueMessageType.AddLogin:
|
||||||
|
typeData.removeIndividualVault = await this.removeIndividualVault();
|
||||||
|
break;
|
||||||
|
case NotificationQueueMessageType.RequestFilelessImport:
|
||||||
|
typeData.importType = (
|
||||||
|
notificationQueueMessage as AddRequestFilelessImportQueueMessage
|
||||||
|
).importType;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < this.notificationQueue.length; i++) {
|
await BrowserApi.tabSendMessageData(tab, "openNotificationBar", {
|
||||||
if (
|
type: NotificationTypes[notificationType],
|
||||||
this.notificationQueue[i].tab.id !== tab.id ||
|
typeData,
|
||||||
this.notificationQueue[i].domain !== tabDomain
|
});
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.notificationQueue[i].type === NotificationQueueMessageType.AddLogin) {
|
|
||||||
BrowserApi.tabSendMessageData(tab, "openNotificationBar", {
|
|
||||||
type: "add",
|
|
||||||
typeData: {
|
|
||||||
isVaultLocked: this.notificationQueue[i].wasVaultLocked,
|
|
||||||
theme: await this.getCurrentTheme(),
|
|
||||||
removeIndividualVault: await this.removeIndividualVault(),
|
|
||||||
webVaultURL: await this.environmentService.getWebVaultUrl(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (this.notificationQueue[i].type === NotificationQueueMessageType.ChangePassword) {
|
|
||||||
BrowserApi.tabSendMessageData(tab, "openNotificationBar", {
|
|
||||||
type: "change",
|
|
||||||
typeData: {
|
|
||||||
isVaultLocked: this.notificationQueue[i].wasVaultLocked,
|
|
||||||
theme: await this.getCurrentTheme(),
|
|
||||||
webVaultURL: await this.environmentService.getWebVaultUrl(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (this.notificationQueue[i].type === NotificationQueueMessageType.UnlockVault) {
|
|
||||||
BrowserApi.tabSendMessageData(tab, "openNotificationBar", {
|
|
||||||
type: "unlock",
|
|
||||||
typeData: {
|
|
||||||
isVaultLocked: this.notificationQueue[i].wasVaultLocked,
|
|
||||||
theme: await this.getCurrentTheme(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getCurrentTheme() {
|
private async getCurrentTheme() {
|
||||||
@ -351,11 +349,28 @@ export default class NotificationBackground {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loginDomain = Utils.getDomain(tab.url);
|
const loginDomain = Utils.getDomain(tab.url);
|
||||||
if (!loginDomain) {
|
if (loginDomain) {
|
||||||
|
this.pushUnlockVaultToQueue(loginDomain, tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a notification to request a fileless import when the user
|
||||||
|
* attempts to trigger an import from a third party website.
|
||||||
|
*
|
||||||
|
* @param tab - The tab that we are sending the notification to
|
||||||
|
* @param importType - The type of import that is being requested
|
||||||
|
*/
|
||||||
|
async requestFilelessImport(tab: chrome.tabs.Tab, importType: string) {
|
||||||
|
const currentAuthStatus = await this.authService.getAuthStatus();
|
||||||
|
if (currentAuthStatus !== AuthenticationStatus.Unlocked || this.notificationQueue.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pushUnlockVaultToQueue(loginDomain, tab);
|
const loginDomain = Utils.getDomain(tab.url);
|
||||||
|
if (loginDomain) {
|
||||||
|
this.pushRequestFilelessImportToQueue(loginDomain, tab, importType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pushChangePasswordToQueue(
|
private async pushChangePasswordToQueue(
|
||||||
@ -389,6 +404,32 @@ export default class NotificationBackground {
|
|||||||
expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds
|
expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds
|
||||||
wasVaultLocked: true,
|
wasVaultLocked: true,
|
||||||
};
|
};
|
||||||
|
await this.sendNotificationQueueMessage(tab, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes a request to start a fileless import to the notification queue.
|
||||||
|
* This will display a notification bar to the user, prompting them to
|
||||||
|
* start the import.
|
||||||
|
*
|
||||||
|
* @param loginDomain - The domain of the tab that we are sending the notification to
|
||||||
|
* @param tab - The tab that we are sending the notification to
|
||||||
|
* @param importType - The type of import that is being requested
|
||||||
|
*/
|
||||||
|
private async pushRequestFilelessImportToQueue(
|
||||||
|
loginDomain: string,
|
||||||
|
tab: chrome.tabs.Tab,
|
||||||
|
importType?: string,
|
||||||
|
) {
|
||||||
|
this.removeTabFromNotificationQueue(tab);
|
||||||
|
const message: AddRequestFilelessImportQueueMessage = {
|
||||||
|
type: NotificationQueueMessageType.RequestFilelessImport,
|
||||||
|
domain: loginDomain,
|
||||||
|
tab,
|
||||||
|
expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds
|
||||||
|
wasVaultLocked: false,
|
||||||
|
importType,
|
||||||
|
};
|
||||||
this.notificationQueue.push(message);
|
this.notificationQueue.push(message);
|
||||||
await this.checkNotificationQueue(tab);
|
await this.checkNotificationQueue(tab);
|
||||||
this.removeTabFromNotificationQueue(tab);
|
this.removeTabFromNotificationQueue(tab);
|
||||||
|
@ -847,6 +847,7 @@ async function loadNotificationBar() {
|
|||||||
theme: typeData.theme,
|
theme: typeData.theme,
|
||||||
removeIndividualVault: typeData.removeIndividualVault,
|
removeIndividualVault: typeData.removeIndividualVault,
|
||||||
webVaultURL: typeData.webVaultURL,
|
webVaultURL: typeData.webVaultURL,
|
||||||
|
importType: typeData.importType,
|
||||||
};
|
};
|
||||||
const barQueryString = new URLSearchParams(barQueryParams).toString();
|
const barQueryString = new URLSearchParams(barQueryParams).toString();
|
||||||
const barPage = "notification/bar.html?" + barQueryString;
|
const barPage = "notification/bar.html?" + barQueryString;
|
||||||
|
@ -250,6 +250,7 @@ function createPortSpyMock(name: string) {
|
|||||||
addListener: jest.fn(),
|
addListener: jest.fn(),
|
||||||
},
|
},
|
||||||
postMessage: jest.fn(),
|
postMessage: jest.fn(),
|
||||||
|
disconnect: jest.fn(),
|
||||||
sender: {
|
sender: {
|
||||||
tab: createChromeTabMock(),
|
tab: createChromeTabMock(),
|
||||||
},
|
},
|
||||||
|
@ -32,6 +32,15 @@ function sendExtensionRuntimeMessage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) {
|
||||||
|
(chrome.runtime.onConnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
|
||||||
|
(call) => {
|
||||||
|
const callback = call[0];
|
||||||
|
callback(port);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function sendPortMessage(port: chrome.runtime.Port, message: any) {
|
function sendPortMessage(port: chrome.runtime.Port, message: any) {
|
||||||
(port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
(port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
||||||
const callback = call[0];
|
const callback = call[0];
|
||||||
@ -94,6 +103,7 @@ export {
|
|||||||
flushPromises,
|
flushPromises,
|
||||||
postWindowMessage,
|
postWindowMessage,
|
||||||
sendExtensionRuntimeMessage,
|
sendExtensionRuntimeMessage,
|
||||||
|
triggerRuntimeOnConnectEvent,
|
||||||
sendPortMessage,
|
sendPortMessage,
|
||||||
triggerPortOnDisconnectEvent,
|
triggerPortOnDisconnectEvent,
|
||||||
triggerWindowOnFocusedChangedEvent,
|
triggerWindowOnFocusedChangedEvent,
|
||||||
|
@ -60,4 +60,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="template-fileless-import">
|
||||||
|
<div class="inner-wrapper">
|
||||||
|
<div id="fileless-import-text"></div>
|
||||||
|
<div id="fileless-import-buttons">
|
||||||
|
<button type="button" id="cancel-fileless-import" class="secondary"></button>
|
||||||
|
<button type="button" id="start-fileless-import" class="primary"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</html>
|
</html>
|
||||||
|
@ -145,6 +145,18 @@ button {
|
|||||||
font-family: $font-family-sans-serif;
|
font-family: $font-family-sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
@include themify($themes) {
|
||||||
|
color: themed("successColor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
@include themify($themes) {
|
||||||
|
color: themed("errorColor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
#select-folder {
|
#select-folder {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import type { Jsonify } from "type-fest";
|
import type { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||||
import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||||
|
|
||||||
|
import { FilelessImportPort, FilelessImportType } from "../../tools/enums/fileless-import.enums";
|
||||||
|
|
||||||
require("./bar.scss");
|
require("./bar.scss");
|
||||||
|
|
||||||
|
const logService = new ConsoleLogService(false);
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
// delay 50ms so that we get proper body dimensions
|
// delay 50ms so that we get proper body dimensions
|
||||||
setTimeout(load, 50);
|
setTimeout(load, 50);
|
||||||
@ -30,6 +35,11 @@ function load() {
|
|||||||
notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"),
|
notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"),
|
||||||
notificationUnlock: chrome.i18n.getMessage("notificationUnlock"),
|
notificationUnlock: chrome.i18n.getMessage("notificationUnlock"),
|
||||||
notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"),
|
notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"),
|
||||||
|
filelessImport: chrome.i18n.getMessage("filelessImport"),
|
||||||
|
lpFilelessImport: chrome.i18n.getMessage("lpFilelessImport"),
|
||||||
|
cancelFilelessImport: chrome.i18n.getMessage("no"),
|
||||||
|
lpCancelFilelessImport: chrome.i18n.getMessage("lpCancelFilelessImport"),
|
||||||
|
startFilelessImport: chrome.i18n.getMessage("startFilelessImport"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const logoLink = document.getElementById("logo-link") as HTMLAnchorElement;
|
const logoLink = document.getElementById("logo-link") as HTMLAnchorElement;
|
||||||
@ -74,6 +84,7 @@ function load() {
|
|||||||
|
|
||||||
changeTemplate.content.getElementById("change-text").textContent = i18n.notificationChangeDesc;
|
changeTemplate.content.getElementById("change-text").textContent = i18n.notificationChangeDesc;
|
||||||
|
|
||||||
|
// i18n for "Unlock" (unlock extension) template
|
||||||
const unlockTemplate = document.getElementById("template-unlock") as HTMLTemplateElement;
|
const unlockTemplate = document.getElementById("template-unlock") as HTMLTemplateElement;
|
||||||
|
|
||||||
const unlockButton = unlockTemplate.content.getElementById("unlock-vault");
|
const unlockButton = unlockTemplate.content.getElementById("unlock-vault");
|
||||||
@ -81,6 +92,22 @@ function load() {
|
|||||||
|
|
||||||
unlockTemplate.content.getElementById("unlock-text").textContent = i18n.notificationUnlockDesc;
|
unlockTemplate.content.getElementById("unlock-text").textContent = i18n.notificationUnlockDesc;
|
||||||
|
|
||||||
|
// i18n for "Fileless Import" (fileless-import) template
|
||||||
|
const isLpImport = getQueryVariable("importType") === FilelessImportType.LP;
|
||||||
|
const importTemplate = document.getElementById("template-fileless-import") as HTMLTemplateElement;
|
||||||
|
|
||||||
|
const startImportButton = importTemplate.content.getElementById("start-fileless-import");
|
||||||
|
startImportButton.textContent = i18n.startFilelessImport;
|
||||||
|
|
||||||
|
const cancelImportButton = importTemplate.content.getElementById("cancel-fileless-import");
|
||||||
|
cancelImportButton.textContent = isLpImport
|
||||||
|
? i18n.lpCancelFilelessImport
|
||||||
|
: i18n.cancelFilelessImport;
|
||||||
|
|
||||||
|
importTemplate.content.getElementById("fileless-import-text").textContent = isLpImport
|
||||||
|
? i18n.lpFilelessImport
|
||||||
|
: i18n.filelessImport;
|
||||||
|
|
||||||
// i18n for body content
|
// i18n for body content
|
||||||
const closeButton = document.getElementById("close-button");
|
const closeButton = document.getElementById("close-button");
|
||||||
closeButton.title = i18n.close;
|
closeButton.title = i18n.close;
|
||||||
@ -91,6 +118,8 @@ function load() {
|
|||||||
handleTypeChange();
|
handleTypeChange();
|
||||||
} else if (getQueryVariable("type") === "unlock") {
|
} else if (getQueryVariable("type") === "unlock") {
|
||||||
handleTypeUnlock();
|
handleTypeUnlock();
|
||||||
|
} else if (getQueryVariable("type") === "fileless-import") {
|
||||||
|
handleTypeFilelessImport();
|
||||||
}
|
}
|
||||||
|
|
||||||
closeButton.addEventListener("click", (e) => {
|
closeButton.addEventListener("click", (e) => {
|
||||||
@ -194,6 +223,54 @@ function handleTypeUnlock() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a port to communicate with the fileless importer content script.
|
||||||
|
* This connection to the background script is used to trigger the action of
|
||||||
|
* downloading the CSV file from the LP importer or importing the data into
|
||||||
|
* the Bitwarden vault.
|
||||||
|
*/
|
||||||
|
function handleTypeFilelessImport() {
|
||||||
|
const importType = getQueryVariable("importType");
|
||||||
|
const port = chrome.runtime.connect({ name: FilelessImportPort.NotificationBar });
|
||||||
|
setContent(document.getElementById("template-fileless-import") as HTMLTemplateElement);
|
||||||
|
|
||||||
|
const startFilelessImportButton = document.getElementById("start-fileless-import");
|
||||||
|
const startFilelessImport = () => {
|
||||||
|
port.postMessage({ command: "startFilelessImport", importType });
|
||||||
|
document.getElementById("fileless-import-buttons").textContent =
|
||||||
|
chrome.i18n.getMessage("importing");
|
||||||
|
startFilelessImportButton.removeEventListener("click", startFilelessImport);
|
||||||
|
};
|
||||||
|
startFilelessImportButton.addEventListener("click", startFilelessImport);
|
||||||
|
|
||||||
|
const cancelFilelessImportButton = document.getElementById("cancel-fileless-import");
|
||||||
|
cancelFilelessImportButton.addEventListener("click", () => {
|
||||||
|
port.postMessage({ command: "cancelFilelessImport", importType });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePortMessage = (msg: any) => {
|
||||||
|
if (msg.command !== "filelessImportCompleted" && msg.command !== "filelessImportFailed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
port.disconnect();
|
||||||
|
|
||||||
|
if (msg.command === "filelessImportCompleted") {
|
||||||
|
document.getElementById("fileless-import-buttons").textContent = chrome.i18n.getMessage(
|
||||||
|
"dataSuccessfullyImported",
|
||||||
|
);
|
||||||
|
document.getElementById("fileless-import-buttons").classList.add("success-message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("fileless-import-buttons").textContent =
|
||||||
|
chrome.i18n.getMessage("dataImportFailed");
|
||||||
|
document.getElementById("fileless-import-buttons").classList.add("error-message");
|
||||||
|
logService.error(`Error Encountered During Import: ${msg.importErrorMessage}`);
|
||||||
|
};
|
||||||
|
port.onMessage.addListener(handlePortMessage);
|
||||||
|
}
|
||||||
|
|
||||||
function setContent(template: HTMLTemplateElement) {
|
function setContent(template: HTMLTemplateElement) {
|
||||||
const content = document.getElementById("content");
|
const content = document.getElementById("content");
|
||||||
while (content.firstChild) {
|
while (content.firstChild) {
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
import NotificationQueueMessage from "./notification-queue-message";
|
||||||
|
import { NotificationQueueMessageType } from "./notification-queue-message-type";
|
||||||
|
|
||||||
|
export default class AddRequestFilelessImportQueueMessage extends NotificationQueueMessage {
|
||||||
|
type: NotificationQueueMessageType.RequestFilelessImport;
|
||||||
|
importType?: string;
|
||||||
|
}
|
@ -1,5 +1,15 @@
|
|||||||
export enum NotificationQueueMessageType {
|
enum NotificationQueueMessageType {
|
||||||
AddLogin = 0,
|
AddLogin = 0,
|
||||||
ChangePassword = 1,
|
ChangePassword = 1,
|
||||||
UnlockVault = 2,
|
UnlockVault = 2,
|
||||||
|
RequestFilelessImport = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NotificationTypes = {
|
||||||
|
[NotificationQueueMessageType.AddLogin]: "add",
|
||||||
|
[NotificationQueueMessageType.ChangePassword]: "change",
|
||||||
|
[NotificationQueueMessageType.UnlockVault]: "unlock",
|
||||||
|
[NotificationQueueMessageType.RequestFilelessImport]: "fileless-import",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export { NotificationQueueMessageType, NotificationTypes };
|
||||||
|
@ -24,6 +24,12 @@ $solarizedDarkBase2: #eee8d5;
|
|||||||
$solarizedDarkCyan: #2aa198;
|
$solarizedDarkCyan: #2aa198;
|
||||||
$solarizedDarkGreen: #859900;
|
$solarizedDarkGreen: #859900;
|
||||||
|
|
||||||
|
$success-color-light: #017e45;
|
||||||
|
$success-color-dark: #8db89b;
|
||||||
|
|
||||||
|
$error-color-light: #c83522;
|
||||||
|
$error-color-dark: #ee9792;
|
||||||
|
|
||||||
$themes: (
|
$themes: (
|
||||||
light: (
|
light: (
|
||||||
textColor: $text-color,
|
textColor: $text-color,
|
||||||
@ -37,6 +43,8 @@ $themes: (
|
|||||||
inputBackgroundColor: #ffffff,
|
inputBackgroundColor: #ffffff,
|
||||||
borderColor: $border-color,
|
borderColor: $border-color,
|
||||||
focusOutlineColor: $focus-outline-color,
|
focusOutlineColor: $focus-outline-color,
|
||||||
|
successColor: $success-color-light,
|
||||||
|
errorColor: $error-color-light,
|
||||||
),
|
),
|
||||||
dark: (
|
dark: (
|
||||||
textColor: #ffffff,
|
textColor: #ffffff,
|
||||||
@ -50,6 +58,8 @@ $themes: (
|
|||||||
inputBackgroundColor: #2f343d,
|
inputBackgroundColor: #2f343d,
|
||||||
borderColor: #4c525f,
|
borderColor: #4c525f,
|
||||||
focusOutlineColor: $focus-outline-color,
|
focusOutlineColor: $focus-outline-color,
|
||||||
|
successColor: $success-color-dark,
|
||||||
|
errorColor: $error-color-dark,
|
||||||
),
|
),
|
||||||
nord: (
|
nord: (
|
||||||
textColor: $nord5,
|
textColor: $nord5,
|
||||||
@ -63,6 +73,7 @@ $themes: (
|
|||||||
inputBackgroundColor: $nord2,
|
inputBackgroundColor: $nord2,
|
||||||
borderColor: $nord0,
|
borderColor: $nord0,
|
||||||
focusOutlineColor: $focus-outline-color,
|
focusOutlineColor: $focus-outline-color,
|
||||||
|
successColor: $success-color-dark,
|
||||||
),
|
),
|
||||||
solarizedDark: (
|
solarizedDark: (
|
||||||
textColor: $solarizedDarkBase2,
|
textColor: $solarizedDarkBase2,
|
||||||
@ -77,6 +88,7 @@ $themes: (
|
|||||||
inputBackgroundColor: $solarizedDarkBase01,
|
inputBackgroundColor: $solarizedDarkBase01,
|
||||||
borderColor: $solarizedDarkBase2,
|
borderColor: $solarizedDarkBase2,
|
||||||
focusOutlineColor: $focus-outline-color,
|
focusOutlineColor: $focus-outline-color,
|
||||||
|
successColor: $success-color-dark,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -166,6 +166,7 @@ import { BackgroundMemoryStorageService } from "../platform/storage/background-m
|
|||||||
import { BrowserSendService } from "../services/browser-send.service";
|
import { BrowserSendService } from "../services/browser-send.service";
|
||||||
import { BrowserSettingsService } from "../services/browser-settings.service";
|
import { BrowserSettingsService } from "../services/browser-settings.service";
|
||||||
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
|
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
|
||||||
|
import FilelessImporterBackground from "../tools/background/fileless-importer.background";
|
||||||
import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service";
|
import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service";
|
||||||
import { Fido2Service as Fido2ServiceAbstraction } from "../vault/services/abstractions/fido2.service";
|
import { Fido2Service as Fido2ServiceAbstraction } from "../vault/services/abstractions/fido2.service";
|
||||||
import { BrowserFolderService } from "../vault/services/browser-folder.service";
|
import { BrowserFolderService } from "../vault/services/browser-folder.service";
|
||||||
@ -262,6 +263,7 @@ export default class MainBackground {
|
|||||||
private idleBackground: IdleBackground;
|
private idleBackground: IdleBackground;
|
||||||
private notificationBackground: NotificationBackground;
|
private notificationBackground: NotificationBackground;
|
||||||
private overlayBackground: OverlayBackground;
|
private overlayBackground: OverlayBackground;
|
||||||
|
private filelessImporterBackground: FilelessImporterBackground;
|
||||||
private runtimeBackground: RuntimeBackground;
|
private runtimeBackground: RuntimeBackground;
|
||||||
private tabsBackground: TabsBackground;
|
private tabsBackground: TabsBackground;
|
||||||
private webRequestBackground: WebRequestBackground;
|
private webRequestBackground: WebRequestBackground;
|
||||||
@ -723,6 +725,14 @@ export default class MainBackground {
|
|||||||
this.stateService,
|
this.stateService,
|
||||||
this.i18nService,
|
this.i18nService,
|
||||||
);
|
);
|
||||||
|
this.filelessImporterBackground = new FilelessImporterBackground(
|
||||||
|
this.configService,
|
||||||
|
this.authService,
|
||||||
|
this.policyService,
|
||||||
|
this.notificationBackground,
|
||||||
|
this.importService,
|
||||||
|
this.syncService,
|
||||||
|
);
|
||||||
this.tabsBackground = new TabsBackground(
|
this.tabsBackground = new TabsBackground(
|
||||||
this,
|
this,
|
||||||
this.notificationBackground,
|
this.notificationBackground,
|
||||||
@ -805,6 +815,7 @@ export default class MainBackground {
|
|||||||
await (this.eventUploadService as EventUploadService).init(true);
|
await (this.eventUploadService as EventUploadService).init(true);
|
||||||
await this.runtimeBackground.init();
|
await this.runtimeBackground.init();
|
||||||
await this.notificationBackground.init();
|
await this.notificationBackground.init();
|
||||||
|
this.filelessImporterBackground.init();
|
||||||
await this.commandsBackground.init();
|
await this.commandsBackground.init();
|
||||||
|
|
||||||
this.configService.init();
|
this.configService.init();
|
||||||
|
@ -35,6 +35,12 @@
|
|||||||
"css": ["content/autofill.css"],
|
"css": ["content/autofill.css"],
|
||||||
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
||||||
"run_at": "document_end"
|
"run_at": "document_end"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"all_frames": false,
|
||||||
|
"js": ["content/lp-fileless-importer.js"],
|
||||||
|
"matches": ["https://lastpass.com/export.php"],
|
||||||
|
"run_at": "document_start"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
import { FilelessImportTypeKeys } from "../../enums/fileless-import.enums";
|
||||||
|
|
||||||
|
type FilelessImportPortMessage = {
|
||||||
|
command?: string;
|
||||||
|
importType?: FilelessImportTypeKeys;
|
||||||
|
data?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilelessImportPortMessageHandlerParams = {
|
||||||
|
message: FilelessImportPortMessage;
|
||||||
|
port: chrome.runtime.Port;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportNotificationMessageHandlers = {
|
||||||
|
[key: string]: ({ message, port }: FilelessImportPortMessageHandlerParams) => void;
|
||||||
|
cancelFilelessImport: ({ message, port }: FilelessImportPortMessageHandlerParams) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LpImporterMessageHandlers = {
|
||||||
|
[key: string]: ({ message, port }: FilelessImportPortMessageHandlerParams) => void;
|
||||||
|
displayLpImportNotification: ({ port }: { port: chrome.runtime.Port }) => void;
|
||||||
|
startLpImport: ({ message }: { message: FilelessImportPortMessage }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FilelessImporterBackground {
|
||||||
|
init(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
FilelessImportPortMessage,
|
||||||
|
ImportNotificationMessageHandlers,
|
||||||
|
LpImporterMessageHandlers,
|
||||||
|
FilelessImporterBackground,
|
||||||
|
};
|
@ -0,0 +1,291 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
|
||||||
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
|
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/services/config/config.service";
|
||||||
|
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
|
import { Importer, ImportResult, ImportServiceAbstraction } from "@bitwarden/importer/core";
|
||||||
|
|
||||||
|
import NotificationBackground from "../../autofill/background/notification.background";
|
||||||
|
import { createPortSpyMock } from "../../autofill/jest/autofill-mocks";
|
||||||
|
import {
|
||||||
|
flushPromises,
|
||||||
|
sendPortMessage,
|
||||||
|
triggerRuntimeOnConnectEvent,
|
||||||
|
} from "../../autofill/jest/testing-utils";
|
||||||
|
import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import.enums";
|
||||||
|
|
||||||
|
import FilelessImporterBackground from "./fileless-importer.background";
|
||||||
|
|
||||||
|
describe("FilelessImporterBackground ", () => {
|
||||||
|
let filelessImporterBackground: FilelessImporterBackground;
|
||||||
|
const configService = mock<ConfigService>();
|
||||||
|
const authService = mock<AuthService>();
|
||||||
|
const policyService = mock<PolicyService>();
|
||||||
|
const notificationBackground = mock<NotificationBackground>();
|
||||||
|
const importService = mock<ImportServiceAbstraction>();
|
||||||
|
const syncService = mock<SyncService>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
filelessImporterBackground = new FilelessImporterBackground(
|
||||||
|
configService,
|
||||||
|
authService,
|
||||||
|
policyService,
|
||||||
|
notificationBackground,
|
||||||
|
importService,
|
||||||
|
syncService,
|
||||||
|
);
|
||||||
|
filelessImporterBackground.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("init", () => {
|
||||||
|
it("sets up the port message listeners on initialization of the class", () => {
|
||||||
|
expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handle ports onConnect", () => {
|
||||||
|
let lpImporterPort: chrome.runtime.Port;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
lpImporterPort = createPortSpyMock(FilelessImportPort.LpImporter);
|
||||||
|
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||||
|
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
|
||||||
|
jest
|
||||||
|
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
|
||||||
|
.mockResolvedValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the port connection if the port name is not present in the set of filelessImportNames", async () => {
|
||||||
|
const port = createPortSpyMock("some-other-port");
|
||||||
|
|
||||||
|
triggerRuntimeOnConnectEvent(port);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(port.postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts a message to the port indicating that the fileless import feature is disabled if the user's auth status is not unlocked", async () => {
|
||||||
|
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Locked);
|
||||||
|
|
||||||
|
triggerRuntimeOnConnectEvent(lpImporterPort);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(lpImporterPort.postMessage).toHaveBeenCalledWith({
|
||||||
|
command: "verifyFeatureFlag",
|
||||||
|
filelessImportEnabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
|
||||||
|
.mockResolvedValue(true);
|
||||||
|
|
||||||
|
triggerRuntimeOnConnectEvent(lpImporterPort);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(lpImporterPort.postMessage).toHaveBeenCalledWith({
|
||||||
|
command: "verifyFeatureFlag",
|
||||||
|
filelessImportEnabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts a message to the port indicating that the fileless import feature is disabled if the feature flag is turned off", async () => {
|
||||||
|
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(false);
|
||||||
|
|
||||||
|
triggerRuntimeOnConnectEvent(lpImporterPort);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(lpImporterPort.postMessage).toHaveBeenCalledWith({
|
||||||
|
command: "verifyFeatureFlag",
|
||||||
|
filelessImportEnabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts a message to the port indicating that the fileless import feature is enabled", async () => {
|
||||||
|
triggerRuntimeOnConnectEvent(lpImporterPort);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(lpImporterPort.postMessage).toHaveBeenCalledWith({
|
||||||
|
command: "verifyFeatureFlag",
|
||||||
|
filelessImportEnabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("port messages", () => {
|
||||||
|
let notificationPort: chrome.runtime.Port;
|
||||||
|
let lpImporterPort: chrome.runtime.Port;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||||
|
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
|
||||||
|
jest
|
||||||
|
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
|
||||||
|
.mockResolvedValue(false);
|
||||||
|
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.NotificationBar));
|
||||||
|
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.LpImporter));
|
||||||
|
await flushPromises();
|
||||||
|
notificationPort = filelessImporterBackground["importNotificationsPort"];
|
||||||
|
lpImporterPort = filelessImporterBackground["lpImporterPort"];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips handling a message if a message handler is not associated with the port message command", () => {
|
||||||
|
sendPortMessage(notificationPort, { command: "commandNotFound" });
|
||||||
|
|
||||||
|
expect(chrome.tabs.sendMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("import notification port messages", () => {
|
||||||
|
describe("startFilelessImport", () => {
|
||||||
|
it("sends a message to start the LastPass fileless import within the content script", () => {
|
||||||
|
sendPortMessage(notificationPort, {
|
||||||
|
command: "startFilelessImport",
|
||||||
|
importType: FilelessImportType.LP,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lpImporterPort.postMessage).toHaveBeenCalledWith({
|
||||||
|
command: "startLpFilelessImport",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cancelFilelessImport", () => {
|
||||||
|
it("sends a message to close the notification bar", async () => {
|
||||||
|
sendPortMessage(notificationPort, { command: "cancelFilelessImport" });
|
||||||
|
|
||||||
|
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||||
|
notificationPort.sender.tab.id,
|
||||||
|
{
|
||||||
|
command: "closeNotificationBar",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(lpImporterPort.postMessage).not.toHaveBeenCalledWith({
|
||||||
|
command: "triggerCsvDownload",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a message to trigger a download of the LP importer CSV", () => {
|
||||||
|
sendPortMessage(notificationPort, {
|
||||||
|
command: "cancelFilelessImport",
|
||||||
|
importType: FilelessImportType.LP,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lpImporterPort.postMessage).toHaveBeenCalledWith({
|
||||||
|
command: "triggerCsvDownload",
|
||||||
|
});
|
||||||
|
expect(lpImporterPort.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lp importer port messages", () => {
|
||||||
|
describe("displayLpImportNotification", () => {
|
||||||
|
it("creates a request fileless import notification", async () => {
|
||||||
|
jest.spyOn(filelessImporterBackground["notificationBackground"], "requestFilelessImport");
|
||||||
|
|
||||||
|
sendPortMessage(lpImporterPort, {
|
||||||
|
command: "displayLpImportNotification",
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filelessImporterBackground["notificationBackground"].requestFilelessImport,
|
||||||
|
).toHaveBeenCalledWith(lpImporterPort.sender.tab, FilelessImportType.LP);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startLpImport", () => {
|
||||||
|
it("ignores the message if the message does not contain data", () => {
|
||||||
|
sendPortMessage(lpImporterPort, {
|
||||||
|
command: "startLpImport",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filelessImporterBackground["importService"].import).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers the import of the LastPass vault", async () => {
|
||||||
|
const data = "url,username,password";
|
||||||
|
const importer = mock<Importer>();
|
||||||
|
jest
|
||||||
|
.spyOn(filelessImporterBackground["importService"], "getImporter")
|
||||||
|
.mockReturnValue(importer);
|
||||||
|
jest.spyOn(filelessImporterBackground["importService"], "import").mockResolvedValue(
|
||||||
|
mock<ImportResult>({
|
||||||
|
success: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jest.spyOn(filelessImporterBackground["syncService"], "fullSync");
|
||||||
|
|
||||||
|
sendPortMessage(lpImporterPort, {
|
||||||
|
command: "startLpImport",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(filelessImporterBackground["importService"].import).toHaveBeenCalledWith(
|
||||||
|
importer,
|
||||||
|
data,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
filelessImporterBackground["importNotificationsPort"].postMessage,
|
||||||
|
).toHaveBeenCalledWith({ command: "filelessImportCompleted" });
|
||||||
|
expect(filelessImporterBackground["syncService"].fullSync).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts a failed message if the import fails", async () => {
|
||||||
|
const data = "url,username,password";
|
||||||
|
const importer = mock<Importer>();
|
||||||
|
jest
|
||||||
|
.spyOn(filelessImporterBackground["importService"], "getImporter")
|
||||||
|
.mockReturnValue(importer);
|
||||||
|
jest
|
||||||
|
.spyOn(filelessImporterBackground["importService"], "import")
|
||||||
|
.mockImplementation(() => {
|
||||||
|
throw new Error("error");
|
||||||
|
});
|
||||||
|
jest.spyOn(filelessImporterBackground["syncService"], "fullSync");
|
||||||
|
|
||||||
|
sendPortMessage(lpImporterPort, {
|
||||||
|
command: "startLpImport",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filelessImporterBackground["importNotificationsPort"].postMessage,
|
||||||
|
).toHaveBeenCalledWith({ command: "filelessImportFailed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleImporterPortDisconnect", () => {
|
||||||
|
it("resets the port properties to null", () => {
|
||||||
|
const lpImporterPort = createPortSpyMock(FilelessImportPort.LpImporter);
|
||||||
|
const notificationPort = createPortSpyMock(FilelessImportPort.NotificationBar);
|
||||||
|
filelessImporterBackground["lpImporterPort"] = lpImporterPort;
|
||||||
|
filelessImporterBackground["importNotificationsPort"] = notificationPort;
|
||||||
|
|
||||||
|
filelessImporterBackground["handleImporterPortDisconnect"](lpImporterPort);
|
||||||
|
|
||||||
|
expect(filelessImporterBackground["lpImporterPort"]).toBeNull();
|
||||||
|
expect(filelessImporterBackground["importNotificationsPort"]).not.toBeNull();
|
||||||
|
|
||||||
|
filelessImporterBackground["handleImporterPortDisconnect"](notificationPort);
|
||||||
|
|
||||||
|
expect(filelessImporterBackground["importNotificationsPort"]).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,253 @@
|
|||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||||
|
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
|
import { ImportServiceAbstraction } from "@bitwarden/importer/core";
|
||||||
|
|
||||||
|
import NotificationBackground from "../../autofill/background/notification.background";
|
||||||
|
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||||
|
import {
|
||||||
|
FilelessImportPort,
|
||||||
|
FilelessImportType,
|
||||||
|
FilelessImportTypeKeys,
|
||||||
|
} from "../enums/fileless-import.enums";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ImportNotificationMessageHandlers,
|
||||||
|
LpImporterMessageHandlers,
|
||||||
|
FilelessImporterBackground as FilelessImporterBackgroundInterface,
|
||||||
|
FilelessImportPortMessage,
|
||||||
|
} from "./abstractions/fileless-importer.background";
|
||||||
|
|
||||||
|
class FilelessImporterBackground implements FilelessImporterBackgroundInterface {
|
||||||
|
private static readonly filelessImporterPortNames: Set<string> = new Set([
|
||||||
|
FilelessImportPort.LpImporter,
|
||||||
|
FilelessImportPort.NotificationBar,
|
||||||
|
]);
|
||||||
|
private importNotificationsPort: chrome.runtime.Port;
|
||||||
|
private lpImporterPort: chrome.runtime.Port;
|
||||||
|
private readonly importNotificationsPortMessageHandlers: ImportNotificationMessageHandlers = {
|
||||||
|
startFilelessImport: ({ message }) => this.startFilelessImport(message.importType),
|
||||||
|
cancelFilelessImport: ({ message, port }) =>
|
||||||
|
this.cancelFilelessImport(message.importType, port.sender),
|
||||||
|
};
|
||||||
|
private readonly lpImporterPortMessageHandlers: LpImporterMessageHandlers = {
|
||||||
|
displayLpImportNotification: ({ port }) =>
|
||||||
|
this.displayFilelessImportNotification(port.sender.tab, FilelessImportType.LP),
|
||||||
|
startLpImport: ({ message }) => this.triggerLpImport(message.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance of the fileless importer background logic.
|
||||||
|
*
|
||||||
|
* @param configService - Identifies if the feature flag is enabled.
|
||||||
|
* @param authService - Verifies if the auth status of the user.
|
||||||
|
* @param policyService - Identifies if the user account has a policy that disables personal ownership.
|
||||||
|
* @param notificationBackground - Used to inject the notification bar into the tab.
|
||||||
|
* @param importService - Used to import the export data into the vault.
|
||||||
|
* @param syncService - Used to trigger a full sync after the import is completed.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigServiceAbstraction,
|
||||||
|
private authService: AuthService,
|
||||||
|
private policyService: PolicyService,
|
||||||
|
private notificationBackground: NotificationBackground,
|
||||||
|
private importService: ImportServiceAbstraction,
|
||||||
|
private syncService: SyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the fileless importer background logic.
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
this.setupPortMessageListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts an import of the export data pulled from the tab.
|
||||||
|
*
|
||||||
|
* @param importType - The type of import to start. Identifies the used content script.
|
||||||
|
*/
|
||||||
|
private startFilelessImport(importType: FilelessImportTypeKeys) {
|
||||||
|
if (importType === FilelessImportType.LP) {
|
||||||
|
this.lpImporterPort?.postMessage({ command: "startLpFilelessImport" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels an import of the export data pulled from the tab. This closes any
|
||||||
|
* existing notifications that are present in the tab, and triggers importer
|
||||||
|
* specific behavior based on the import type.
|
||||||
|
*
|
||||||
|
* @param importType - The type of import to cancel. Identifies the used content script.
|
||||||
|
* @param sender - The sender of the message.
|
||||||
|
*/
|
||||||
|
private async cancelFilelessImport(
|
||||||
|
importType: FilelessImportTypeKeys,
|
||||||
|
sender: chrome.runtime.MessageSender,
|
||||||
|
) {
|
||||||
|
if (importType === FilelessImportType.LP) {
|
||||||
|
this.triggerLpImporterCsvDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
await BrowserApi.tabSendMessage(sender.tab, { command: "closeNotificationBar" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects the notification bar into the passed tab.
|
||||||
|
*
|
||||||
|
* @param tab
|
||||||
|
* @param importType
|
||||||
|
*/
|
||||||
|
private async displayFilelessImportNotification(tab: chrome.tabs.Tab, importType: string) {
|
||||||
|
await this.notificationBackground.requestFilelessImport(tab, importType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private triggerLpImporterCsvDownload() {
|
||||||
|
this.lpImporterPort?.postMessage({ command: "triggerCsvDownload" });
|
||||||
|
this.lpImporterPort?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes the import process for the LP importer. This is triggered when the
|
||||||
|
* user opts to save the export to Bitwarden within the notification bar.
|
||||||
|
*
|
||||||
|
* @param data - The export data to import.
|
||||||
|
* @param sender - The sender of the message.
|
||||||
|
*/
|
||||||
|
private async triggerLpImport(data: string) {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptForPassword_callback = async () => "";
|
||||||
|
const importer = this.importService.getImporter(
|
||||||
|
"lastpasscsv",
|
||||||
|
promptForPassword_callback,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.importService.import(importer, data, null, null, false);
|
||||||
|
if (result.success) {
|
||||||
|
this.importNotificationsPort?.postMessage({ command: "filelessImportCompleted" });
|
||||||
|
await this.syncService.fullSync(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.importNotificationsPort?.postMessage({
|
||||||
|
command: "filelessImportFailed",
|
||||||
|
importErrorMessage: Object.values(error).length
|
||||||
|
? error
|
||||||
|
: chrome.i18n.getMessage("importNetworkError"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifies if the user account has a policy that disables personal ownership.
|
||||||
|
*/
|
||||||
|
private async removeIndividualVault(): Promise<boolean> {
|
||||||
|
return await firstValueFrom(
|
||||||
|
this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up onConnect listeners for the extension.
|
||||||
|
*/
|
||||||
|
private setupPortMessageListeners() {
|
||||||
|
chrome.runtime.onConnect.addListener(this.handlePortOnConnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles connections from content scripts that affect the fileless importer behavior.
|
||||||
|
* Is used to facilitate the passing of data and user actions to enact the import
|
||||||
|
* of web content to the Bitwarden vault. Along with this, a check is made to ensure
|
||||||
|
* that the feature flag is enabled and the user is authenticated.
|
||||||
|
*/
|
||||||
|
private handlePortOnConnect = async (port: chrome.runtime.Port) => {
|
||||||
|
if (!FilelessImporterBackground.filelessImporterPortNames.has(port.name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filelessImportFeatureFlagEnabled = await this.configService.getFeatureFlag<boolean>(
|
||||||
|
FeatureFlag.BrowserFilelessImport,
|
||||||
|
);
|
||||||
|
const userAuthStatus = await this.authService.getAuthStatus();
|
||||||
|
const removeIndividualVault = await this.removeIndividualVault();
|
||||||
|
const filelessImportEnabled =
|
||||||
|
filelessImportFeatureFlagEnabled &&
|
||||||
|
userAuthStatus === AuthenticationStatus.Unlocked &&
|
||||||
|
!removeIndividualVault;
|
||||||
|
port.postMessage({ command: "verifyFeatureFlag", filelessImportEnabled });
|
||||||
|
|
||||||
|
if (!filelessImportEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
port.onMessage.addListener(this.handleImporterPortMessage);
|
||||||
|
port.onDisconnect.addListener(this.handleImporterPortDisconnect);
|
||||||
|
|
||||||
|
switch (port.name) {
|
||||||
|
case FilelessImportPort.LpImporter:
|
||||||
|
this.lpImporterPort = port;
|
||||||
|
break;
|
||||||
|
case FilelessImportPort.NotificationBar:
|
||||||
|
this.importNotificationsPort = port;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles messages that are sent from fileless importer content scripts.
|
||||||
|
* @param message - The message that was sent.
|
||||||
|
* @param port - The port that the message was sent from.
|
||||||
|
*/
|
||||||
|
private handleImporterPortMessage = (
|
||||||
|
message: FilelessImportPortMessage,
|
||||||
|
port: chrome.runtime.Port,
|
||||||
|
) => {
|
||||||
|
let handler: CallableFunction | undefined;
|
||||||
|
|
||||||
|
switch (port.name) {
|
||||||
|
case FilelessImportPort.LpImporter:
|
||||||
|
handler = this.lpImporterPortMessageHandlers[message.command];
|
||||||
|
break;
|
||||||
|
case FilelessImportPort.NotificationBar:
|
||||||
|
handler = this.importNotificationsPortMessageHandlers[message.command];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler({ message, port });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles disconnections from fileless importer content scripts.
|
||||||
|
* @param port - The port that was disconnected.
|
||||||
|
*/
|
||||||
|
private handleImporterPortDisconnect = (port: chrome.runtime.Port) => {
|
||||||
|
switch (port.name) {
|
||||||
|
case FilelessImportPort.LpImporter:
|
||||||
|
this.lpImporterPort = null;
|
||||||
|
break;
|
||||||
|
case FilelessImportPort.NotificationBar:
|
||||||
|
this.importNotificationsPort = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilelessImporterBackground;
|
@ -0,0 +1,25 @@
|
|||||||
|
type LpFilelessImporterMessage = {
|
||||||
|
command?: string;
|
||||||
|
data?: string;
|
||||||
|
filelessImportEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LpFilelessImporterMessageHandlerParams = {
|
||||||
|
message: LpFilelessImporterMessage;
|
||||||
|
port: chrome.runtime.Port;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LpFilelessImporterMessageHandlers = {
|
||||||
|
[key: string]: ({ message, port }: LpFilelessImporterMessageHandlerParams) => void;
|
||||||
|
verifyFeatureFlag: ({ message }: { message: LpFilelessImporterMessage }) => void;
|
||||||
|
triggerCsvDownload: () => void;
|
||||||
|
startLpFilelessImport: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LpFilelessImporter {
|
||||||
|
init(): void;
|
||||||
|
handleFeatureFlagVerification(message: LpFilelessImporterMessage): void;
|
||||||
|
triggerCsvDownload(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { LpFilelessImporterMessage, LpFilelessImporterMessageHandlers, LpFilelessImporter };
|
223
apps/browser/src/tools/content/lp-fileless-importer.spec.ts
Normal file
223
apps/browser/src/tools/content/lp-fileless-importer.spec.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { createPortSpyMock } from "../../autofill/jest/autofill-mocks";
|
||||||
|
import { sendPortMessage } from "../../autofill/jest/testing-utils";
|
||||||
|
import { FilelessImportPort } from "../enums/fileless-import.enums";
|
||||||
|
|
||||||
|
import { LpFilelessImporter } from "./abstractions/lp-fileless-importer";
|
||||||
|
|
||||||
|
describe("LpFilelessImporter", () => {
|
||||||
|
let lpFilelessImporter: LpFilelessImporter & { [key: string]: any };
|
||||||
|
const portSpy: chrome.runtime.Port = createPortSpyMock(FilelessImportPort.LpImporter);
|
||||||
|
chrome.runtime.connect = jest.fn(() => portSpy);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
require("./lp-fileless-importer");
|
||||||
|
lpFilelessImporter = (globalThis as any).lpFilelessImporter;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(globalThis as any).lpFilelessImporter = undefined;
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.resetModules();
|
||||||
|
Object.defineProperty(document, "readyState", {
|
||||||
|
value: "complete",
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("init", () => {
|
||||||
|
it("sets up the port connection with the background script", () => {
|
||||||
|
lpFilelessImporter.init();
|
||||||
|
|
||||||
|
expect(chrome.runtime.connect).toHaveBeenCalledWith({
|
||||||
|
name: FilelessImportPort.LpImporter,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleFeatureFlagVerification", () => {
|
||||||
|
it("disconnects the message port when the fileless import feature is disabled", () => {
|
||||||
|
lpFilelessImporter.handleFeatureFlagVerification({ filelessImportEnabled: false });
|
||||||
|
|
||||||
|
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`", () => {
|
||||||
|
Object.defineProperty(document, "readyState", {
|
||||||
|
value: "loading",
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
const message = {
|
||||||
|
command: "verifyFeatureFlag",
|
||||||
|
filelessImportEnabled: true,
|
||||||
|
};
|
||||||
|
jest.spyOn(document, "addEventListener");
|
||||||
|
|
||||||
|
lpFilelessImporter.handleFeatureFlagVerification(message);
|
||||||
|
|
||||||
|
expect(document.addEventListener).toHaveBeenCalledWith(
|
||||||
|
"DOMContentLoaded",
|
||||||
|
(lpFilelessImporter as any).loadImporter,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets up a mutation observer to watch the document body for injection of the export content", () => {
|
||||||
|
const message = {
|
||||||
|
command: "verifyFeatureFlag",
|
||||||
|
filelessImportEnabled: true,
|
||||||
|
};
|
||||||
|
jest.spyOn(document, "addEventListener");
|
||||||
|
jest.spyOn(window, "MutationObserver").mockImplementationOnce(() => mock<MutationObserver>());
|
||||||
|
|
||||||
|
lpFilelessImporter.handleFeatureFlagVerification(message);
|
||||||
|
|
||||||
|
expect(window.MutationObserver).toHaveBeenCalledWith(
|
||||||
|
(lpFilelessImporter as any).handleMutation,
|
||||||
|
);
|
||||||
|
expect((lpFilelessImporter as any).mutationObserver.observe).toHaveBeenCalledWith(
|
||||||
|
document.body,
|
||||||
|
{ childList: true, subtree: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("triggerCsvDownload", () => {
|
||||||
|
it("posts a window message that triggers the download of the LastPass export", () => {
|
||||||
|
jest.spyOn(globalThis, "postMessage");
|
||||||
|
|
||||||
|
lpFilelessImporter.triggerCsvDownload();
|
||||||
|
|
||||||
|
expect(globalThis.postMessage).toHaveBeenCalledWith(
|
||||||
|
{ command: "triggerCsvDownload" },
|
||||||
|
"https://lastpass.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleMutation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
lpFilelessImporter["mutationObserver"] = mock<MutationObserver>({ disconnect: jest.fn() });
|
||||||
|
jest.spyOn(portSpy, "postMessage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores mutations that contain empty records", () => {
|
||||||
|
lpFilelessImporter["handleMutation"]([]);
|
||||||
|
|
||||||
|
expect(portSpy.postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores mutations that have no added nodes in the mutation", () => {
|
||||||
|
lpFilelessImporter["handleMutation"]([{ addedNodes: [] }]);
|
||||||
|
|
||||||
|
expect(portSpy.postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores mutations that have no added nodes with a tagname of `pre`", () => {
|
||||||
|
lpFilelessImporter["handleMutation"]([{ addedNodes: [{ nodeName: "div" }] }]);
|
||||||
|
|
||||||
|
expect(portSpy.postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores mutations where the found `pre` element does not contain any textContent", () => {
|
||||||
|
lpFilelessImporter["handleMutation"]([{ addedNodes: [{ nodeName: "pre" }] }]);
|
||||||
|
|
||||||
|
expect(portSpy.postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores mutations where the found `pre` element does not contain the expected header content", () => {
|
||||||
|
lpFilelessImporter["handleMutation"]([
|
||||||
|
{ addedNodes: [{ nodeName: "pre", textContent: "some other content" }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(portSpy.postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will store the export data, display the import notification, and disconnect the mutation observer when the export data is appended", () => {
|
||||||
|
const observerDisconnectSpy = jest.spyOn(
|
||||||
|
lpFilelessImporter["mutationObserver"],
|
||||||
|
"disconnect",
|
||||||
|
);
|
||||||
|
|
||||||
|
lpFilelessImporter["handleMutation"]([
|
||||||
|
{ addedNodes: [{ nodeName: "pre", textContent: "url,username,password" }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(lpFilelessImporter["exportData"]).toEqual("url,username,password");
|
||||||
|
expect(portSpy.postMessage).toHaveBeenCalledWith({ command: "displayLpImportNotification" });
|
||||||
|
expect(observerDisconnectSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handlePortMessage", () => {
|
||||||
|
it("ignores messages that are not registered with the portMessageHandlers", () => {
|
||||||
|
const message = { command: "unknownCommand" };
|
||||||
|
jest.spyOn(lpFilelessImporter, "handleFeatureFlagVerification");
|
||||||
|
jest.spyOn(lpFilelessImporter, "triggerCsvDownload");
|
||||||
|
|
||||||
|
sendPortMessage(portSpy, message);
|
||||||
|
|
||||||
|
expect(lpFilelessImporter.handleFeatureFlagVerification).not.toHaveBeenCalled();
|
||||||
|
expect(lpFilelessImporter.triggerCsvDownload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the port message that verifies the fileless import feature flag", () => {
|
||||||
|
const message = { command: "verifyFeatureFlag", filelessImportEnabled: true };
|
||||||
|
jest.spyOn(lpFilelessImporter, "handleFeatureFlagVerification").mockImplementation();
|
||||||
|
|
||||||
|
sendPortMessage(portSpy, message);
|
||||||
|
|
||||||
|
expect(lpFilelessImporter.handleFeatureFlagVerification).toHaveBeenCalledWith(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the port message that triggers the LastPass csv download", () => {
|
||||||
|
const message = { command: "triggerCsvDownload" };
|
||||||
|
jest.spyOn(lpFilelessImporter, "triggerCsvDownload");
|
||||||
|
|
||||||
|
sendPortMessage(portSpy, message);
|
||||||
|
|
||||||
|
expect(lpFilelessImporter.triggerCsvDownload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handles the port message that triggers the LastPass fileless import", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(lpFilelessImporter as any, "postPortMessage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the import of the export data is not populated", () => {
|
||||||
|
const message = { command: "startLpFilelessImport" };
|
||||||
|
|
||||||
|
sendPortMessage(portSpy, message);
|
||||||
|
|
||||||
|
expect(lpFilelessImporter.postPortMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts the last pass fileless import", () => {
|
||||||
|
const message = { command: "startLpFilelessImport" };
|
||||||
|
const exportData = "url,username,password";
|
||||||
|
lpFilelessImporter["exportData"] = exportData;
|
||||||
|
|
||||||
|
sendPortMessage(portSpy, message);
|
||||||
|
|
||||||
|
expect(lpFilelessImporter.postPortMessage).toHaveBeenCalledWith({
|
||||||
|
command: "startLpImport",
|
||||||
|
data: exportData,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
197
apps/browser/src/tools/content/lp-fileless-importer.ts
Normal file
197
apps/browser/src/tools/content/lp-fileless-importer.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { FilelessImportPort } from "../enums/fileless-import.enums";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LpFilelessImporter as LpFilelessImporterInterface,
|
||||||
|
LpFilelessImporterMessage,
|
||||||
|
LpFilelessImporterMessageHandlers,
|
||||||
|
} from "./abstractions/lp-fileless-importer";
|
||||||
|
|
||||||
|
class LpFilelessImporter implements LpFilelessImporterInterface {
|
||||||
|
private exportData: string;
|
||||||
|
private messagePort: chrome.runtime.Port;
|
||||||
|
private mutationObserver: MutationObserver;
|
||||||
|
private readonly portMessageHandlers: LpFilelessImporterMessageHandlers = {
|
||||||
|
verifyFeatureFlag: ({ message }) => this.handleFeatureFlagVerification(message),
|
||||||
|
triggerCsvDownload: () => this.triggerCsvDownload(),
|
||||||
|
startLpFilelessImport: () => this.startLpImport(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the LP fileless importer.
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
this.setupMessagePort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enacts behavior based on the feature flag verification message. If the feature flag is
|
||||||
|
* not enabled, the message port is disconnected. If the feature flag is enabled, the
|
||||||
|
* download of the CSV file is suppressed.
|
||||||
|
*
|
||||||
|
* @param message - The port message, contains the feature flag indicator.
|
||||||
|
*/
|
||||||
|
handleFeatureFlagVerification(message: LpFilelessImporterMessage) {
|
||||||
|
if (!message.filelessImportEnabled) {
|
||||||
|
this.messagePort?.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.suppressDownload();
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", this.loadImporter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadImporter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a message to the LP importer to trigger the download of the CSV file.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
* This is done by observing the DOM for the addition of the LP importer element.
|
||||||
|
*/
|
||||||
|
private loadImporter = () => {
|
||||||
|
this.mutationObserver = new MutationObserver(this.handleMutation);
|
||||||
|
this.mutationObserver.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles mutations that are observed by the mutation observer. When the exported data
|
||||||
|
* element is added to the DOM, the export data is extracted and the import prompt is
|
||||||
|
* displayed.
|
||||||
|
*
|
||||||
|
* @param mutations - The mutations that were observed.
|
||||||
|
*/
|
||||||
|
private handleMutation = (mutations: MutationRecord[]) => {
|
||||||
|
let textContent: string;
|
||||||
|
for (let index = 0; index < mutations?.length; index++) {
|
||||||
|
const mutation: MutationRecord = mutations[index];
|
||||||
|
|
||||||
|
textContent = Array.from(mutation.addedNodes)
|
||||||
|
.filter((node) => node.nodeName.toLowerCase() === "pre")
|
||||||
|
.map((node) => (node as HTMLPreElement).textContent?.trim())
|
||||||
|
.find((text) => text?.indexOf("url,username,password") >= 0);
|
||||||
|
|
||||||
|
if (textContent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textContent) {
|
||||||
|
this.exportData = textContent;
|
||||||
|
this.postPortMessage({ command: "displayLpImportNotification" });
|
||||||
|
this.mutationObserver.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the export data is present, sends a message to the background with
|
||||||
|
* the export data to start the import process.
|
||||||
|
*/
|
||||||
|
private startLpImport() {
|
||||||
|
if (!this.exportData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.postPortMessage({ command: "startLpImport", data: this.exportData });
|
||||||
|
this.messagePort?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a message to the background script.
|
||||||
|
*
|
||||||
|
* @param message - The message to post.
|
||||||
|
*/
|
||||||
|
private postPortMessage(message: LpFilelessImporterMessage) {
|
||||||
|
this.messagePort?.postMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a message to the global context of the page.
|
||||||
|
*
|
||||||
|
* @param message - The message to post.
|
||||||
|
*/
|
||||||
|
private postWindowMessage(message: LpFilelessImporterMessage) {
|
||||||
|
globalThis.postMessage(message, "https://lastpass.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up the message port that is used to facilitate communication between the
|
||||||
|
* background script and the content script.
|
||||||
|
*/
|
||||||
|
private setupMessagePort() {
|
||||||
|
this.messagePort = chrome.runtime.connect({ name: FilelessImportPort.LpImporter });
|
||||||
|
this.messagePort.onMessage.addListener(this.handlePortMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles messages that are sent from the background script.
|
||||||
|
*
|
||||||
|
* @param message - The message that was sent.
|
||||||
|
* @param port - The port that the message was sent from.
|
||||||
|
*/
|
||||||
|
private handlePortMessage = (message: LpFilelessImporterMessage, port: chrome.runtime.Port) => {
|
||||||
|
const handler = this.portMessageHandlers[message.command];
|
||||||
|
if (!handler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler({ message, port });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
if (!(globalThis as any).lpFilelessImporter) {
|
||||||
|
(globalThis as any).lpFilelessImporter = new LpFilelessImporter();
|
||||||
|
(globalThis as any).lpFilelessImporter.init();
|
||||||
|
}
|
||||||
|
})();
|
12
apps/browser/src/tools/enums/fileless-import.enums.ts
Normal file
12
apps/browser/src/tools/enums/fileless-import.enums.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const FilelessImportType = {
|
||||||
|
LP: "LP",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type FilelessImportTypeKeys = (typeof FilelessImportType)[keyof typeof FilelessImportType];
|
||||||
|
|
||||||
|
const FilelessImportPort = {
|
||||||
|
NotificationBar: "fileless-importer-notification-bar",
|
||||||
|
LpImporter: "lp-fileless-importer",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export { FilelessImportType, FilelessImportTypeKeys, FilelessImportPort };
|
@ -180,6 +180,7 @@ const mainConfig = {
|
|||||||
"overlay/button": "./src/autofill/overlay/pages/button/bootstrap-autofill-overlay-button.ts",
|
"overlay/button": "./src/autofill/overlay/pages/button/bootstrap-autofill-overlay-button.ts",
|
||||||
"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",
|
||||||
},
|
},
|
||||||
optimization: {
|
optimization: {
|
||||||
minimize: ENV !== "development",
|
minimize: ENV !== "development",
|
||||||
|
Loading…
Reference in New Issue
Block a user