1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-21 11:35:34 +01:00

[PM-934] Autofill not working until page has been refreshed (#6826)

* [PM-934] Autofill not working until page has been refreshed

* [PM-934] Adjusting cleanup of the messages_handler script

* [PM-934] Fixing small issue found within collection of page details

* [PM-934] Addressing concenrs brought up during code review

* [PM-934] Addressing concenrs brought up during code review

* [PM-934] Addressing concenrs brought up during code review

* [PM-934] Addressing concenrs brought up during code review

* [PM-934] Applying re-set changes to the autofill overlay implementation on reset of the extension

* [PM-934] Applying jest tests to added logic within AutofillOverlayContent service

* [PM-934] Fixing typo present in tabs background listener

* [PM-934] Finishing up jest tests for updated implementation

* [PM-934] Incorporating methodology for ensuring the autofill overlay updates to reflect user settings within existing tabs

* [PM-934] Refining implementation to ensure we do not unnecessarily re-inject content scripts when the autofill overlay settings change

* [PM-934] Working through jest tests for added implementation details

* [PM-934] Working through jest tests for added implementation details

* [PM-934] Finalizing jest tests for implemented logic

* [PM-5035] Refactoring method structure
This commit is contained in:
Cesar Gonzalez 2023-12-13 10:25:16 -06:00 committed by GitHub
parent 7051f255ed
commit bf60711efe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 836 additions and 128 deletions

View File

@ -673,7 +673,7 @@ class OverlayBackground implements OverlayBackgroundInterface {
*/
private setupExtensionMessageListeners() {
BrowserApi.messageListener("overlay.background", this.handleExtensionMessage);
chrome.runtime.onConnect.addListener(this.handlePortOnConnect);
BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect);
}
/**

View File

@ -10,6 +10,10 @@ import {
settingsServiceFactory,
SettingsServiceInitOptions,
} from "../../../background/service-factories/settings-service.factory";
import {
configServiceFactory,
ConfigServiceInitOptions,
} from "../../../platform/background/service-factories/config-service.factory";
import {
CachedServices,
factory,
@ -43,7 +47,8 @@ export type AutoFillServiceInitOptions = AutoFillServiceOptions &
EventCollectionServiceInitOptions &
LogServiceInitOptions &
SettingsServiceInitOptions &
UserVerificationServiceInitOptions;
UserVerificationServiceInitOptions &
ConfigServiceInitOptions;
export function autofillServiceFactory(
cache: { autofillService?: AbstractAutoFillService } & CachedServices,
@ -62,6 +67,7 @@ export function autofillServiceFactory(
await logServiceFactory(cache, opts),
await settingsServiceFactory(cache, opts),
await userVerificationServiceFactory(cache, opts),
await configServiceFactory(cache, opts),
),
);
}

View File

@ -15,7 +15,7 @@ import OverlayBackground from "./overlay.background";
import TabsBackground from "./tabs.background";
describe("TabsBackground", () => {
let tabsBackgorund: TabsBackground;
let tabsBackground: TabsBackground;
const mainBackground = mock<MainBackground>({
messagingService: {
send: jest.fn(),
@ -25,7 +25,7 @@ describe("TabsBackground", () => {
const overlayBackground = mock<OverlayBackground>();
beforeEach(() => {
tabsBackgorund = new TabsBackground(mainBackground, notificationBackground, overlayBackground);
tabsBackground = new TabsBackground(mainBackground, notificationBackground, overlayBackground);
});
afterEach(() => {
@ -35,11 +35,11 @@ describe("TabsBackground", () => {
describe("init", () => {
it("sets up a window on focusChanged listener", () => {
const handleWindowOnFocusChangedSpy = jest.spyOn(
tabsBackgorund as any,
tabsBackground as any,
"handleWindowOnFocusChanged",
);
tabsBackgorund.init();
tabsBackground.init();
expect(chrome.windows.onFocusChanged.addListener).toHaveBeenCalledWith(
handleWindowOnFocusChangedSpy,
@ -49,7 +49,7 @@ describe("TabsBackground", () => {
describe("tab event listeners", () => {
beforeEach(() => {
tabsBackgorund.init();
tabsBackground["setupTabEventListeners"]();
});
describe("window onFocusChanged event", () => {
@ -64,7 +64,7 @@ describe("TabsBackground", () => {
triggerWindowOnFocusedChangedEvent(10);
await flushPromises();
expect(tabsBackgorund["focusedWindowId"]).toBe(10);
expect(tabsBackground["focusedWindowId"]).toBe(10);
});
it("updates the current tab data", async () => {
@ -144,7 +144,7 @@ describe("TabsBackground", () => {
beforeEach(() => {
mainBackground.onUpdatedRan = false;
tabsBackgorund["focusedWindowId"] = focusedWindowId;
tabsBackground["focusedWindowId"] = focusedWindowId;
tab = mock<chrome.tabs.Tab>({
windowId: focusedWindowId,
active: true,

View File

@ -20,6 +20,14 @@ export default class TabsBackground {
return;
}
this.updateCurrentTabData();
this.setupTabEventListeners();
}
/**
* Sets up the tab and window event listeners.
*/
private setupTabEventListeners() {
chrome.windows.onFocusChanged.addListener(this.handleWindowOnFocusChanged);
chrome.tabs.onActivated.addListener(this.handleTabOnActivated);
chrome.tabs.onReplaced.addListener(this.handleTabOnReplaced);
@ -33,7 +41,7 @@ export default class TabsBackground {
* @param windowId - The ID of the window that was focused.
*/
private handleWindowOnFocusChanged = async (windowId: number) => {
if (!windowId) {
if (windowId == null || windowId < 0) {
return;
}
@ -116,8 +124,10 @@ export default class TabsBackground {
* for the current tab. Also updates the overlay ciphers.
*/
private updateCurrentTabData = async () => {
await this.main.refreshBadge();
await this.main.refreshMenu();
await this.overlayBackground.updateOverlayCiphers();
await Promise.all([
this.main.refreshBadge(),
this.main.refreshMenu(),
this.overlayBackground.updateOverlayCiphers(),
]);
};
}

View File

@ -17,6 +17,7 @@ type AutofillExtensionMessage = {
direction?: "previous" | "next";
isOpeningFullOverlay?: boolean;
forceCloseOverlay?: boolean;
autofillOverlayVisibility?: number;
};
};
@ -34,10 +35,12 @@ type AutofillExtensionMessageHandlers = {
updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void;
bgUnlockPopoutOpened: () => void;
bgVaultItemRepromptPopoutOpened: () => void;
updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void;
};
interface AutofillInit {
init(): void;
destroy(): void;
}
export { AutofillExtensionMessage, AutofillExtensionMessageHandlers, AutofillInit };

View File

@ -6,7 +6,7 @@ import { flushPromises, sendExtensionRuntimeMessage } from "../jest/testing-util
import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script";
import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
import { RedirectFocusDirection } from "../utils/autofill-overlay.enum";
import { AutofillOverlayVisibility, RedirectFocusDirection } from "../utils/autofill-overlay.enum";
import { AutofillExtensionMessage } from "./abstractions/autofill-init";
import AutofillInit from "./autofill-init";
@ -16,6 +16,11 @@ describe("AutofillInit", () => {
const autofillOverlayContentService = mock<AutofillOverlayContentService>();
beforeEach(() => {
chrome.runtime.connect = jest.fn().mockReturnValue({
onDisconnect: {
addListener: jest.fn(),
},
});
autofillInit = new AutofillInit(autofillOverlayContentService);
});
@ -477,6 +482,57 @@ describe("AutofillInit", () => {
expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled();
});
});
describe("updateAutofillOverlayVisibility", () => {
beforeEach(() => {
autofillInit["autofillOverlayContentService"].autofillOverlayVisibility =
AutofillOverlayVisibility.OnButtonClick;
});
it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => {
sendExtensionRuntimeMessage({
command: "updateAutofillOverlayVisibility",
data: {},
});
expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual(
AutofillOverlayVisibility.OnButtonClick,
);
});
it("updates the overlay visibility value", () => {
const message = {
command: "updateAutofillOverlayVisibility",
data: {
autofillOverlayVisibility: AutofillOverlayVisibility.Off,
},
};
sendExtensionRuntimeMessage(message);
expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual(
message.data.autofillOverlayVisibility,
);
});
});
});
});
describe("destroy", () => {
it("removes the extension message listeners", () => {
autofillInit.destroy();
expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith(
autofillInit["handleExtensionMessage"],
);
});
it("destroys the collectAutofillContentService", () => {
jest.spyOn(autofillInit["collectAutofillContentService"], "destroy");
autofillInit.destroy();
expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled();
});
});
});

