diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index f08176469c..65289c02da 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -94,7 +94,7 @@ export type OverlayBackgroundExtensionMessageHandlers = { [key: string]: CallableFunction; autofillOverlayElementClosed: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - checkIsOverlayLoginCiphersPopulated: ({ sender }: BackgroundSenderParam) => void; + checkIsInlineMenuCiphersPopulated: ({ sender }: BackgroundSenderParam) => void; updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; updateIsFieldCurrentlyFocused: ({ message }: BackgroundMessageParam) => void; checkIsFieldCurrentlyFocused: () => boolean; diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index b6f05415c0..e1da0a1c4a 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -32,9 +32,14 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserPlatformUtilsService } from "../../platform/services/platform-utils/browser-platform-utils.service"; -import { AutofillOverlayElement } from "../enums/autofill-overlay.enum"; +import { AutofillOverlayElement, AutofillOverlayPort } from "../enums/autofill-overlay.enum"; import { AutofillService } from "../services/abstractions/autofill.service"; -import { createChromeTabMock, createAutofillPageDetailsMock } from "../spec/autofill-mocks"; +import { + createChromeTabMock, + createAutofillPageDetailsMock, + createPortSpyMock, + createFocusedFieldDataMock, +} from "../spec/autofill-mocks"; import { flushPromises, sendMockExtensionMessage } from "../spec/testing-utils"; import { @@ -47,6 +52,7 @@ import { OverlayBackground } from "./overlay.background"; describe("OverlayBackground", () => { const mockUserId = Utils.newGuid() as UserId; + const sendResponse = jest.fn(); let accountService: FakeAccountService; let fakeStateProvider: FakeStateProvider; let showFaviconsMock$: BehaviorSubject; @@ -70,8 +76,26 @@ describe("OverlayBackground", () => { let subFrameOffsetsSpy: SubFrameOffsetsForTab; let getFrameDetailsSpy: jest.SpyInstance; let tabsSendMessageSpy: jest.SpyInstance; + let sendMessageSpy: jest.SpyInstance; let getTabFromCurrentWindowIdSpy: jest.SpyInstance; + let buttonPortSpy: chrome.runtime.Port; + let listPortSpy: chrome.runtime.Port; + let getFrameCounter: number = 2; + async function initOverlayElementPorts(options = { initList: true, initButton: true }) { + const { initList, initButton } = options; + if (initButton) { + await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.Button)); + buttonPortSpy = overlayBackground["inlineMenuButtonPort"]; + } + + if (initList) { + await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.List)); + listPortSpy = overlayBackground["inlineMenuListPort"]; + } + + return { buttonPortSpy, listPortSpy }; + } beforeEach(() => { accountService = mockAccountServiceWith(mockUserId); @@ -125,6 +149,7 @@ describe("OverlayBackground", () => { }); }); tabsSendMessageSpy = jest.spyOn(BrowserApi, "tabSendMessage"); + sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage"); getTabFromCurrentWindowIdSpy = jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); void overlayBackground.init(); @@ -349,11 +374,17 @@ describe("OverlayBackground", () => { beforeEach(() => { sender = mock({ tab, frameId: middleFrameId }); jest.useFakeTimers(); - overlayBackground["isFieldCurrentlyFocused"] = true; + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: true, + }); }); it("skips updating the position of either inline menu element if a field is not currently focused", async () => { - overlayBackground["isFieldCurrentlyFocused"] = false; + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: false, + }); sendMockExtensionMessage({ command: "rebuildSubFrameOffsets" }, sender); await flushInlineMenuUpdatePromises(); @@ -430,7 +461,7 @@ describe("OverlayBackground", () => { }); }); - describe("updateOverlayCiphers", () => { + describe("updating the overlay ciphers", () => { const url = "https://jest-testing-website.com"; const tab = createChromeTabMock({ url }); const cipher1 = mock({ @@ -480,7 +511,7 @@ describe("OverlayBackground", () => { expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url); expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); - expect(overlayBackground["overlayLoginCiphers"]).toStrictEqual( + expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( new Map([ ["overlay-cipher-0", cipher2], ["overlay-cipher-1", cipher1], @@ -535,4 +566,230 @@ describe("OverlayBackground", () => { }); }); }); + + describe("extension message handlers", () => { + describe("autofillOverlayElementClosed message handler", () => { + beforeEach(async () => { + await initOverlayElementPorts(); + }); + + it("disconnects any expired ports if the sender is not from the same page as the most recently focused field", () => { + const port1 = mock(); + const port2 = mock(); + overlayBackground["expiredPorts"] = [port1, port2]; + const sender = mock({ tab: { id: 1 } }); + const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendMockExtensionMessage( + { + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, + }, + sender, + ); + + expect(port1.disconnect).toHaveBeenCalled(); + expect(port2.disconnect).toHaveBeenCalled(); + }); + + it("disconnects the button element port", () => { + sendMockExtensionMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.disconnect).toHaveBeenCalled(); + }); + + it("disconnects the list element port", () => { + sendMockExtensionMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.List, + }); + + expect(listPortSpy.disconnect).toHaveBeenCalled(); + }); + }); + + describe("autofillOverlayAddNewVaultItem message handler", () => { + let sender: chrome.runtime.MessageSender; + let openAddEditVaultItemPopoutSpy: jest.SpyInstance; + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + openAddEditVaultItemPopoutSpy = jest + .spyOn(overlayBackground as any, "openAddEditVaultItemPopout") + .mockImplementation(); + }); + + it("will not open the add edit popout window if the message does not have a login cipher provided", () => { + sendMockExtensionMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); + + expect(cipherService.setAddEditCipherInfo).not.toHaveBeenCalled(); + expect(openAddEditVaultItemPopoutSpy).not.toHaveBeenCalled(); + }); + + it("will open the add edit popout window after creating a new cipher", async () => { + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + login: { + uri: "https://tacos.com", + hostname: "", + username: "username", + password: "password", + }, + }, + sender, + ); + await flushPromises(); + + expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher"); + expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled(); + }); + }); + + describe("checkIsInlineMenuCiphersPopulated message handler", () => { + let focusedFieldData: FocusedFieldData; + + beforeEach(() => { + focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + mock({ tab: { id: 2 }, frameId: 0 }), + ); + }); + + it("returns false if the sender's tab id is not equal to the focused field's tab id", async () => { + const sender = mock({ tab: { id: 1 } }); + + sendMockExtensionMessage( + { command: "checkIsInlineMenuCiphersPopulated" }, + sender, + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(false); + }); + + it("returns false if the overlay login cipher are not populated", () => {}); + + it("returns true if the overlay login ciphers are populated", async () => { + overlayBackground["inlineMenuCiphers"] = new Map([ + ["overlay-cipher-0", mock()], + ]); + + sendMockExtensionMessage( + { command: "checkIsInlineMenuCiphersPopulated" }, + mock({ tab: { id: 2 } }), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("checkIsFieldCurrentlyFocused message handler", () => { + it("returns true when a form field is currently focused", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: true, + }); + + sendMockExtensionMessage( + { command: "checkIsFieldCurrentlyFocused" }, + mock(), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("checkIsFieldCurrentlyFilling message handler", () => { + it("returns true if autofill is currently running", async () => { + sendMockExtensionMessage({ + command: "updateIsFieldCurrentlyFilling", + isFieldCurrentlyFilling: true, + }); + + sendMockExtensionMessage( + { command: "checkIsFieldCurrentlyFilling" }, + mock(), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(true); + }); + }); + + describe("getAutofillInlineMenuVisibility message handler", () => { + it("returns the current inline menu visibility setting", async () => { + sendMockExtensionMessage( + { command: "getAutofillInlineMenuVisibility" }, + mock(), + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith(AutofillOverlayVisibility.OnFieldFocus); + }); + }); + + describe("openAutofillInlineMenu", () => { + let sender: chrome.runtime.MessageSender; + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + getTabFromCurrentWindowIdSpy.mockResolvedValue(sender.tab); + tabsSendMessageSpy.mockImplementation(); + }); + + it("opens the autofill inline menu by sending a message to the current tab", async () => { + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "openAutofillInlineMenu", + isFocusingFieldElement: false, + isOpeningFullAutofillInlineMenu: false, + authStatus: 2, + }, + { frameId: 0 }, + ); + }); + + it("sends the open menu message to the focused field's frameId", async () => { + sender.frameId = 10; + sendMockExtensionMessage({ command: "updateFocusedFieldData" }, sender); + await flushPromises(); + + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }, sender); + await flushPromises(); + + expect(tabsSendMessageSpy).toHaveBeenCalledWith( + sender.tab, + { + command: "openAutofillInlineMenu", + isFocusingFieldElement: false, + isOpeningFullAutofillInlineMenu: false, + authStatus: 2, + }, + { frameId: 10 }, + ); + }); + }); + }); + + describe("inline menu button message handlers", () => {}); + + describe("inline menu list message handlers", () => {}); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 70ba44aecc..8a519ad5a1 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -49,7 +49,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { private readonly openUnlockPopout = openUnlockPopout; private readonly openViewVaultItemPopout = openViewVaultItemPopout; private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout; - private overlayLoginCiphers: Map = new Map(); + private inlineMenuCiphers: Map = new Map(); private pageDetailsForTab: PageDetailsForTab = {}; private subFrameOffsetsForTab: SubFrameOffsetsForTab = {}; private updateInlineMenuPositionTimeout: number | NodeJS.Timeout; @@ -67,8 +67,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { autofillOverlayElementClosed: ({ message, sender }) => this.overlayElementClosed(message, sender), autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender), - checkIsOverlayLoginCiphersPopulated: ({ sender }) => - this.checkIsOverlayLoginCiphersPopulated(sender), + checkIsInlineMenuCiphersPopulated: ({ sender }) => + this.checkIsInlineMenuCiphersPopulated(sender), updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender), updateIsFieldCurrentlyFocused: ({ message }) => this.updateIsFieldCurrentlyFocused(message), checkIsFieldCurrentlyFocused: () => this.checkIsFieldCurrentlyFocused(), @@ -179,12 +179,12 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } - this.overlayLoginCiphers = new Map(); + this.inlineMenuCiphers = new Map(); const ciphersViews = (await this.cipherService.getAllDecryptedForUrl(currentTab.url)).sort( (a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b), ); for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) { - this.overlayLoginCiphers.set(`overlay-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); + this.inlineMenuCiphers.set(`overlay-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); } const ciphers = await this.getOverlayCipherData(); @@ -200,7 +200,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { */ private async getOverlayCipherData(): Promise { const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); - const overlayCiphersArray = Array.from(this.overlayLoginCiphers); + const overlayCiphersArray = Array.from(this.inlineMenuCiphers); const overlayCipherData: OverlayCipherData[] = []; for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) { @@ -388,7 +388,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { ); if ( mostRecentlyFocusedFieldHasValue && - (this.checkIsOverlayLoginCiphersPopulated(sender) || + (this.checkIsInlineMenuCiphersPopulated(sender) || (await this.getAuthStatus()) !== AuthenticationStatus.Unlocked) ) { return; @@ -401,7 +401,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { * Triggers autofill for the selected cipher in the inline menu list. Also places * the selected cipher at the top of the list of ciphers. * - * @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID. + * @param overlayCipherId - Cipher ID corresponding to the inlineMenuCiphers map. Does not correspond to the actual cipher's ID. * @param sender - The sender of the port message */ private async fillSelectedInlineMenuListItem( @@ -413,7 +413,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } - const cipher = this.overlayLoginCiphers.get(overlayCipherId); + const cipher = this.inlineMenuCiphers.get(overlayCipherId); if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) { return; @@ -430,7 +430,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.platformUtilsService.copyToClipboard(totpCode); } - this.overlayLoginCiphers = new Map([[overlayCipherId, cipher], ...this.overlayLoginCiphers]); + this.inlineMenuCiphers = new Map([[overlayCipherId, cipher], ...this.inlineMenuCiphers]); } /** @@ -801,14 +801,14 @@ export class OverlayBackground implements OverlayBackgroundInterface { /** * Triggers the opening of a vault item popout window associated * with the passed cipher ID. - * @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID. + * @param overlayCipherId - Cipher ID corresponding to the inlineMenuCiphers map. Does not correspond to the actual cipher's ID. * @param sender - The sender of the port message */ private async viewSelectedCipher( { overlayCipherId }: OverlayPortMessage, { sender }: chrome.runtime.Port, ) { - const cipher = this.overlayLoginCiphers.get(overlayCipherId); + const cipher = this.inlineMenuCiphers.get(overlayCipherId); if (!cipher) { return; } @@ -945,18 +945,34 @@ export class OverlayBackground implements OverlayBackgroundInterface { await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); } + /** + * Updates the property that identifies if a form field set up for the inline menu is currently focused. + * + * @param message - The message received from the web page + */ private updateIsFieldCurrentlyFocused(message: OverlayBackgroundExtensionMessage) { this.isFieldCurrentlyFocused = message.isFieldCurrentlyFocused; } + /** + * Allows a content script to check if a form field setup for the inline menu is currently focused. + */ private checkIsFieldCurrentlyFocused() { return this.isFieldCurrentlyFocused; } + /** + * Updates the property that identifies if a form field is currently being autofilled. + * + * @param message - The message received from the web page + */ private updateIsFieldCurrentlyFilling(message: OverlayBackgroundExtensionMessage) { this.isFieldCurrentlyFilling = message.isFieldCurrentlyFilling; } + /** + * Allows a content script to check if a form field is currently being autofilled. + */ private checkIsFieldCurrentlyFilling() { return this.isFieldCurrentlyFilling; } @@ -977,8 +993,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { ); } - private checkIsOverlayLoginCiphersPopulated(sender: chrome.runtime.MessageSender) { - return sender.tab.id === this.focusedFieldData.tabId && this.overlayLoginCiphers.size > 0; + private checkIsInlineMenuCiphersPopulated(sender: chrome.runtime.MessageSender) { + return sender.tab.id === this.focusedFieldData.tabId && this.inlineMenuCiphers.size > 0; } private updateInlineMenuButtonColorScheme() { diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts index d9b574260c..b5b5075b59 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts @@ -18,7 +18,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe private ariaAlertTimeout: number | NodeJS.Timeout; private delayedCloseTimeout: number | NodeJS.Timeout; private readonly fadeInOpacityTransition = "opacity 125ms ease-out 0s"; - private readonly fadeOutOpacityTransition = "opacity 75ms ease-in 0s"; + private readonly fadeOutOpacityTransition = "opacity 60ms ease-out 0s"; private iframeStyles: Partial = { all: "initial", position: "fixed", diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 305bb76441..d23faf3fab 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -1022,7 +1022,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ } private async isInlineMenuCiphersPopulated() { - return (await this.sendExtensionMessage("checkIsOverlayLoginCiphersPopulated")) === true; + return (await this.sendExtensionMessage("checkIsInlineMenuCiphersPopulated")) === true; } /**