diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 638e9f0b77..7d3254eea0 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -3,7 +3,10 @@ import { BehaviorSubject } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; +import { + AutofillOverlayVisibility, + SHOW_AUTOFILL_BUTTON, +} from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction as AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DefaultDomainSettingsService, @@ -44,7 +47,14 @@ import { createPortSpyMock, createFocusedFieldDataMock, } from "../spec/autofill-mocks"; -import { flushPromises, sendMockExtensionMessage, sendPortMessage } from "../spec/testing-utils"; +import { + flushPromises, + sendMockExtensionMessage, + sendPortMessage, + triggerPortOnConnectEvent, + triggerPortOnDisconnectEvent, + triggerPortOnMessageEvent, +} from "../spec/testing-utils"; import { FocusedFieldData, @@ -93,19 +103,19 @@ describe("OverlayBackground", () => { async function initOverlayElementPorts(options = { initList: true, initButton: true }) { const { initList, initButton } = options; if (initButton) { - await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.Button)); + triggerPortOnConnectEvent(createPortSpyMock(AutofillOverlayPort.Button)); buttonPortSpy = overlayBackground["inlineMenuButtonPort"]; buttonMessageConnectorSpy = createPortSpyMock(AutofillOverlayPort.ButtonMessageConnector); - await overlayBackground["handlePortOnConnect"](buttonMessageConnectorSpy); + triggerPortOnConnectEvent(buttonMessageConnectorSpy); } if (initList) { - await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.List)); + triggerPortOnConnectEvent(createPortSpyMock(AutofillOverlayPort.List)); listPortSpy = overlayBackground["inlineMenuListPort"]; listMessageConnectorSpy = createPortSpyMock(AutofillOverlayPort.ListMessageConnector); - await overlayBackground["handlePortOnConnect"](listMessageConnectorSpy); + triggerPortOnConnectEvent(listMessageConnectorSpy); } return { buttonPortSpy, listPortSpy }; @@ -1269,6 +1279,25 @@ describe("OverlayBackground", () => { }); }); + describe("handle extension onMessage", () => { + it("will return early if the message command is not present within the extensionMessageHandlers", () => { + const message = { + command: "not-a-command", + }; + const sender = mock({ tab: { id: 1 } }); + const sendResponse = jest.fn(); + + const returnValue = overlayBackground["handleExtensionMessage"]( + message, + sender, + sendResponse, + ); + + expect(returnValue).toBe(undefined); + expect(sendResponse).not.toHaveBeenCalled(); + }); + }); + describe("inline menu button message handlers", () => { let sender: chrome.runtime.MessageSender; const portKey = "inlineMenuButtonPort"; @@ -1613,5 +1642,152 @@ describe("OverlayBackground", () => { ); }); }); + + describe("viewSelectedCipher message handler", () => { + let openViewVaultItemPopoutSpy: jest.SpyInstance; + + beforeEach(() => { + openViewVaultItemPopoutSpy = jest + .spyOn(overlayBackground as any, "openViewVaultItemPopout") + .mockImplementation(); + }); + + it("returns early if the passed cipher ID does not match one of the inline menu ciphers", async () => { + overlayBackground["inlineMenuCiphers"] = new Map([ + ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], + ]); + + sendPortMessage(listMessageConnectorSpy, { + command: "viewSelectedCipher", + overlayCipherId: "overlay-cipher-1", + portKey, + }); + await flushPromises(); + + expect(openViewVaultItemPopoutSpy).not.toHaveBeenCalled(); + }); + + it("will open the view vault item popout with the selected cipher", async () => { + const cipher = mock({ id: "overlay-cipher-1" }); + overlayBackground["inlineMenuCiphers"] = new Map([ + ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], + ["overlay-cipher-1", cipher], + ]); + + sendPortMessage(listMessageConnectorSpy, { + command: "viewSelectedCipher", + overlayCipherId: "overlay-cipher-1", + portKey, + }); + await flushPromises(); + + expect(openViewVaultItemPopoutSpy).toHaveBeenCalledWith(sender.tab, { + cipherId: cipher.id, + action: SHOW_AUTOFILL_BUTTON, + }); + }); + }); + + describe("redirectAutofillInlineMenuFocusOut message handler", () => { + it("redirects focus out of the inline menu list", async () => { + sendPortMessage(listMessageConnectorSpy, { + command: "redirectAutofillInlineMenuFocusOut", + direction: RedirectFocusDirection.Next, + portKey, + }); + await flushPromises(); + + expect(tabSendMessageDataSpy).toHaveBeenCalledWith( + sender.tab, + "redirectAutofillInlineMenuFocusOut", + { direction: RedirectFocusDirection.Next }, + ); + }); + }); + + describe("updateAutofillInlineMenuListHeight message handler", () => { + it("sends a message to the list port to update the menu iframe position", () => { + sendPortMessage(listMessageConnectorSpy, { + command: "updateAutofillInlineMenuListHeight", + styles: { height: "100px" }, + portKey, + }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateInlineMenuIframePosition", + styles: { height: "100px" }, + }); + }); + }); + }); + + describe("handle port onConnect", () => { + it("skips setting up the overlay port if the port connection is not for an overlay element", async () => { + const port = createPortSpyMock("not-an-overlay-element"); + + triggerPortOnConnectEvent(port); + await flushPromises(); + + expect(port.onMessage.addListener).not.toHaveBeenCalled(); + expect(port.postMessage).not.toHaveBeenCalled(); + }); + + it("stores an existing overlay port so that it can be disconnected at a later time", async () => { + overlayBackground["inlineMenuButtonPort"] = mock(); + + await initOverlayElementPorts({ initList: false, initButton: true }); + await flushPromises(); + + expect(overlayBackground["expiredPorts"].length).toBe(1); + }); + }); + + describe("handle overlay element port onMessage", () => { + let sender: chrome.runtime.MessageSender; + const portKey = "inlineMenuListPort"; + + beforeEach(async () => { + sender = mock({ tab: { id: 1 } }); + portKeyForTabSpy[sender.tab.id] = portKey; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + await initOverlayElementPorts(); + listMessageConnectorSpy.sender = sender; + openUnlockPopoutSpy.mockImplementation(); + }); + + it("ignores messages that do not contain a valid portKey", async () => { + triggerPortOnMessageEvent(buttonMessageConnectorSpy, { + command: "autofillInlineMenuBlurred", + }); + await flushPromises(); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); + }); + + it("ignores messages from ports that are not listened to", () => { + triggerPortOnMessageEvent(buttonPortSpy, { + command: "autofillInlineMenuBlurred", + portKey, + }); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillInlineMenuListFocused", + }); + }); + }); + + describe("handle port onDisconnect", () => { + it("sets the disconnected port to a `null` value", async () => { + await initOverlayElementPorts(); + + triggerPortOnDisconnectEvent(buttonPortSpy); + triggerPortOnDisconnectEvent(listPortSpy); + await flushPromises(); + + expect(overlayBackground["inlineMenuListPort"]).toBeNull(); + expect(overlayBackground["inlineMenuButtonPort"]).toBeNull(); + }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 06c2a2e086..64abc4d8a8 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1170,7 +1170,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { message: OverlayBackgroundExtensionMessage, port: chrome.runtime.Port, ) => { - if (this.portKeyForTab[port.sender.tab.id] !== message?.portKey) { + const tabPortKey = this.portKeyForTab[port.sender.tab.id]; + if (!tabPortKey || tabPortKey !== message?.portKey) { return; } diff --git a/apps/browser/src/autofill/spec/testing-utils.ts b/apps/browser/src/autofill/spec/testing-utils.ts index 5b0db5ebd6..2b821d2f73 100644 --- a/apps/browser/src/autofill/spec/testing-utils.ts +++ b/apps/browser/src/autofill/spec/testing-utils.ts @@ -1,21 +1,21 @@ import { mock } from "jest-mock-extended"; -function triggerTestFailure() { +export function triggerTestFailure() { expect(true).toBe("Test has failed."); } const scheduler = typeof setImmediate === "function" ? setImmediate : setTimeout; -function flushPromises() { +export function flushPromises() { return new Promise(function (resolve) { scheduler(resolve); }); } -function postWindowMessage(data: any, origin = "https://localhost/", source = window) { +export function postWindowMessage(data: any, origin = "https://localhost/", source = window) { globalThis.dispatchEvent(new MessageEvent("message", { data, origin, source })); } -function sendMockExtensionMessage( +export function sendMockExtensionMessage( message: any, sender?: chrome.runtime.MessageSender, sendResponse?: CallableFunction, @@ -32,7 +32,7 @@ function sendMockExtensionMessage( ); } -function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) { +export function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) { (chrome.runtime.onConnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach( (call) => { const callback = call[0]; @@ -41,21 +41,37 @@ function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) { ); } -function sendPortMessage(port: chrome.runtime.Port, message: any) { +export function sendPortMessage(port: chrome.runtime.Port, message: any) { (port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; callback(message || {}, port); }); } -function triggerPortOnDisconnectEvent(port: chrome.runtime.Port) { +export function triggerPortOnConnectEvent(port: chrome.runtime.Port) { + (chrome.runtime.onConnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach( + (call) => { + const callback = call[0]; + callback(port); + }, + ); +} + +export function triggerPortOnMessageEvent(port: chrome.runtime.Port, message: any) { + (port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { + const callback = call[0]; + callback(message, port); + }); +} + +export function triggerPortOnDisconnectEvent(port: chrome.runtime.Port) { (port.onDisconnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; callback(port); }); } -function triggerWindowOnFocusedChangedEvent(windowId: number) { +export function triggerWindowOnFocusedChangedEvent(windowId: number) { (chrome.windows.onFocusChanged.addListener as unknown as jest.SpyInstance).mock.calls.forEach( (call) => { const callback = call[0]; @@ -64,7 +80,7 @@ function triggerWindowOnFocusedChangedEvent(windowId: number) { ); } -function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) { +export function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) { (chrome.tabs.onActivated.addListener as unknown as jest.SpyInstance).mock.calls.forEach( (call) => { const callback = call[0]; @@ -73,14 +89,14 @@ function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) { ); } -function triggerTabOnReplacedEvent(addedTabId: number, removedTabId: number) { +export function triggerTabOnReplacedEvent(addedTabId: number, removedTabId: number) { (chrome.tabs.onReplaced.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; callback(addedTabId, removedTabId); }); } -function triggerTabOnUpdatedEvent( +export function triggerTabOnUpdatedEvent( tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab, @@ -91,14 +107,14 @@ function triggerTabOnUpdatedEvent( }); } -function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.TabRemoveInfo) { +export function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.TabRemoveInfo) { (chrome.tabs.onRemoved.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; callback(tabId, removeInfo); }); } -function mockQuerySelectorAllDefinedCall() { +export function mockQuerySelectorAllDefinedCall() { const originalDocumentQuerySelectorAll = document.querySelectorAll; document.querySelectorAll = function (selector: string) { return originalDocumentQuerySelectorAll.call( @@ -125,19 +141,3 @@ function mockQuerySelectorAllDefinedCall() { }, }; } - -export { - triggerTestFailure, - flushPromises, - postWindowMessage, - sendMockExtensionMessage, - triggerRuntimeOnConnectEvent, - sendPortMessage, - triggerPortOnDisconnectEvent, - triggerWindowOnFocusedChangedEvent, - triggerTabOnActivatedEvent, - triggerTabOnReplacedEvent, - triggerTabOnUpdatedEvent, - triggerTabOnRemovedEvent, - mockQuerySelectorAllDefinedCall, -};