View File

@ -26,6 +26,7 @@ class AutofillInit implements AutofillInitInterface {
updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message),
bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(),
bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(),
updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message),
};
/**
@ -214,6 +215,19 @@ class AutofillInit implements AutofillInitInterface {
);
}
/**
* Updates the autofill overlay visibility.
*
* @param data - Contains the autoFillOverlayVisibility value
*/
private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) {
if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) {
return;
}
this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility;
}
/**
* Sets up the extension message listeners for the content script.
*/
@ -247,6 +261,16 @@ class AutofillInit implements AutofillInitInterface {
Promise.resolve(messageResponse).then((response) => sendResponse(response));
return true;
};
/**
* Handles destroying the autofill init content script. Removes all
* listeners, timeouts, and object instances to prevent memory leaks.
*/
destroy() {
chrome.runtime.onMessage.removeListener(this.handleExtensionMessage);
this.collectAutofillContentService.destroy();
this.autofillOverlayContentService?.destroy();
}
}
export default AutofillInit;

View File

@ -1,3 +1,5 @@
import { getFromLocalStorage, setupExtensionDisconnectAction } from "../utils";
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", loadAutofiller);
} else {
@ -8,27 +10,30 @@ function loadAutofiller() {
let pageHref: string = null;
let filledThisHref = false;
let delayFillTimeout: number;
const activeUserIdKey = "activeUserId";
let activeUserId: string;
chrome.storage.local.get(activeUserIdKey, (obj: any) => {
if (obj == null || obj[activeUserIdKey] == null) {
return;
}
activeUserId = obj[activeUserIdKey];
});
chrome.storage.local.get(activeUserId, (obj: any) => {
if (obj?.[activeUserId]?.settings?.enableAutoFillOnPageLoad === true) {
setInterval(() => doFillIfNeeded(), 500);
}
});
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.command === "fillForm" && pageHref === msg.url) {
let doFillInterval: NodeJS.Timeout;
const handleExtensionDisconnect = () => {
clearDoFillInterval();
clearDelayFillTimeout();
};
const handleExtensionMessage = (message: any) => {
if (message.command === "fillForm" && pageHref === message.url) {
filledThisHref = true;
}
});
};
setupExtensionEventListeners();
triggerUserFillOnLoad();
async function triggerUserFillOnLoad() {
const activeUserIdKey = "activeUserId";
const userKeyStorage = await getFromLocalStorage(activeUserIdKey);
const activeUserId = userKeyStorage[activeUserIdKey];
const activeUserStorage = await getFromLocalStorage(activeUserId);
if (activeUserStorage?.[activeUserId]?.settings?.enableAutoFillOnPageLoad === true) {
clearDoFillInterval();
doFillInterval = setInterval(() => doFillIfNeeded(), 500);
}
}
function doFillIfNeeded(force = false) {
if (force || pageHref !== window.location.href) {
@ -36,9 +41,7 @@ function loadAutofiller() {
// Some websites are slow and rendering all page content. Try to fill again later
// if we haven't already.
filledThisHref = false;
if (delayFillTimeout != null) {
window.clearTimeout(delayFillTimeout);
}
clearDelayFillTimeout();
delayFillTimeout = window.setTimeout(() => {
if (!filledThisHref) {
doFillIfNeeded(true);
@ -55,4 +58,21 @@ function loadAutofiller() {
chrome.runtime.sendMessage(msg);
}
}
function clearDoFillInterval() {
if (doFillInterval) {
window.clearInterval(doFillInterval);
}
}
function clearDelayFillTimeout() {
if (delayFillTimeout) {
window.clearTimeout(delayFillTimeout);
}
}
function setupExtensionEventListeners() {
setupExtensionDisconnectAction(handleExtensionDisconnect);
chrome.runtime.onMessage.addListener(handleExtensionMessage);
}
}

View File

@ -1,4 +1,5 @@
import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
import { setupAutofillInitDisconnectAction } from "../utils";
import AutofillInit from "./autofill-init";
@ -6,6 +7,8 @@ import AutofillInit from "./autofill-init";
if (!windowContext.bitwardenAutofillInit) {
const autofillOverlayContentService = new AutofillOverlayContentService();
windowContext.bitwardenAutofillInit = new AutofillInit(autofillOverlayContentService);
setupAutofillInitDisconnectAction(windowContext);
windowContext.bitwardenAutofillInit.init();
}
})(window);

View File

@ -1,8 +1,12 @@
import { setupAutofillInitDisconnectAction } from "../utils";
import AutofillInit from "./autofill-init";
(function (windowContext) {
if (!windowContext.bitwardenAutofillInit) {
windowContext.bitwardenAutofillInit = new AutofillInit();
setupAutofillInitDisconnectAction(windowContext);
windowContext.bitwardenAutofillInit.init();
}
})(window);

View File

@ -1,31 +1,4 @@
window.addEventListener(
"message",
(event) => {
if (event.source !== window) {
return;
}
if (event.data.command && event.data.command === "authResult") {
chrome.runtime.sendMessage({
command: event.data.command,
code: event.data.code,
state: event.data.state,
lastpass: event.data.lastpass,
referrer: event.source.location.hostname,
});
}
if (event.data.command && event.data.command === "webAuthnResult") {
chrome.runtime.sendMessage({
command: event.data.command,
data: event.data.data,
remember: event.data.remember,
referrer: event.source.location.hostname,
});
}
},
false,
);
import { setupExtensionDisconnectAction } from "../utils";
const forwardCommands = [
"bgUnlockPopoutOpened",
@ -34,8 +7,59 @@ const forwardCommands = [
"addedCipher",
];
chrome.runtime.onMessage.addListener((event) => {
if (forwardCommands.includes(event.command)) {
chrome.runtime.sendMessage(event);
/**
* Handles sending extension messages to the background
* script based on window messages from the page.
*
* @param event - Window message event
*/
const handleWindowMessage = (event: MessageEvent) => {
if (event.source !== window) {
return;
}
});
if (event.data.command && event.data.command === "authResult") {
chrome.runtime.sendMessage({
command: event.data.command,
code: event.data.code,
state: event.data.state,
lastpass: event.data.lastpass,
referrer: event.source.location.hostname,
});
}
if (event.data.command && event.data.command === "webAuthnResult") {
chrome.runtime.sendMessage({
command: event.data.command,
data: event.data.data,
remember: event.data.remember,
referrer: event.source.location.hostname,
});
}
};
/**
* Handles forwarding any commands that need to trigger
* an action from one service of the extension background
* to another.
*
* @param message - Message from the extension
*/
const handleExtensionMessage = (message: any) => {
if (forwardCommands.includes(message.command)) {
chrome.runtime.sendMessage(message);
}
};
/**
* Handles cleaning up any event listeners that were
* added to the window or extension.
*/
const handleExtensionDisconnect = () => {
window.removeEventListener("message", handleWindowMessage);
chrome.runtime.onMessage.removeListener(handleExtensionMessage);
};
window.addEventListener("message", handleWindowMessage, false);
chrome.runtime.onMessage.addListener(handleExtensionMessage);
setupExtensionDisconnectAction(handleExtensionDisconnect);

View File

@ -4,6 +4,7 @@ import AddLoginRuntimeMessage from "../notification/models/add-login-runtime-mes
import ChangePasswordRuntimeMessage from "../notification/models/change-password-runtime-message";
import { FormData } from "../services/abstractions/autofill.service";
import { GlobalSettings, UserSettings } from "../types";
import { getFromLocalStorage, setupExtensionDisconnectAction } from "../utils";
interface HTMLElementWithFormOpId extends HTMLElement {
formOpId: string;
@ -122,6 +123,8 @@ async function loadNotificationBar() {
}
}
setupExtensionDisconnectAction(handleExtensionDisconnection);
if (!showNotificationBar) {
return;
}
@ -999,11 +1002,23 @@ async function loadNotificationBar() {
return theEl === document;
}
function handleExtensionDisconnection(port: chrome.runtime.Port) {
closeBar(false);
clearTimeout(domObservationCollectTimeoutId);
clearTimeout(collectPageDetailsTimeoutId);
clearTimeout(handlePageChangeTimeoutId);
observer?.disconnect();
observer = null;
watchedForms.forEach((wf: WatchedForm) => {
const form = wf.formEl;
form.removeEventListener("submit", formSubmitted, false);
const submitButton = getSubmitButton(
form,
unionSets(logInButtonNames, changePasswordButtonNames),
);
submitButton?.removeEventListener("click", formSubmitted, false);
});
}
// End Helper Functions
}
async function getFromLocalStorage(keys: string | string[]): Promise<Record<string, any>> {
return new Promise((resolve) => {
chrome.storage.local.get(keys, (storage: Record<string, any>) => resolve(storage));
});
}

View File

@ -0,0 +1,5 @@
const AutofillPort = {
InjectedScript: "autofill-injected-script-port",
} as const;
export { AutofillPort };

View File

@ -1,5 +1,5 @@
import { EVENTS } from "../../constants";
import { setElementStyles } from "../../utils/utils";
import { setElementStyles } from "../../utils";
import {
BackgroundPortMessageHandlers,
AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface,
@ -166,9 +166,10 @@ class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterf
this.updateElementStyles(this.iframe, { opacity: "0", height: "0px", display: "block" });
globalThis.removeEventListener("message", this.handleWindowMessage);
this.port.onMessage.removeListener(this.handlePortMessage);
this.port.onDisconnect.removeListener(this.handlePortDisconnect);
this.port.disconnect();
this.unobserveIframe();
this.port?.onMessage.removeListener(this.handlePortMessage);
this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
this.port?.disconnect();
this.port = null;
};
@ -369,7 +370,7 @@ class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterf
* Unobserves the iframe element for mutations to its style attribute.
*/
private unobserveIframe() {
this.iframeMutationObserver.disconnect();
this.iframeMutationObserver?.disconnect();
}
/**

View File

@ -3,8 +3,8 @@ import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EVENTS } from "../../../constants";
import { buildSvgDomElement } from "../../../utils";
import { logoIcon, logoLockedIcon } from "../../../utils/svg-icons";
import { buildSvgDomElement } from "../../../utils/utils";
import {
InitAutofillOverlayButtonMessage,
OverlayButtonWindowMessageHandlers,

View File

@ -4,8 +4,8 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
import { OverlayCipherData } from "../../../background/abstractions/overlay.background";
import { EVENTS } from "../../../constants";
import { buildSvgDomElement } from "../../../utils";
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../utils/svg-icons";
import { buildSvgDomElement } from "../../../utils/utils";
import {
InitAutofillOverlayListMessage,
OverlayListWindowMessageHandlers,

View File

@ -10,6 +10,7 @@ import { UriMatchType } from "@bitwarden/common/vault/enums";
import { BrowserApi } from "../../../platform/browser/browser-api";
import { flagEnabled } from "../../../platform/flags";
import { AutofillService } from "../../services/abstractions/autofill.service";
import { AutofillOverlayVisibility } from "../../utils/autofill-overlay.enum";
@Component({
@ -35,6 +36,7 @@ export class AutofillComponent implements OnInit {
private platformUtilsService: PlatformUtilsService,
private configService: ConfigServiceAbstraction,
private settingsService: SettingsService,
private autofillService: AutofillService,
) {
this.autoFillOverlayVisibilityOptions = [
{
@ -86,7 +88,10 @@ export class AutofillComponent implements OnInit {
}
async updateAutoFillOverlayVisibility() {
const previousAutoFillOverlayVisibility =
await this.settingsService.getAutoFillOverlayVisibility();
await this.settingsService.setAutoFillOverlayVisibility(this.autoFillOverlayVisibility);
await this.handleUpdatingAutofillOverlayContentScripts(previousAutoFillOverlayVisibility);
}
async updateAutoFillOnPageLoad() {
@ -144,4 +149,25 @@ export class AutofillComponent implements OnInit {
event.preventDefault();
BrowserApi.createNewTab(this.disablePasswordManagerLink);
}
private async handleUpdatingAutofillOverlayContentScripts(
previousAutoFillOverlayVisibility: number,
) {
const autofillOverlayPreviouslyDisabled =
previousAutoFillOverlayVisibility === AutofillOverlayVisibility.Off;
const autofillOverlayCurrentlyDisabled =
this.autoFillOverlayVisibility === AutofillOverlayVisibility.Off;
if (!autofillOverlayPreviouslyDisabled && !autofillOverlayCurrentlyDisabled) {
const tabs = await BrowserApi.tabsQuery({});
tabs.forEach((tab) =>
BrowserApi.tabSendMessageData(tab, "updateAutofillOverlayVisibility", {
autofillOverlayVisibility: this.autoFillOverlayVisibility,
}),
);
return;
}
await this.autofillService.reloadAutofillScripts();
}
}

View File

@ -14,6 +14,7 @@ interface AutofillOverlayContentService {
isCurrentlyFilling: boolean;
isOverlayCiphersPopulated: boolean;
pageDetailsUpdateRequired: boolean;
autofillOverlayVisibility: number;
init(): void;
setupAutofillOverlayListenerOnField(
autofillFieldElement: ElementWithOpId<FormFieldElement>,
@ -27,6 +28,7 @@ interface AutofillOverlayContentService {
redirectOverlayFocusOut(direction: "previous" | "next"): void;
focusMostRecentOverlayField(): void;
blurMostRecentOverlayField(): void;
destroy(): void;
}
export { OpenAutofillOverlayOptions, AutofillOverlayContentService };

View File

@ -44,10 +44,12 @@ export interface GenerateFillScriptOptions {
}
export abstract class AutofillService {
loadAutofillScriptsOnInstall: () => Promise<void>;
reloadAutofillScripts: () => Promise<void>;
injectAutofillScripts: (
sender: chrome.runtime.MessageSender,
autofillV2?: boolean,
autofillOverlay?: boolean,
tab: chrome.tabs.Tab,
frameId?: number,
triggeringOnPageLoad?: boolean,
) => Promise<void>;
getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[];
doAutoFill: (options: AutoFillOptions) => Promise<string | null>;

View File

@ -22,6 +22,7 @@ interface CollectAutofillContentService {
filterCallback: CallableFunction,
isObservingShadowRoot?: boolean,
): Node[];
destroy(): void;
}
export {

View File

@ -1609,4 +1609,100 @@ describe("AutofillOverlayContentService", () => {
expect(autofillOverlayContentService["removeAutofillOverlay"]).toHaveBeenCalled();
});
});
describe("destroy", () => {
let autofillFieldElement: ElementWithOpId<FormFieldElement>;
let autofillFieldData: AutofillField;
beforeEach(() => {
document.body.innerHTML = `
<form id="validFormId">
<input type="text" id="username-field" placeholder="username" />
<input type="password" id="password-field" placeholder="password" />
</form>
`;
autofillFieldElement = document.getElementById(
"username-field",
) as ElementWithOpId<FormFieldElement>;
autofillFieldElement.opid = "op-1";
autofillFieldData = createAutofillFieldMock({
opid: "username-field",
form: "validFormId",
placeholder: "username",
elementNumber: 1,
});
autofillOverlayContentService.setupAutofillOverlayListenerOnField(
autofillFieldElement,
autofillFieldData,
);
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
});
it("disconnects all mutation observers", () => {
autofillOverlayContentService["setupMutationObserver"]();
jest.spyOn(autofillOverlayContentService["bodyElementMutationObserver"], "disconnect");
jest.spyOn(autofillOverlayContentService["documentElementMutationObserver"], "disconnect");
autofillOverlayContentService.destroy();
expect(
autofillOverlayContentService["documentElementMutationObserver"].disconnect,
).toHaveBeenCalled();
expect(
autofillOverlayContentService["bodyElementMutationObserver"].disconnect,
).toHaveBeenCalled();
});
it("clears the user interaction event timeout", () => {
jest.spyOn(autofillOverlayContentService as any, "clearUserInteractionEventTimeout");
autofillOverlayContentService.destroy();
expect(autofillOverlayContentService["clearUserInteractionEventTimeout"]).toHaveBeenCalled();
});
it("de-registers all global event listeners", () => {
jest.spyOn(globalThis.document, "removeEventListener");
jest.spyOn(globalThis, "removeEventListener");
jest.spyOn(autofillOverlayContentService as any, "removeOverlayRepositionEventListeners");
autofillOverlayContentService.destroy();
expect(globalThis.document.removeEventListener).toHaveBeenCalledWith(
EVENTS.VISIBILITYCHANGE,
autofillOverlayContentService["handleVisibilityChangeEvent"],
);
expect(globalThis.removeEventListener).toHaveBeenCalledWith(
EVENTS.FOCUSOUT,
autofillOverlayContentService["handleFormFieldBlurEvent"],
);
expect(
autofillOverlayContentService["removeOverlayRepositionEventListeners"],
).toHaveBeenCalled();
});
it("de-registers any event listeners that are attached to the form field elements", () => {
jest.spyOn(autofillOverlayContentService as any, "removeCachedFormFieldEventListeners");
jest.spyOn(autofillFieldElement, "removeEventListener");
jest.spyOn(autofillOverlayContentService["formFieldElements"], "delete");
autofillOverlayContentService.destroy();
expect(
autofillOverlayContentService["removeCachedFormFieldEventListeners"],
).toHaveBeenCalledWith(autofillFieldElement);
expect(autofillFieldElement.removeEventListener).toHaveBeenCalledWith(
EVENTS.BLUR,
autofillOverlayContentService["handleFormFieldBlurEvent"],
);
expect(autofillFieldElement.removeEventListener).toHaveBeenCalledWith(
EVENTS.KEYUP,
autofillOverlayContentService["handleFormFieldKeyupEvent"],
);
expect(autofillOverlayContentService["formFieldElements"].delete).toHaveBeenCalledWith(
autofillFieldElement,
);
});
});
});

View File

@ -10,16 +10,12 @@ import AutofillField from "../models/autofill-field";
import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe";
import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe";
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
import { generateRandomCustomElementName, sendExtensionMessage, setElementStyles } from "../utils";
import {
AutofillOverlayElement,
RedirectFocusDirection,
AutofillOverlayVisibility,
} from "../utils/autofill-overlay.enum";
import {
generateRandomCustomElementName,
sendExtensionMessage,
setElementStyles,
} from "../utils/utils";
import {
AutofillOverlayContentService as AutofillOverlayContentServiceInterface,
@ -32,9 +28,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
isCurrentlyFilling = false;
isOverlayCiphersPopulated = false;
pageDetailsUpdateRequired = false;
autofillOverlayVisibility: number;
private readonly findTabs = tabbable;
private readonly sendExtensionMessage = sendExtensionMessage;
private autofillOverlayVisibility: number;
private formFieldElements: Set<ElementWithOpId<FormFieldElement>> = new Set([]);
private userFilledFields: Record<string, FillableFormFieldElement> = {};
private authStatus: AuthenticationStatus;
private focusableElements: FocusableElement[] = [];
@ -47,6 +44,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
private userInteractionEventTimeout: NodeJS.Timeout;
private overlayElementsMutationObserver: MutationObserver;
private bodyElementMutationObserver: MutationObserver;
private documentElementMutationObserver: MutationObserver;
private mutationObserverIterations = 0;
private mutationObserverIterationsResetTimeout: NodeJS.Timeout;
private autofillFieldKeywordsMap: WeakMap<AutofillField, string> = new WeakMap();
@ -86,6 +84,8 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
return;
}
this.formFieldElements.add(formFieldElement);
if (!this.autofillOverlayVisibility) {
await this.getAutofillOverlayVisibility();
}
@ -901,10 +901,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
this.handleBodyElementMutationObserverUpdate,
);
const documentElementMutationObserver = new MutationObserver(
this.documentElementMutationObserver = new MutationObserver(
this.handleDocumentElementMutationObserverUpdate,
);
documentElementMutationObserver.observe(globalThis.document.documentElement, {
this.documentElementMutationObserver.observe(globalThis.document.documentElement, {
childList: true,
});
};
@ -1117,6 +1117,28 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
const documentRoot = element.getRootNode() as ShadowRoot | Document;
return documentRoot?.activeElement;
}
/**
* Destroys the autofill overlay content service. This method will
* disconnect the mutation observers and remove all event listeners.
*/
destroy() {
this.documentElementMutationObserver?.disconnect();
this.clearUserInteractionEventTimeout();
this.formFieldElements.forEach((formFieldElement) => {
this.removeCachedFormFieldEventListeners(formFieldElement);
formFieldElement.removeEventListener(EVENTS.BLUR, this.handleFormFieldBlurEvent);
formFieldElement.removeEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent);
this.formFieldElements.delete(formFieldElement);
});
globalThis.document.removeEventListener(
EVENTS.VISIBILITYCHANGE,
this.handleVisibilityChangeEvent,
);
globalThis.removeEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent);
this.removeAutofillOverlay();
this.removeOverlayRepositionEventListeners();
}
}
export default AutofillOverlayContentService;

View File

@ -2,7 +2,9 @@ import { mock, mockReset } from "jest-mock-extended";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ConfigService } from "@bitwarden/common/platform/services/config/config.service";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { SettingsService } from "@bitwarden/common/services/settings.service";
import {
@ -24,6 +26,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserStateService } from "../../platform/services/browser-state.service";
import { AutofillPort } from "../enums/autofill-port.enums";
import {
createAutofillFieldMock,
createAutofillPageDetailsMock,
@ -54,6 +57,7 @@ describe("AutofillService", () => {
const logService = mock<LogService>();
const settingsService = mock<SettingsService>();
const userVerificationService = mock<UserVerificationService>();
const configService = mock<ConfigService>();
beforeEach(() => {
autofillService = new AutofillService(
@ -64,6 +68,7 @@ describe("AutofillService", () => {
logService,
settingsService,
userVerificationService,
configService,
);
});
@ -72,6 +77,72 @@ describe("AutofillService", () => {
mockReset(cipherService);
});
describe("loadAutofillScriptsOnInstall", () => {
let tab1: chrome.tabs.Tab;
let tab2: chrome.tabs.Tab;
let tab3: chrome.tabs.Tab;
beforeEach(() => {
tab1 = createChromeTabMock({ id: 1, url: "https://some-url.com" });
tab2 = createChromeTabMock({ id: 2, url: "http://some-url.com" });
tab3 = createChromeTabMock({ id: 3, url: "chrome-extension://some-extension-route" });
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([tab1, tab2]);
});
it("queries all browser tabs and injects the autofill scripts into them", async () => {
jest.spyOn(autofillService, "injectAutofillScripts");
await autofillService.loadAutofillScriptsOnInstall();
expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({});
expect(autofillService.injectAutofillScripts).toHaveBeenCalledWith(tab1, 0, false);
expect(autofillService.injectAutofillScripts).toHaveBeenCalledWith(tab2, 0, false);
});
it("skips injecting scripts into tabs that do not have an http(s) protocol", async () => {
jest.spyOn(autofillService, "injectAutofillScripts");
await autofillService.loadAutofillScriptsOnInstall();
expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({});
expect(autofillService.injectAutofillScripts).not.toHaveBeenCalledWith(tab3);
});
it("sets up an extension runtime onConnect listener", async () => {
await autofillService.loadAutofillScriptsOnInstall();
// eslint-disable-next-line no-restricted-syntax
expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledWith(expect.any(Function));
});
});
describe("reloadAutofillScripts", () => {
it("disconnects and removes all autofill script ports", () => {
const port1 = mock<chrome.runtime.Port>({
disconnect: jest.fn(),
});
const port2 = mock<chrome.runtime.Port>({
disconnect: jest.fn(),
});
autofillService["autofillScriptPortsSet"] = new Set([port1, port2]);
autofillService.reloadAutofillScripts();
expect(port1.disconnect).toHaveBeenCalled();
expect(port2.disconnect).toHaveBeenCalled();
expect(autofillService["autofillScriptPortsSet"].size).toBe(0);
});
it("re-injects the autofill scripts in all tabs", () => {
autofillService["autofillScriptPortsSet"] = new Set([mock<chrome.runtime.Port>()]);
jest.spyOn(autofillService as any, "injectAutofillScriptsInAllTabs");
autofillService.reloadAutofillScripts();
expect(autofillService["injectAutofillScriptsInAllTabs"]).toHaveBeenCalled();
});
});
describe("injectAutofillScripts", () => {
const autofillV1Script = "autofill.js";
const autofillV2BootstrapScript = "bootstrap-autofill.js";
@ -83,12 +154,12 @@ describe("AutofillService", () => {
beforeEach(() => {
tabMock = createChromeTabMock();
sender = { tab: tabMock };
sender = { tab: tabMock, frameId: 1 };
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
});
it("accepts an extension message sender and injects the autofill scripts into the tab of the sender", async () => {
await autofillService.injectAutofillScripts(sender);
await autofillService.injectAutofillScripts(sender.tab, sender.frameId, true);
[autofillV1Script, ...defaultAutofillScripts].forEach((scriptName) => {
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
@ -105,7 +176,11 @@ describe("AutofillService", () => {
});
it("will inject the bootstrap-autofill script if the enableAutofillV2 flag is set", async () => {
await autofillService.injectAutofillScripts(sender, true);
jest
.spyOn(configService, "getFeatureFlag")
.mockImplementation((flag) => Promise.resolve(flag === FeatureFlag.AutofillV2));
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV2BootstrapScript}`,
@ -120,11 +195,16 @@ describe("AutofillService", () => {
});
it("will inject the bootstrap-autofill-overlay script if the enableAutofillOverlay flag is set and the user has the autofill overlay enabled", async () => {
jest
.spyOn(configService, "getFeatureFlag")
.mockImplementation((flag) =>
Promise.resolve(flag === FeatureFlag.AutofillOverlay || flag === FeatureFlag.AutofillV2),
);
jest
.spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus);
await autofillService.injectAutofillScripts(sender, true, true);
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillOverlayBootstrapScript}`,
@ -144,18 +224,25 @@ describe("AutofillService", () => {
});
it("will inject the bootstrap-autofill script if the enableAutofillOverlay flag is set but the user does not have the autofill overlay enabled", async () => {
jest
.spyOn(configService, "getFeatureFlag")
.mockImplementation((flag) =>
Promise.resolve(flag === FeatureFlag.AutofillOverlay || flag === FeatureFlag.AutofillV2),
);
jest
.spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.Off);
await autofillService.injectAutofillScripts(sender, true, true);
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV2BootstrapScript}`,
frameId: sender.frameId,
...defaultExecuteScriptOptions,
});
expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, {
file: `content/${autofillV1Script}`,
frameId: sender.frameId,
...defaultExecuteScriptOptions,
});
});
@ -4436,4 +4523,58 @@ describe("AutofillService", () => {
expect(autofillService["currentlyOpeningPasswordRepromptPopout"]).toBe(false);
});
});
describe("handleInjectedScriptPortConnection", () => {
it("ignores port connections that do not have the correct port name", () => {
const port = mock<chrome.runtime.Port>({
name: "some-invalid-port-name",
onDisconnect: { addListener: jest.fn() },
}) as any;
autofillService["handleInjectedScriptPortConnection"](port);
expect(port.onDisconnect.addListener).not.toHaveBeenCalled();
expect(autofillService["autofillScriptPortsSet"].size).toBe(0);
});
it("adds the connect port to the set of injected script ports and sets up an onDisconnect listener", () => {
const port = mock<chrome.runtime.Port>({
name: AutofillPort.InjectedScript,
onDisconnect: { addListener: jest.fn() },
}) as any;
jest.spyOn(autofillService as any, "handleInjectScriptPortOnDisconnect");
autofillService["handleInjectedScriptPortConnection"](port);
expect(port.onDisconnect.addListener).toHaveBeenCalledWith(
autofillService["handleInjectScriptPortOnDisconnect"],
);
expect(autofillService["autofillScriptPortsSet"].size).toBe(1);
});
});
describe("handleInjectScriptPortOnDisconnect", () => {
it("ignores port disconnections that do not have the correct port name", () => {
autofillService["autofillScriptPortsSet"].add(mock<chrome.runtime.Port>());
autofillService["handleInjectScriptPortOnDisconnect"](
mock<chrome.runtime.Port>({
name: "some-invalid-port-name",
}),
);
expect(autofillService["autofillScriptPortsSet"].size).toBe(1);
});
it("removes the port from the set of injected script ports", () => {
const port = mock<chrome.runtime.Port>({
name: AutofillPort.InjectedScript,
}) as any;
autofillService["autofillScriptPortsSet"].add(port);
autofillService["handleInjectScriptPortOnDisconnect"](port);
expect(autofillService["autofillScriptPortsSet"].size).toBe(0);
});
});
});

View File

@ -2,6 +2,8 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
@ -13,6 +15,7 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service";
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
import { AutofillPort } from "../enums/autofill-port.enums";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script";
@ -35,6 +38,7 @@ export default class AutofillService implements AutofillServiceInterface {
private openVaultItemPasswordRepromptPopout = openVaultItemPasswordRepromptPopout;
private openPasswordRepromptPopoutDebounce: NodeJS.Timeout;
private currentlyOpeningPasswordRepromptPopout = false;
private autofillScriptPortsSet = new Set<chrome.runtime.Port>();
constructor(
private cipherService: CipherService,
@ -44,23 +48,54 @@ export default class AutofillService implements AutofillServiceInterface {
private logService: LogService,
private settingsService: SettingsService,
private userVerificationService: UserVerificationService,
private configService: ConfigServiceAbstraction,
) {}
/**
* Triggers on installation of the extension Handles injecting
* content scripts into all tabs that are currently open, and
* sets up a listener to ensure content scripts can identify
* if the extension context has been disconnected.
*/
async loadAutofillScriptsOnInstall() {
BrowserApi.addListener(chrome.runtime.onConnect, this.handleInjectedScriptPortConnection);
this.injectAutofillScriptsInAllTabs();
}
/**
* Triggers a complete reload of all autofill scripts on tabs open within
* the user's browsing session. This is done by first disconnecting all
* existing autofill content script ports, which cleans up existing object
* instances, and then re-injecting the autofill scripts into all tabs.
*/
async reloadAutofillScripts() {
this.autofillScriptPortsSet.forEach((port) => {
port.disconnect();
this.autofillScriptPortsSet.delete(port);
});
this.injectAutofillScriptsInAllTabs();
}
/**
* Injects the autofill scripts into the current tab and all frames
* found within the tab. Temporarily, will conditionally inject
* the refactor of the core autofill script if the feature flag
* is enabled.
* @param {chrome.runtime.MessageSender} sender
* @param {boolean} autofillV2
* @param {boolean} autofillOverlay
* @returns {Promise<void>}
* @param {chrome.tabs.Tab} tab
* @param {number} frameId
* @param {boolean} triggeringOnPageLoad
*/
async injectAutofillScripts(
sender: chrome.runtime.MessageSender,
autofillV2 = false,
autofillOverlay = false,
) {
tab: chrome.tabs.Tab,
frameId = 0,
triggeringOnPageLoad = true,
): Promise<void> {
const autofillV2 = await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillV2);
const autofillOverlay = await this.configService.getFeatureFlag<boolean>(
FeatureFlag.AutofillOverlay,
);
let mainAutofillScript = "autofill.js";
const isUsingAutofillOverlay =
@ -73,20 +108,24 @@ export default class AutofillService implements AutofillServiceInterface {
: "bootstrap-autofill.js";
}
const injectedScripts = [
mainAutofillScript,
"autofiller.js",
"notificationBar.js",
"contextMenuHandler.js",
];
const injectedScripts = [mainAutofillScript];
if (triggeringOnPageLoad) {
injectedScripts.push("autofiller.js");
}
injectedScripts.push("notificationBar.js", "contextMenuHandler.js");
for (const injectedScript of injectedScripts) {
await BrowserApi.executeScriptInTab(sender.tab.id, {
await BrowserApi.executeScriptInTab(tab.id, {
file: `content/${injectedScript}`,
frameId: sender.frameId,
frameId,
runAt: "document_start",
});
}
await BrowserApi.executeScriptInTab(tab.id, {
file: "content/message_handler.js",
runAt: "document_start",
});
}
/**
@ -1877,4 +1916,47 @@ export default class AutofillService implements AutofillServiceInterface {
return false;
}
/**
* Handles incoming long-lived connections from injected autofill scripts.
* Stores the port in a set to facilitate disconnecting ports if the extension
* needs to re-inject the autofill scripts.
*
* @param port - The port that was connected
*/
private handleInjectedScriptPortConnection = (port: chrome.runtime.Port) => {
if (port.name !== AutofillPort.InjectedScript) {
return;
}
this.autofillScriptPortsSet.add(port);
port.onDisconnect.addListener(this.handleInjectScriptPortOnDisconnect);
};
/**
* Handles disconnecting ports that relate to injected autofill scripts.
* @param port - The port that was disconnected
*/
private handleInjectScriptPortOnDisconnect = (port: chrome.runtime.Port) => {
if (port.name !== AutofillPort.InjectedScript) {
return;
}
this.autofillScriptPortsSet.delete(port);
};
/**
* Queries all open tabs in the user's browsing session
* and injects the autofill scripts into the page.
*/
private async injectAutofillScriptsInAllTabs() {
const tabs = await BrowserApi.tabsQuery({});
for (let index = 0; index < tabs.length; index++) {
const tab = tabs[index];
if (tab.url?.startsWith("http")) {
this.injectAutofillScripts(tab, 0, false);
}
}
}
}

View File

@ -1249,6 +1249,17 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
return attributeValue;
}
/**
* Destroys the CollectAutofillContentService. Clears all
* timeouts and disconnects the mutation observer.
*/
destroy() {
if (this.updateAutofillElementsAfterMutationTimeout) {
clearTimeout(this.updateAutofillElementsAfterMutationTimeout);
}
this.mutationObserver?.disconnect();
}
}
export default CollectAutofillContentService;

View File

@ -1,10 +1,17 @@
import { AutofillPort } from "../enums/autofill-port.enums";
import { triggerPortOnDisconnectEvent } from "../jest/testing-utils";
import { logoIcon, logoLockedIcon } from "./svg-icons";
import {
buildSvgDomElement,
generateRandomCustomElementName,
sendExtensionMessage,
setElementStyles,
} from "./utils";
getFromLocalStorage,
setupExtensionDisconnectAction,
setupAutofillInitDisconnectAction,
} from "./index";
describe("buildSvgDomElement", () => {
it("returns an SVG DOM element", () => {
@ -116,3 +123,107 @@ describe("setElementStyles", () => {
expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);
});
});
describe("getFromLocalStorage", () => {
it("returns a promise with the storage object pulled from the extension storage api", async () => {
const localStorage: Record<string, any> = {
testValue: "test",
another: "another",
};
jest.spyOn(chrome.storage.local, "get").mockImplementation((keys, callback) => {
const localStorageObject: Record<string, string> = {};
if (typeof keys === "string") {
localStorageObject[keys] = localStorage[keys];
} else if (Array.isArray(keys)) {
for (const key of keys) {
localStorageObject[key] = localStorage[key];
}
}
callback(localStorageObject);
});
const returnValue = await getFromLocalStorage("testValue");
expect(chrome.storage.local.get).toHaveBeenCalled();
expect(returnValue).toEqual({ testValue: "test" });
});
});
describe("setupExtensionDisconnectAction", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("connects a port to the extension background and sets up an onDisconnect listener", () => {
const onDisconnectCallback = jest.fn();
let port: chrome.runtime.Port;
jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
port = {
onDisconnect: {
addListener: onDisconnectCallback,
removeListener: jest.fn(),
},
} as unknown as chrome.runtime.Port;
return port;
});
setupExtensionDisconnectAction(onDisconnectCallback);
expect(chrome.runtime.connect).toHaveBeenCalledWith({
name: AutofillPort.InjectedScript,
});
expect(port.onDisconnect.addListener).toHaveBeenCalledWith(expect.any(Function));
});
});
describe("setupAutofillInitDisconnectAction", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("skips setting up the extension disconnect action if the bitwardenAutofillInit object is not populated", () => {
const onDisconnectCallback = jest.fn();
window.bitwardenAutofillInit = undefined;
const portConnectSpy = jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
return {
onDisconnect: {
addListener: onDisconnectCallback,
removeListener: jest.fn(),
},
} as unknown as chrome.runtime.Port;
});
setupAutofillInitDisconnectAction(window);
expect(portConnectSpy).not.toHaveBeenCalled();
});
it("destroys the autofill init instance when the port is disconnected", () => {
let port: chrome.runtime.Port;
const autofillInitDestroy: CallableFunction = jest.fn();
window.bitwardenAutofillInit = {
destroy: autofillInitDestroy,
} as any;
jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
port = {
onDisconnect: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
} as unknown as chrome.runtime.Port;
return port;
});
setupAutofillInitDisconnectAction(window);
triggerPortOnDisconnectEvent(port as chrome.runtime.Port);
expect(chrome.runtime.connect).toHaveBeenCalled();
expect(port.onDisconnect.addListener).toHaveBeenCalled();
expect(autofillInitDestroy).toHaveBeenCalled();
expect(window.bitwardenAutofillInit).toBeUndefined();
});
});

View File

@ -1,3 +1,5 @@
import { AutofillPort } from "../enums/autofill-port.enums";
/**
* Generates a random string of characters that formatted as a custom element name.
*/
@ -103,9 +105,57 @@ function setElementStyles(
}
}
/**
* Get data from local storage based on the keys provided.
*
* @param keys - String or array of strings of keys to get from local storage
*/
async function getFromLocalStorage(keys: string | string[]): Promise<Record<string, any>> {
return new Promise((resolve) => {
chrome.storage.local.get(keys, (storage: Record<string, any>) => resolve(storage));
});
}
/**
* Sets up a long-lived connection with the extension background
* and triggers an onDisconnect event if the extension context
* is invalidated.
*
* @param callback - Callback function to run when the extension disconnects
*/
function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => void) {
const port = chrome.runtime.connect({ name: AutofillPort.InjectedScript });
const onDisconnectCallback = (disconnectedPort: chrome.runtime.Port) => {
callback(disconnectedPort);
port.onDisconnect.removeListener(onDisconnectCallback);
};
port.onDisconnect.addListener(onDisconnectCallback);
}
/**
* Handles setup of the extension disconnect action for the autofill init class
* in both instances where the overlay might or might not be initialized.
*
* @param windowContext - The global window context
*/
function setupAutofillInitDisconnectAction(windowContext: Window) {
if (!windowContext.bitwardenAutofillInit) {
return;
}
const onDisconnectCallback = () => {
windowContext.bitwardenAutofillInit.destroy();
delete windowContext.bitwardenAutofillInit;
};
setupExtensionDisconnectAction(onDisconnectCallback);
}
export {
generateRandomCustomElementName,
buildSvgDomElement,
sendExtensionMessage,
setElementStyles,
getFromLocalStorage,
setupExtensionDisconnectAction,
setupAutofillInitDisconnectAction,
};

View File

@ -566,6 +566,7 @@ export default class MainBackground {
this.logService,
this.settingsService,
this.userVerificationService,
this.configService,
);
this.auditService = new AuditService(this.cryptoFunctionService, this.apiService);

View File

@ -1,5 +1,4 @@
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@ -97,9 +96,9 @@ export default class RuntimeBackground {
await closeUnlockPopout();
}
await this.notificationsService.updateConnection(msg.command === "loggedIn");
await this.main.refreshBadge();
await this.main.refreshMenu(false);
this.notificationsService.updateConnection(msg.command === "unlocked");
this.systemService.cancelProcessReload();
if (item) {
@ -133,11 +132,7 @@ export default class RuntimeBackground {
await this.main.openPopup();
break;
case "triggerAutofillScriptInjection":
await this.autofillService.injectAutofillScripts(
sender,
await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillV2),
await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillOverlay),
);
await this.autofillService.injectAutofillScripts(sender.tab, sender.frameId);
break;
case "bgCollectPageDetails":
await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId);
@ -325,6 +320,8 @@ export default class RuntimeBackground {
private async checkOnInstalled() {
setTimeout(async () => {
this.autofillService.loadAutofillScriptsOnInstall();
if (this.onInstalledReason != null) {
if (this.onInstalledReason === "install") {
BrowserApi.createNewTab("https://bitwarden.com/browser-start/");

View File

@ -24,12 +24,6 @@
"matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start"
},
{
"all_frames": false,
"js": ["content/message_handler.js"],
"matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start"
},
{
"all_frames": true,
"css": ["content/autofill.css"],

View File

@ -23,11 +23,12 @@ const runtime = {
removeListener: jest.fn(),
},
sendMessage: jest.fn(),
getManifest: jest.fn(),
getManifest: jest.fn(() => ({ version: 2 })),
getURL: jest.fn((path) => `chrome-extension://id/${path}`),
connect: jest.fn(),
onConnect: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
};