From 5b4e4d8f1a19b801634beab4e801e025a34dbc62 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Tue, 27 Aug 2024 13:31:44 -0500 Subject: [PATCH] [PM-10669] Fix inconsistencies with notification bar when saving or updating login credentials (#10617) * [PM-10669] Notification bar appears inconsistently after login * [PM-10669] Notification bar appears inconsistently after login * [PM-10669] Migrating work from POC branch into feature branch * [PM-10669] Incorporating styles for select element * [PM-10669] Incorporating styles for select element * [PM-10669] Fixing notification bar lifespan const * [PM-10669] Incorporating logic that conditionally loads specific bootstrap autofill feature files * [PM-10669] Incorporating logic to more smoothly handle transitioning between pages within the notification bar0 * [PM-10669] Incorporating logic to more smoothly handle transitioning between pages within the notification bar0 * [PM-10669] Incorporating logic to more smoothly handle transitioning between pages within the notification bar0 * [PM-10669] Incorporating a circle checkmark icon within the success message of the notification bar * [PM-10669] Fixing an issue where the notification bar can potentially load in between loading states for a tab * [PM-10669] Fixing an issue where the notification bar can potentially load in between loading states for a tab * [PM-10669] Fixing an issue where the notification bar can potentially load in between loading states for a tab * [PM-10669] Fixing an issue where the notification bar can potentially load in between loading states for a tab * [PM-10669] Fixing how we handle keyup events on the submit button * [PM-10669] Fixing how we handle keyup events on the submit button * [PM-10669] Fixing jest tests within notification bar * [PM-10669] Adding a jest tests to validate behavior within AutofillInit * [PM-10669] Adding a jest tests to validate behavior within AutofillInit * [PM-11170] Addressing test coverage within CollectAutofillContentService * [PM-11170] Addressing test coverage within CollectAutofillContentService * [PM-10669] Refactoring implementation * [PM-10669] Adding documentation to the methods incorporated within the AutofillOverlayContentService * [PM-10669] Incorporating jest tests for the AutofillOverlayContentService * [PM-10669] Migrating logic associated with the DomQuerySevice away from the CollectAutofillContentService * [PM-10669] Fixing required references to DomQueryService within the implementation * [PM-10669] Holding off on re-incorporating the userTreeWalkerStrategyFlag * [PM-10669] Incorporating jest tests for DomQueryService * [PM-10669] Adding jest test to validate changes within AutofillService * [PM-10669] Adding jest tests to validate changes within AutofillOverlayContentService * [PM-10669] Adding documentation to the OverlayNotificationsBackground class * [PM-10669] Adding documentation to the OverlayNotificationsBackground class * [PM-10669] Incorporating jest tests to validate the OverlayNotificationsBackground class * [PM-10669] Incorporating jest tests to validate the OverlayNotificationsBackground class * [PM-10669] Incorporating jest tests to validate the OverlayNotificationsBackground class * [PM-10669] Incorporating jest tests to validate the OverlayNotificationsBackground class * [PM-10669] Incorporating jest tests to validate the OverlayNotificationsBackground class * [PM-10669] Refactoring OverlayNotificationsContentService and incorporating logic that triggers a fade out of the notification bar on success of a saved password * [PM-10669] Refactoring OverlayNotificationsContentService and incorporating logic that triggers a fade out of the notification bar on success of a saved password * [PM-10669] Refactoring OverlayNotificationsContentService and incorporating logic that triggers a fade out of the notification bar on success of a saved password * [PM-10669] Finalizing jest tests for OverlayNotificationsContentService * [PM-10669] Finalizing jest tests for OverlayNotificationsContentService * [PM-10669] Adding new copy for the password saved/updated event in the notification bar * [PM-10669] Fixing visual presentation of sucesss message * [PM-10669] Fixing visual presentation of sucesss message * [PM-10418] Incorporating fallback for when we cannot capture the form button effectively * [PM-10669] Incorporating fixes for form submission button not being captured * [PM-10669] Incorporating a guard to ensure that an AJAX submission captures form data after the user has entered their credentials * [PM-10669] Incorporating a field qualification rule to ensure that we capture forms that are non-viewable on load * [PM-10669] Incorporating a document readyState listener to ensure that we populate the notification bar once the document body is loaded * [PM-10669] Incorporating a match pattern for subdomains of a main domain when filtering out web requests * [PM-10669] Incorporating a match pattern for subdomains of a main domain when filtering out web requests * [PM-10669] Incorporating a redundant methodology to capture `GET` requests that trigger after a form submisson * [PM-10669] Incorporating a redundant methodology to capture `GET` requests that trigger after a form submisson * [PM-10669] Adding jest tests to validate changes within OverlayNotificationsBackground * [PM-10669] Adjusting timeout for modified login credentials to ensure user can enter data on form * [PM-10669] Refining how we handle re-capturing user credentails on before request to better handle multi-part forms * [PM-10669] Refining how we handle re-capturing user credentails on before request to better handle multi-part forms * [PM-10669] Adjusting jest tests to ensure code coverage * [PM-10669] Fixing issues with Safari * [PM-10669] Fixing an invalid qualification rule * [PM-10669] Ensuring that we capture input changes correctly when a field is going from a hidden to non-hidden state * [PM-10669] Fixing jest tests within overlay content service * [PM-10669] Fixing jest tests within overlay content service * [PM-10669] Adding a jest test to validate changes to overlay content service --- apps/browser/src/_locales/en/messages.json | 8 + .../abstractions/notification.background.ts | 7 +- .../overlay-notifications.background.ts | 52 ++ .../notification.background.spec.ts | 74 +-- .../background/notification.background.ts | 51 +- .../overlay-notifications.background.spec.ts | 548 +++++++++++++++++ .../overlay-notifications.background.ts | 557 ++++++++++++++++++ .../content/auto-submit-login.spec.ts | 14 +- .../src/autofill/content/auto-submit-login.ts | 12 +- .../autofill/content/autofill-init.spec.ts | 23 +- .../src/autofill/content/autofill-init.ts | 22 +- .../bootstrap-autofill-overlay-menu.ts | 30 + ...ootstrap-autofill-overlay-notifications.ts | 33 ++ .../content/bootstrap-autofill-overlay.ts | 10 + .../autofill/content/bootstrap-autofill.ts | 4 +- .../src/autofill/content/notification-bar.ts | 3 +- .../content/autofill-init.deprecated.ts | 3 + ...fill-overlay-content.service.deprecated.ts | 2 +- .../autofill/enums/autofill-field.enums.ts | 1 + .../abstractions/notification-bar.ts | 2 + .../src/autofill/notification/bar.html | 27 +- .../src/autofill/notification/bar.scss | 174 +++++- apps/browser/src/autofill/notification/bar.ts | 21 +- ...tofill-inline-menu-content.service.spec.ts | 7 +- .../overlay-notifications-content.service.ts | 40 ++ ...notifications-content.service.spec.ts.snap | 14 + ...rlay-notifications-content.service.spec.ts | 264 +++++++++ .../overlay-notifications-content.service.ts | 281 +++++++++ .../autofill-overlay-content.service.ts | 10 +- .../collect-autofill-content.service.ts | 10 - .../abstractions/dom-query.service.ts | 12 + ...nline-menu-field-qualifications.service.ts | 4 + .../autofill/services/autofill-constants.ts | 18 + .../autofill-overlay-content.service.spec.ts | 401 +++++++++++-- .../autofill-overlay-content.service.ts | 282 ++++++++- .../services/autofill.service.spec.ts | 51 +- .../src/autofill/services/autofill.service.ts | 83 ++- .../collect-autofill-content.service.spec.ts | 53 +- .../collect-autofill-content.service.ts | 270 ++------- .../services/dom-query.service.spec.ts | 82 +++ .../autofill/services/dom-query.service.ts | 185 ++++++ ...e-menu-field-qualification.service.spec.ts | 15 +- ...inline-menu-field-qualification.service.ts | 77 ++- .../insert-autofill-content.service.spec.ts | 4 + .../insert-autofill-content.service.ts | 11 +- .../src/autofill/spec/testing-utils.ts | 9 + apps/browser/src/autofill/utils/index.ts | 2 +- apps/browser/src/autofill/utils/svg-icons.ts | 3 + .../browser/src/background/main.background.ts | 13 +- .../src/popup/services/services.module.ts | 1 + apps/browser/test.setup.ts | 4 + apps/browser/webpack.config.js | 4 + libs/common/src/autofill/constants/index.ts | 6 + libs/common/src/enums/feature-flag.enum.ts | 2 + 54 files changed, 3412 insertions(+), 484 deletions(-) create mode 100644 apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts create mode 100644 apps/browser/src/autofill/background/overlay-notifications.background.spec.ts create mode 100644 apps/browser/src/autofill/background/overlay-notifications.background.ts create mode 100644 apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts create mode 100644 apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts create mode 100644 apps/browser/src/autofill/overlay/notifications/abstractions/overlay-notifications-content.service.ts create mode 100644 apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap create mode 100644 apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.spec.ts create mode 100644 apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts create mode 100644 apps/browser/src/autofill/services/abstractions/dom-query.service.ts create mode 100644 apps/browser/src/autofill/services/dom-query.service.spec.ts create mode 100644 apps/browser/src/autofill/services/dom-query.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index cd2cc91293..4f1696461a 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3645,10 +3645,18 @@ "message": "Credentials saved successfully!", "description": "Notification message for when saving credentials has succeeded." }, + "passwordSaved": { + "message": "Password saved!", + "description": "Notification message for when saving credentials has succeeded." + }, "updateCipherAttemptSuccess": { "message": "Credentials updated successfully!", "description": "Notification message for when updating credentials has succeeded." }, + "passwordUpdated": { + "message": "Password updated!", + "description": "Notification message for when updating credentials has succeeded." + }, "saveCipherAttemptFailed": { "message": "Error saving credentials. Check console for details.", "description": "Notification message for when saving credentials has failed." diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index e01e2c5c02..ed9d8e6d84 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -9,6 +9,7 @@ interface NotificationQueueMessage { type: NotificationQueueMessageTypes; domain: string; tab: chrome.tabs.Tab; + launchTimestamp: number; expires: Date; wasVaultLocked: boolean; } @@ -88,10 +89,9 @@ type NotificationBackgroundExtensionMessage = { tab?: chrome.tabs.Tab; sender?: string; notificationType?: string; + fadeOutNotification?: boolean; }; -type SaveOrUpdateCipherResult = undefined | { error: string }; - type BackgroundMessageParam = { message: NotificationBackgroundExtensionMessage }; type BackgroundSenderParam = { sender: chrome.runtime.MessageSender }; type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; @@ -100,7 +100,7 @@ type NotificationBackgroundExtensionMessageHandlers = { [key: string]: CallableFunction; unlockCompleted: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgGetFolderData: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; - bgCloseNotificationBar: ({ sender }: BackgroundSenderParam) => Promise; + bgCloseNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgAdjustNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgAddLogin: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgChangedPassword: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; @@ -129,7 +129,6 @@ export { ChangePasswordMessageData, UnlockVaultMessageData, AddLoginMessageData, - SaveOrUpdateCipherResult, NotificationBackgroundExtensionMessage, NotificationBackgroundExtensionMessageHandlers, }; diff --git a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts new file mode 100644 index 0000000000..0ec6a9ae04 --- /dev/null +++ b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts @@ -0,0 +1,52 @@ +import AutofillPageDetails from "../../models/autofill-page-details"; + +export type NotificationTypeData = { + isVaultLocked?: boolean; + theme?: string; + removeIndividualVault?: boolean; + importType?: string; + launchTimestamp?: number; +}; + +export type WebsiteOriginsWithFields = Map>; + +export type ActiveFormSubmissionRequests = Set; + +export type ModifyLoginCipherFormData = { + uri: string; + username: string; + password: string; + newPassword: string; +}; + +export type ModifyLoginCipherFormDataForTab = Map< + chrome.tabs.Tab["id"], + { uri: string; username: string; password: string; newPassword: string } +>; + +export type OverlayNotificationsExtensionMessage = { + command: string; + uri?: string; + username?: string; + password?: string; + newPassword?: string; + details?: AutofillPageDetails; +}; + +type OverlayNotificationsMessageParams = { message: OverlayNotificationsExtensionMessage }; +type OverlayNotificationSenderParams = { sender: chrome.runtime.MessageSender }; +type OverlayNotificationsMessageHandlersParams = OverlayNotificationsMessageParams & + OverlayNotificationSenderParams; + +export type OverlayNotificationsExtensionMessageHandlers = { + [key: string]: ({ message, sender }: OverlayNotificationsMessageHandlersParams) => any; + formFieldSubmitted: ({ message, sender }: OverlayNotificationsMessageHandlersParams) => void; + collectPageDetailsResponse: ({ + message, + sender, + }: OverlayNotificationsMessageHandlersParams) => Promise; +}; + +export interface OverlayNotificationsBackground { + init(): void; +} diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index f3ebe5b1cc..0ede9b9609 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -1,4 +1,4 @@ -import { mock } from "jest-mock-extended"; +import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; @@ -48,7 +48,8 @@ describe("NotificationBackground", () => { let notificationBackground: NotificationBackground; const autofillService = mock(); const cipherService = mock(); - const authService = mock(); + let activeAccountStatusMock$: BehaviorSubject; + let authService: MockProxy; const policyService = mock(); const folderService = mock(); const userNotificationSettingsService = mock(); @@ -60,6 +61,9 @@ describe("NotificationBackground", () => { const accountService = mock(); beforeEach(() => { + activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Locked); + authService = mock(); + authService.activeAccountStatus$ = activeAccountStatusMock$; notificationBackground = new NotificationBackground( autofillService, cipherService, @@ -91,6 +95,7 @@ describe("NotificationBackground", () => { tab: createChromeTabMock(), expires: new Date(), wasVaultLocked: false, + launchTimestamp: 0, }; const cipherView = notificationBackground["convertAddLoginQueueMessageToCipherView"](message); @@ -126,6 +131,7 @@ describe("NotificationBackground", () => { tab: createChromeTabMock(), expires: new Date(), wasVaultLocked: false, + launchTimestamp: 0, }; const cipherView = notificationBackground["convertAddLoginQueueMessageToCipherView"]( message, @@ -222,6 +228,7 @@ describe("NotificationBackground", () => { expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( sender.tab, "closeNotificationBar", + { fadeOutNotification: false }, ); }); }); @@ -249,7 +256,6 @@ describe("NotificationBackground", () => { describe("bgAddLogin message handler", () => { let tab: chrome.tabs.Tab; let sender: chrome.runtime.MessageSender; - let getAuthStatusSpy: jest.SpyInstance; let getEnableAddedLoginPromptSpy: jest.SpyInstance; let getEnableChangedPasswordPromptSpy: jest.SpyInstance; let pushAddLoginToQueueSpy: jest.SpyInstance; @@ -259,7 +265,6 @@ describe("NotificationBackground", () => { beforeEach(() => { tab = createChromeTabMock(); sender = mock({ tab }); - getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus"); getEnableAddedLoginPromptSpy = jest.spyOn( notificationBackground as any, "getEnableAddedLoginPrompt", @@ -281,12 +286,11 @@ describe("NotificationBackground", () => { command: "bgAddLogin", login: { username: "test", password: "password", url: "https://example.com" }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.LoggedOut); + activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); }); @@ -296,12 +300,11 @@ describe("NotificationBackground", () => { command: "bgAddLogin", login: { username: "test", password: "password", url: "" }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Locked); sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); }); @@ -311,13 +314,12 @@ describe("NotificationBackground", () => { command: "bgAddLogin", login: { username: "test", password: "password", url: "https://example.com" }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Locked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); @@ -329,14 +331,13 @@ describe("NotificationBackground", () => { command: "bgAddLogin", login: { username: "test", password: "password", url: "https://example.com" }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); getAllDecryptedForUrlSpy.mockResolvedValueOnce([]); sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); @@ -348,7 +349,7 @@ describe("NotificationBackground", () => { command: "bgAddLogin", login: { username: "test", password: "password", url: "https://example.com" }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -358,7 +359,6 @@ describe("NotificationBackground", () => { sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(getEnableChangedPasswordPromptSpy).toHaveBeenCalled(); @@ -371,7 +371,7 @@ describe("NotificationBackground", () => { command: "bgAddLogin", login: { username: "test", password: "password", url: "https://example.com" }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ mock({ login: { username: "test", password: "password" } }), @@ -380,7 +380,6 @@ describe("NotificationBackground", () => { sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); @@ -390,13 +389,12 @@ describe("NotificationBackground", () => { it("adds the login to the queue if the user has a locked account", async () => { const login = { username: "test", password: "password", url: "https://example.com" }; const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Locked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith("example.com", login, sender.tab, true); }); @@ -407,7 +405,7 @@ describe("NotificationBackground", () => { url: "https://example.com", } as any; const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ mock({ login: { username: "anotherTestUsername", password: "password" } }), @@ -416,14 +414,13 @@ describe("NotificationBackground", () => { sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith("example.com", login, sender.tab); }); it("adds a change password message to the queue if the user has changed an existing cipher's password", async () => { const login = { username: "tEsT", password: "password", url: "https://example.com" }; const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockResolvedValueOnce(true); getEnableChangedPasswordPromptSpy.mockResolvedValueOnce(true); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -448,14 +445,12 @@ describe("NotificationBackground", () => { describe("bgChangedPassword message handler", () => { let tab: chrome.tabs.Tab; let sender: chrome.runtime.MessageSender; - let getAuthStatusSpy: jest.SpyInstance; let pushChangePasswordToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; beforeEach(() => { tab = createChromeTabMock(); sender = mock({ tab }); - getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus"); pushChangePasswordToQueueSpy = jest.spyOn( notificationBackground as any, "pushChangePasswordToQueue", @@ -484,12 +479,11 @@ describe("NotificationBackground", () => { url: "https://example.com", }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Locked); sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( null, "example.com", @@ -508,7 +502,7 @@ describe("NotificationBackground", () => { url: "https://example.com", }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ mock({ login: { username: "test", password: "password" } }), ]); @@ -516,7 +510,6 @@ describe("NotificationBackground", () => { sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); @@ -530,7 +523,7 @@ describe("NotificationBackground", () => { url: "https://example.com", }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ mock({ login: { username: "test", password: "password" } }), mock({ login: { username: "test2", password: "password" } }), @@ -539,7 +532,6 @@ describe("NotificationBackground", () => { sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); @@ -553,7 +545,7 @@ describe("NotificationBackground", () => { url: "https://example.com", }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ mock({ id: "cipher-id", @@ -580,7 +572,7 @@ describe("NotificationBackground", () => { url: "https://example.com", }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ mock({ login: { username: "test", password: "password" } }), mock({ login: { username: "test2", password: "password" } }), @@ -589,7 +581,6 @@ describe("NotificationBackground", () => { sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); @@ -602,7 +593,7 @@ describe("NotificationBackground", () => { url: "https://example.com", }, }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ mock({ id: "cipher-id", @@ -658,12 +649,10 @@ describe("NotificationBackground", () => { }); describe("bgSaveCipher message handler", () => { - let getAuthStatusSpy: jest.SpyInstance; let tabSendMessageDataSpy: jest.SpyInstance; let openUnlockPopoutSpy: jest.SpyInstance; beforeEach(() => { - getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus"); tabSendMessageDataSpy = jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); openUnlockPopoutSpy = jest .spyOn(notificationBackground as any, "openUnlockPopout") @@ -677,12 +666,11 @@ describe("NotificationBackground", () => { edit: false, folder: "folder-id", }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Locked); sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(tabSendMessageDataSpy).toHaveBeenCalledWith( sender.tab, "addToLockedVaultPendingNotifications", @@ -716,7 +704,7 @@ describe("NotificationBackground", () => { let cipherEncryptSpy: jest.SpyInstance; beforeEach(() => { - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getDecryptedCipherByIdSpy = jest.spyOn( notificationBackground as any, "getDecryptedCipherById", @@ -1214,11 +1202,9 @@ describe("NotificationBackground", () => { }); describe("bgUnlockPopoutOpened message handler", () => { - let getAuthStatusSpy: jest.SpyInstance; let pushUnlockVaultToQueueSpy: jest.SpyInstance; beforeEach(() => { - getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus"); pushUnlockVaultToQueueSpy = jest.spyOn( notificationBackground as any, "pushUnlockVaultToQueue", @@ -1236,7 +1222,6 @@ describe("NotificationBackground", () => { sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).not.toHaveBeenCalled(); expect(pushUnlockVaultToQueueSpy).not.toHaveBeenCalled(); }); @@ -1246,12 +1231,11 @@ describe("NotificationBackground", () => { const message: NotificationBackgroundExtensionMessage = { command: "bgUnlockPopoutOpened", }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.LoggedOut); + activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); sendMockExtensionMessage(message, sender); await flushPromises(); - expect(getAuthStatusSpy).toHaveBeenCalled(); expect(pushUnlockVaultToQueueSpy).not.toHaveBeenCalled(); }); @@ -1261,7 +1245,7 @@ describe("NotificationBackground", () => { const message: NotificationBackgroundExtensionMessage = { command: "bgUnlockPopoutOpened", }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Locked); notificationBackground["notificationQueue"] = [mock()]; sendMockExtensionMessage(message, sender); @@ -1276,7 +1260,7 @@ describe("NotificationBackground", () => { const message: NotificationBackgroundExtensionMessage = { command: "bgUnlockPopoutOpened", }; - getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Locked); sendMockExtensionMessage(message, sender); await flushPromises(); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 9aac9b099a..683e3d8f58 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -44,6 +44,7 @@ import { NotificationBackgroundExtensionMessage, NotificationBackgroundExtensionMessageHandlers, } from "./abstractions/notification.background"; +import { NotificationTypeData } from "./abstractions/overlay-notifications.background"; import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.background"; export default class NotificationBackground { @@ -58,7 +59,8 @@ export default class NotificationBackground { private readonly extensionMessageHandlers: NotificationBackgroundExtensionMessageHandlers = { unlockCompleted: ({ message, sender }) => this.handleUnlockCompleted(message, sender), bgGetFolderData: () => this.getFolderData(), - bgCloseNotificationBar: ({ sender }) => this.handleCloseNotificationBarMessage(sender), + bgCloseNotificationBar: ({ message, sender }) => + this.handleCloseNotificationBarMessage(message, sender), bgAdjustNotificationBar: ({ message, sender }) => this.handleAdjustNotificationBarMessage(message, sender), bgAddLogin: ({ message, sender }) => this.addLogin(message, sender), @@ -132,6 +134,10 @@ export default class NotificationBackground { return await firstValueFrom(this.configService.serverConfig$); } + private async getAuthStatus() { + return await firstValueFrom(this.authService.activeAccountStatus$); + } + /** * Checks the notification queue for any messages that need to be sent to the * specified tab. If no tab is specified, the current tab will be used. @@ -186,9 +192,10 @@ export default class NotificationBackground { ) { const notificationType = notificationQueueMessage.type; - const typeData: Record = { + const typeData: NotificationTypeData = { isVaultLocked: notificationQueueMessage.wasVaultLocked, theme: await firstValueFrom(this.themeStateService.selectedTheme$), + launchTimestamp: notificationQueueMessage.launchTimestamp, }; switch (notificationType) { @@ -230,11 +237,11 @@ export default class NotificationBackground { * @param message - The message to add to the queue * @param sender - The contextual sender of the message */ - private async addLogin( + async addLogin( message: NotificationBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { - const authStatus = await this.authService.getAuthStatus(); + const authStatus = await this.getAuthStatus(); if (authStatus === AuthenticationStatus.LoggedOut) { return; } @@ -289,6 +296,7 @@ export default class NotificationBackground { ) { // remove any old messages for this tab this.removeTabFromNotificationQueue(tab); + const launchTimestamp = new Date().getTime(); const message: AddLoginQueueMessage = { type: NotificationQueueMessageType.AddLogin, username: loginInfo.username, @@ -296,7 +304,8 @@ export default class NotificationBackground { domain: loginDomain, uri: loginInfo.url, tab: tab, - expires: new Date(new Date().getTime() + NOTIFICATION_BAR_LIFESPAN_MS), + launchTimestamp, + expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS), wasVaultLocked: isVaultLocked, }; this.notificationQueue.push(message); @@ -310,7 +319,7 @@ export default class NotificationBackground { * @param message - The message to add to the queue * @param sender - The contextual sender of the message */ - private async changedPassword( + async changedPassword( message: NotificationBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { @@ -320,7 +329,7 @@ export default class NotificationBackground { return; } - if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) { + if ((await this.getAuthStatus()) < AuthenticationStatus.Unlocked) { await this.pushChangePasswordToQueue( null, loginDomain, @@ -380,7 +389,7 @@ export default class NotificationBackground { return; } - const currentAuthStatus = await this.authService.getAuthStatus(); + const currentAuthStatus = await this.getAuthStatus(); if (currentAuthStatus !== AuthenticationStatus.Locked || this.notificationQueue.length) { return; } @@ -399,7 +408,7 @@ export default class NotificationBackground { * @param importType - The type of import that is being requested */ async requestFilelessImport(tab: chrome.tabs.Tab, importType: string) { - const currentAuthStatus = await this.authService.getAuthStatus(); + const currentAuthStatus = await this.getAuthStatus(); if (currentAuthStatus !== AuthenticationStatus.Unlocked || this.notificationQueue.length) { return; } @@ -419,13 +428,15 @@ export default class NotificationBackground { ) { // remove any old messages for this tab this.removeTabFromNotificationQueue(tab); + const launchTimestamp = new Date().getTime(); const message: AddChangePasswordQueueMessage = { type: NotificationQueueMessageType.ChangePassword, cipherId: cipherId, newPassword: newPassword, domain: loginDomain, tab: tab, - expires: new Date(new Date().getTime() + NOTIFICATION_BAR_LIFESPAN_MS), + launchTimestamp, + expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS), wasVaultLocked: isVaultLocked, }; this.notificationQueue.push(message); @@ -434,11 +445,13 @@ export default class NotificationBackground { private async pushUnlockVaultToQueue(loginDomain: string, tab: chrome.tabs.Tab) { this.removeTabFromNotificationQueue(tab); + const launchTimestamp = new Date().getTime(); const message: AddUnlockVaultQueueMessage = { type: NotificationQueueMessageType.UnlockVault, domain: loginDomain, tab: tab, - expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds + launchTimestamp, + expires: new Date(launchTimestamp + 0.5 * 60000), // 30 seconds wasVaultLocked: true, }; await this.sendNotificationQueueMessage(tab, message); @@ -459,11 +472,13 @@ export default class NotificationBackground { importType?: string, ) { this.removeTabFromNotificationQueue(tab); + const launchTimestamp = new Date().getTime(); const message: AddRequestFilelessImportQueueMessage = { type: NotificationQueueMessageType.RequestFilelessImport, domain: loginDomain, tab, - expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds + launchTimestamp, + expires: new Date(launchTimestamp + 0.5 * 60000), // 30 seconds wasVaultLocked: false, importType, }; @@ -484,7 +499,7 @@ export default class NotificationBackground { message: NotificationBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { - if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) { + if ((await this.getAuthStatus()) < AuthenticationStatus.Unlocked) { await BrowserApi.tabSendMessageData(sender.tab, "addToLockedVaultPendingNotifications", { commandToRetry: { message: { @@ -736,10 +751,16 @@ export default class NotificationBackground { * Sends a message back to the sender tab which * triggers closure of the notification bar. * + * @param message - The extension message * @param sender - The contextual sender of the message */ - private async handleCloseNotificationBarMessage(sender: chrome.runtime.MessageSender) { - await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar"); + private async handleCloseNotificationBarMessage( + message: NotificationBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar", { + fadeOutNotification: !!message.fadeOutNotification, + }); } /** diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts new file mode 100644 index 0000000000..d694438c00 --- /dev/null +++ b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts @@ -0,0 +1,548 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { EnvironmentServerConfigData } from "@bitwarden/common/platform/models/data/server-config.data"; + +import { BrowserApi } from "../../platform/browser/browser-api"; +import AutofillField from "../models/autofill-field"; +import AutofillPageDetails from "../models/autofill-page-details"; +import { + flushPromises, + sendMockExtensionMessage, + triggerTabOnRemovedEvent, + triggerTabOnUpdatedEvent, + triggerWebNavigationOnCompletedEvent, + triggerWebRequestOnBeforeRequestEvent, + triggerWebRequestOnCompletedEvent, +} from "../spec/testing-utils"; + +import NotificationBackground from "./notification.background"; +import { OverlayNotificationsBackground } from "./overlay-notifications.background"; + +describe("OverlayNotificationsBackground", () => { + let logService: MockProxy; + let configService: MockProxy; + let notificationBackground: NotificationBackground; + let getEnableChangedPasswordPromptSpy: jest.SpyInstance; + let getEnableAddedLoginPromptSpy: jest.SpyInstance; + let overlayNotificationsBackground: OverlayNotificationsBackground; + + beforeEach(async () => { + jest.useFakeTimers(); + logService = mock(); + configService = mock(); + notificationBackground = mock(); + getEnableChangedPasswordPromptSpy = jest + .spyOn(notificationBackground, "getEnableChangedPasswordPrompt") + .mockResolvedValue(true); + getEnableAddedLoginPromptSpy = jest + .spyOn(notificationBackground, "getEnableAddedLoginPrompt") + .mockResolvedValue(true); + overlayNotificationsBackground = new OverlayNotificationsBackground( + logService, + configService, + notificationBackground, + ); + configService.getFeatureFlag.mockResolvedValue(true); + await overlayNotificationsBackground.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + describe("setting up the form submission listeners", () => { + let fields: MockProxy[]; + let details: MockProxy; + + beforeEach(() => { + fields = [mock(), mock(), mock()]; + details = mock({ fields }); + }); + + describe("skipping setting up the web request listeners", () => { + it("skips setting up listeners when the notification bar is disabled", async () => { + getEnableChangedPasswordPromptSpy.mockResolvedValue(false); + getEnableAddedLoginPromptSpy.mockResolvedValue(false); + + sendMockExtensionMessage({ + command: "collectPageDetailsResponse", + details, + }); + await flushPromises(); + + expect(chrome.webRequest.onCompleted.addListener).not.toHaveBeenCalled(); + }); + + describe("when the sender is from an excluded domain", () => { + const senderHost = "example.com"; + const senderUrl = `https://${senderHost}`; + + beforeEach(() => { + jest.spyOn(notificationBackground, "getExcludedDomains").mockResolvedValue({ + [senderHost]: null, + }); + }); + + it("skips setting up listeners when the sender is the user's vault", async () => { + const vault = "https://vault.bitwarden.com"; + const sender = mock({ origin: vault }); + jest + .spyOn(notificationBackground, "getActiveUserServerConfig") + .mockResolvedValue( + mock({ environment: mock({ vault }) }), + ); + + sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender); + await flushPromises(); + + expect(chrome.webRequest.onCompleted.addListener).not.toHaveBeenCalled(); + }); + + it("skips setting up listeners when the sender is an excluded domain", async () => { + const sender = mock({ origin: senderUrl }); + + sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender); + await flushPromises(); + + expect(chrome.webRequest.onCompleted.addListener).not.toHaveBeenCalled(); + }); + + it("skips setting up listeners when the sender contains a malformed origin", async () => { + const senderOrigin = "-_-!..exampwle.com"; + const sender = mock({ origin: senderOrigin }); + + sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender); + await flushPromises(); + + expect(chrome.webRequest.onCompleted.addListener).not.toHaveBeenCalled(); + }); + }); + + it("skips setting up listeners when the sender tab does not contain page details fields", async () => { + const sender = mock({ tab: { id: 1 } }); + details.fields = []; + + sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender); + await flushPromises(); + + expect(chrome.webRequest.onCompleted.addListener).not.toHaveBeenCalled(); + }); + }); + + it("sets up the web request listeners", async () => { + const sender = mock({ + tab: { id: 1 }, + url: "example.com", + }); + + sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender); + await flushPromises(); + + expect(chrome.webRequest.onCompleted.addListener).toHaveBeenCalled(); + }); + + it("skips setting up duplicate listeners when the website origin has been previously encountered with fields", async () => { + const sender = mock({ + tab: { id: 1 }, + url: "example.com", + }); + + sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender); + await flushPromises(); + sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender); + await flushPromises(); + sendMockExtensionMessage({ command: "collectPageDetailsResponse", details }, sender); + await flushPromises(); + + expect(chrome.webRequest.onCompleted.addListener).toHaveBeenCalledTimes(1); + }); + }); + + describe("storing the modified login form data", () => { + const sender = mock({ tab: { id: 1 } }); + + it("stores the modified login cipher form data", async () => { + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + await flushPromises(); + + expect( + overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id), + ).toEqual({ + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }); + }); + + it("clears the modified login cipher form data after 5 seconds", () => { + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + + jest.advanceTimersByTime(CLEAR_NOTIFICATION_LOGIN_DATA_DURATION); + + expect(overlayNotificationsBackground["modifyLoginCipherFormData"].size).toBe(0); + }); + + it("attempts to store the modified login cipher form data within the onBeforeRequest listener when the data is not captured through a submit button click event", async () => { + const pageDetails = mock({ fields: [mock()] }); + const tab = mock({ id: sender.tab.id }); + jest.spyOn(BrowserApi, "getTab").mockResolvedValueOnce(tab); + const response = { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }; + jest.spyOn(BrowserApi, "tabSendMessage").mockResolvedValueOnce(response); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails }, + sender, + ); + await flushPromises(); + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: "https://example.com", + tabId: sender.tab.id, + method: "POST", + requestId: "123345", + }), + ); + await flushPromises(); + + expect( + overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id), + ).toEqual({ + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }); + }); + }); + + describe("web request listeners", () => { + let sender: MockProxy; + const pageDetails = mock({ fields: [mock()] }); + let notificationChangedPasswordSpy: jest.SpyInstance; + let notificationAddLoginSpy: jest.SpyInstance; + + beforeEach(async () => { + sender = mock({ + tab: { id: 1 }, + url: "https://example.com", + }); + notificationChangedPasswordSpy = jest.spyOn(notificationBackground, "changedPassword"); + notificationAddLoginSpy = jest.spyOn(notificationBackground, "addLogin"); + + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails }, + sender, + ); + await flushPromises(); + }); + + describe("ignored web requests", () => { + it("ignores requests from urls that do not start with a valid protocol", async () => { + sender.url = "chrome-extension://extension-id"; + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + }), + ); + + expect(overlayNotificationsBackground["activeFormSubmissionRequests"].size).toBe(0); + }); + + it("ignores requests from urls that do not have a valid tabId", async () => { + sender.tab = mock({ id: -1 }); + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + }), + ); + + expect(overlayNotificationsBackground["activeFormSubmissionRequests"].size).toBe(0); + }); + + it("ignores requests from urls that do not have a valid request method", async () => { + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "GET", + }), + ); + + expect(overlayNotificationsBackground["activeFormSubmissionRequests"].size).toBe(0); + }); + + it("ignores requests that are not part of an active form submission", async () => { + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId: "123345", + }), + ); + + expect(notificationChangedPasswordSpy).not.toHaveBeenCalled(); + expect(notificationAddLoginSpy).not.toHaveBeenCalled(); + }); + + it("ignores requests for tabs that do not contain stored login data", async () => { + const requestId = "123345"; + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId, + }), + ); + await flushPromises(); + + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId, + }), + ); + + expect(notificationChangedPasswordSpy).not.toHaveBeenCalled(); + expect(notificationAddLoginSpy).not.toHaveBeenCalled(); + }); + }); + + describe("web requests that trigger notifications", () => { + const requestId = "123345"; + + beforeEach(async () => { + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId, + }), + ); + await flushPromises(); + }); + + it("waits for the tab's navigation to complete using the web navigation API before initializing the notification", async () => { + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "loading", + url: sender.url, + }), + ); + }); + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId, + }), + ); + await flushPromises(); + + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + triggerWebNavigationOnCompletedEvent( + mock({ + tabId: sender.tab.id, + url: sender.url, + }), + ); + await flushPromises(); + + expect(notificationAddLoginSpy).toHaveBeenCalled(); + }); + + it("initializes the notification immediately when the tab's navigation is complete", async () => { + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + await flushPromises(); + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId, + }), + ); + await flushPromises(); + + expect(notificationAddLoginSpy).toHaveBeenCalled(); + }); + + it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => { + sender.tab = mock({ id: 4 }); + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + await flushPromises(); + chrome.tabs.get = jest.fn().mockImplementation((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId, + }), + ); + await flushPromises(); + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: "https://example.com/redirect", + tabId: sender.tab.id, + method: "GET", + requestId, + }), + ); + await flushPromises(); + + expect(notificationChangedPasswordSpy).toHaveBeenCalled(); + }); + }); + }); + + describe("tab listeners", () => { + let sender: MockProxy; + const pageDetails = mock({ fields: [mock()] }); + const requestId = "123345"; + + beforeEach(async () => { + sender = mock({ + tab: { id: 1 }, + url: "https://example.com", + }); + + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails }, + sender, + ); + await flushPromises(); + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId, + }), + ); + await flushPromises(); + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + await flushPromises(); + }); + + it("clears all associated data with a removed tab", () => { + triggerTabOnRemovedEvent(sender.tab.id, mock()); + + expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0); + }); + + it("clears all associated data with a tab that is entering a `loading` state", () => { + triggerTabOnUpdatedEvent( + sender.tab.id, + mock({ status: "loading" }), + mock({ status: "loading" }), + ); + + expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0); + }); + }); +}); diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts new file mode 100644 index 0000000000..e252bdcc4a --- /dev/null +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -0,0 +1,557 @@ +import { Subject, switchMap, timer } from "rxjs"; + +import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { BrowserApi } from "../../platform/browser/browser-api"; + +import { + ActiveFormSubmissionRequests, + ModifyLoginCipherFormData, + ModifyLoginCipherFormDataForTab, + OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface, + OverlayNotificationsExtensionMessage, + OverlayNotificationsExtensionMessageHandlers, + WebsiteOriginsWithFields, +} from "./abstractions/overlay-notifications.background"; +import NotificationBackground from "./notification.background"; + +export class OverlayNotificationsBackground implements OverlayNotificationsBackgroundInterface { + private websiteOriginsWithFields: WebsiteOriginsWithFields = new Map(); + private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set(); + private modifyLoginCipherFormData: ModifyLoginCipherFormDataForTab = new Map(); + private clearLoginCipherFormDataSubject: Subject = new Subject(); + private readonly formSubmissionRequestMethods: Set = new Set(["POST", "PUT", "PATCH"]); + private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = { + formFieldSubmitted: ({ message, sender }) => this.storeModifiedLoginFormData(message, sender), + collectPageDetailsResponse: ({ message, sender }) => + this.handleCollectPageDetailsResponse(message, sender), + }; + + constructor( + private logService: LogService, + private configService: ConfigService, + private notificationBackground: NotificationBackground, + ) {} + + /** + * Initialize the overlay notifications background service. + */ + async init() { + const featureFlagActive = await this.configService.getFeatureFlag( + FeatureFlag.NotificationBarAddLoginImprovements, + ); + if (!featureFlagActive) { + return; + } + + this.setupExtensionListeners(); + this.clearLoginCipherFormDataSubject + .pipe(switchMap(() => timer(CLEAR_NOTIFICATION_LOGIN_DATA_DURATION))) + .subscribe(() => this.modifyLoginCipherFormData.clear()); + } + + /** + * Handles the response from the content script with the page details. Triggers an initialization + * of the add login or change password notification if the conditions are met. + * + * @param message - The message from the content script + * @param sender - The sender of the message + */ + private async handleCollectPageDetailsResponse( + message: OverlayNotificationsExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + if (await this.shouldInitAddLoginOrChangePasswordNotification(message, sender)) { + this.websiteOriginsWithFields.set(sender.tab.id, this.getSenderUrlMatchPatterns(sender)); + this.setupWebRequestsListeners(); + } + } + + /** + * Determines if the add login or change password notification should be initialized. This depends + * on whether the user has enabled the notification, the sender is not from an excluded domain, the + * tab's page details contains fillable fields, and the website origin has not been previously stored. + * + * @param message - The message from the content script + * @param sender - The sender of the message + */ + private async shouldInitAddLoginOrChangePasswordNotification( + message: OverlayNotificationsExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + return ( + (await this.isAddLoginOrChangePasswordNotificationEnabled()) && + !(await this.isSenderFromExcludedDomain(sender)) && + message.details?.fields?.length > 0 && + !this.websiteOriginsWithFields.has(sender.tab.id) + ); + } + + /** + * Determines if the add login or change password notification is enabled. + * This is based on the user's settings for the notification. + */ + private async isAddLoginOrChangePasswordNotificationEnabled() { + return ( + (await this.notificationBackground.getEnableChangedPasswordPrompt()) || + (await this.notificationBackground.getEnableAddedLoginPrompt()) + ); + } + + /** + * Returns the match patterns for the sender's URL. This is used to filter out + * the web requests that are not from the sender's tab. + * + * @param sender - The sender of the message + */ + private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) { + return new Set([ + ...this.generateMatchPatterns(sender.url), + ...this.generateMatchPatterns(sender.tab.url), + ]); + } + + /** + * Generates the origin and subdomain match patterns for the URL. + * + * @param url - The URL of the tab + */ + private generateMatchPatterns(url: string): string[] { + try { + if (!url.startsWith("http")) { + url = `https://${url}`; + } + + const originMatchPattern = `${new URL(url).origin}/*`; + + const parsedUrl = new URL(url); + const splitHost = parsedUrl.hostname.split("."); + const domain = splitHost.slice(-2).join("."); + const subDomainMatchPattern = `${parsedUrl.protocol}//*.${domain}/*`; + + return [originMatchPattern, subDomainMatchPattern]; + } catch { + return []; + } + } + + /** + * Stores the login form data that was modified by the user in the content script. This data is + * used to trigger the add login or change password notification when the form is submitted. + * + * @param message - The message from the content script + * @param sender - The sender of the message + */ + private storeModifiedLoginFormData = ( + message: OverlayNotificationsExtensionMessage, + sender: chrome.runtime.MessageSender, + ) => { + const { uri, username, password, newPassword } = message; + if (!username && !password && !newPassword) { + return; + } + + this.clearLoginCipherFormDataSubject.next(); + const formData = { uri, username, password, newPassword }; + + const existingModifyLoginData = this.modifyLoginCipherFormData.get(sender.tab.id); + if (existingModifyLoginData) { + formData.username = formData.username || existingModifyLoginData.username; + formData.password = formData.password || existingModifyLoginData.password; + formData.newPassword = formData.newPassword || existingModifyLoginData.newPassword; + } + + this.modifyLoginCipherFormData.set(sender.tab.id, formData); + }; + + /** + * Determines if the sender of the message is from an excluded domain. This is used to prevent the + * add login or change password notification from being triggered on the user's vault domain or + * other excluded domains. + * + * @param sender - The sender of the message + */ + private async isSenderFromExcludedDomain(sender: chrome.runtime.MessageSender): Promise { + try { + const senderOrigin = sender.origin; + const serverConfig = await this.notificationBackground.getActiveUserServerConfig(); + const activeUserVault = serverConfig?.environment?.vault; + if (activeUserVault === senderOrigin) { + return true; + } + + const excludedDomains = await this.notificationBackground.getExcludedDomains(); + if (!excludedDomains) { + return false; + } + + const senderDomain = new URL(senderOrigin).hostname; + return excludedDomains[senderDomain] !== undefined; + } catch { + return true; + } + } + + /** + * Removes and resets the onBeforeRequest and onCompleted listeners for web requests. This ensures + * that we are only listening for form submission requests on the tabs that have fillable form fields. + */ + private setupWebRequestsListeners() { + chrome.webRequest.onBeforeRequest.removeListener(this.handleOnBeforeRequestEvent); + chrome.webRequest.onCompleted.removeListener(this.handleOnCompletedRequestEvent); + if (this.websiteOriginsWithFields.size) { + const requestFilter: chrome.webRequest.RequestFilter = this.generateRequestFilter(); + chrome.webRequest.onBeforeRequest.addListener(this.handleOnBeforeRequestEvent, requestFilter); + chrome.webRequest.onCompleted.addListener(this.handleOnCompletedRequestEvent, requestFilter); + } + } + + /** + * Generates the request filter for the web requests. This is used to filter out the web requests + * that are not from the tabs that have fillable form fields. + */ + private generateRequestFilter(): chrome.webRequest.RequestFilter { + const websiteOrigins = Array.from(this.websiteOriginsWithFields.values()); + const urls: string[] = []; + websiteOrigins.forEach((origins) => urls.push(...origins)); + return { + urls, + types: ["main_frame", "sub_frame", "xmlhttprequest"], + }; + } + + /** + * Handles the onBeforeRequest event for web requests. This is used to ensures that the following + * onCompleted event is only triggered for form submission requests. + * + * @param details - The details of the web request + */ + private handleOnBeforeRequestEvent = (details: chrome.webRequest.WebRequestDetails) => { + if (this.isPostSubmissionFormRedirection(details)) { + this.setupNotificationInitTrigger( + details.tabId, + details.requestId, + this.modifyLoginCipherFormData.get(details.tabId), + ).catch((error) => this.logService.error(error)); + + return; + } + + if (!this.isValidFormSubmissionRequest(details)) { + return; + } + + const { requestId, tabId, frameId } = details; + this.activeFormSubmissionRequests.add(requestId); + + if (this.notificationDataIncompleteOnBeforeRequest(tabId)) { + this.getFormFieldDataFromTab(tabId, frameId).catch((error) => this.logService.error(error)); + } + }; + + /** + * Captures the modified login form data if the tab contains incomplete data. This is used as + * a redundancy to ensure that the modified login form data is captured in cases where the form + * is split into multiple parts. + * + * @param tabId - The id of the tab + */ + private notificationDataIncompleteOnBeforeRequest = (tabId: number) => { + const modifyLoginData = this.modifyLoginCipherFormData.get(tabId); + return ( + !modifyLoginData || + !this.shouldTriggerAddLoginNotification(modifyLoginData) || + !this.shouldTriggerChangePasswordNotification(modifyLoginData) + ); + }; + + /** + * Determines whether the request is happening after a form submission. This is identified by a GET + * request that is triggered after a form submission POST request from the same request id. If + * this is the case, and the modified login form data is available, the add login or change password + * notification is triggered. + * + * @param details - The details of the web request + */ + private isPostSubmissionFormRedirection = (details: chrome.webRequest.WebRequestDetails) => { + return ( + details.method?.toUpperCase() === "GET" && + this.activeFormSubmissionRequests.has(details.requestId) && + this.modifyLoginCipherFormData.has(details.tabId) + ); + }; + + /** + * Determines if the web request is a valid form submission request. A valid web request + * is a POST, PUT, or PATCH request that is not from an invalid host. + * + * @param details - The details of the web request + */ + private isValidFormSubmissionRequest = (details: chrome.webRequest.WebRequestDetails) => { + return ( + !this.requestHostIsInvalid(details) && + this.formSubmissionRequestMethods.has(details.method?.toUpperCase()) + ); + }; + + /** + * Retrieves the form field data from the tab. This is used to get the modified login form data + * in cases where the submit button is not clicked, but the form is submitted through other means. + * + * @param tabId - The senders tab id + * @param frameId - The frame where the form is located + */ + private getFormFieldDataFromTab = async (tabId: number, frameId: number) => { + const tab = await BrowserApi.getTab(tabId); + if (!tab) { + return; + } + + const response = (await BrowserApi.tabSendMessage( + tab, + { command: "getFormFieldDataForNotification" }, + { frameId }, + )) as OverlayNotificationsExtensionMessage; + if (response) { + this.storeModifiedLoginFormData(response, { tab }); + } + }; + + /** + * Handles the onCompleted event for web requests. This is used to trigger the add login or change + * password notification when a form submission request is completed. + * + * @param details - The details of the web response + */ + private handleOnCompletedRequestEvent = async (details: chrome.webRequest.WebResponseDetails) => { + if ( + this.requestHostIsInvalid(details) || + this.isInvalidStatusCode(details.statusCode) || + !this.activeFormSubmissionRequests.has(details.requestId) + ) { + return; + } + + const modifyLoginData = this.modifyLoginCipherFormData.get(details.tabId); + if (!modifyLoginData) { + return; + } + + this.setupNotificationInitTrigger(details.tabId, details.requestId, modifyLoginData).catch( + (error) => this.logService.error(error), + ); + }; + + /** + * Sets up the initialization trigger for the add login or change password notification. This is used + * to ensure that the notification is triggered after the tab has finished loading. + * + * @param tabId - The id of the tab + * @param requestId - The request id of the web request + * @param modifyLoginData - The modified login form data + */ + private setupNotificationInitTrigger = async ( + tabId: number, + requestId: string, + modifyLoginData: ModifyLoginCipherFormData, + ) => { + const tab = await BrowserApi.getTab(tabId); + if (tab.status !== "complete") { + await this.delayNotificationInitUntilTabIsComplete(tabId, requestId, modifyLoginData); + return; + } + + await this.triggerNotificationInit(requestId, modifyLoginData, tab); + }; + + /** + * Delays the initialization of the add login or change password notification + * until the tab is complete. This is used to ensure that the notification is + * triggered after the tab has finished loading. + * + * @param tabId - The id of the tab + * @param requestId - The request id of the web request + * @param modifyLoginData - The modified login form data + */ + private delayNotificationInitUntilTabIsComplete = async ( + tabId: chrome.webRequest.ResourceRequest["tabId"], + requestId: chrome.webRequest.ResourceRequest["requestId"], + modifyLoginData: ModifyLoginCipherFormData, + ) => { + const handleWebNavigationOnCompleted = async () => { + chrome.webNavigation.onCompleted.removeListener(handleWebNavigationOnCompleted); + const tab = await BrowserApi.getTab(tabId); + await this.triggerNotificationInit(requestId, modifyLoginData, tab); + }; + chrome.webNavigation.onCompleted.addListener(handleWebNavigationOnCompleted); + }; + + /** + * Initializes the add login or change password notification based on the modified login form data + * and the tab details. This will trigger the notification to be displayed to the user. + * + * @param requestId - The details of the web response + * @param modifyLoginData - The modified login form data + * @param tab - The tab details + */ + private triggerNotificationInit = async ( + requestId: chrome.webRequest.ResourceRequest["requestId"], + modifyLoginData: ModifyLoginCipherFormData, + tab: chrome.tabs.Tab, + ) => { + if (this.shouldTriggerChangePasswordNotification(modifyLoginData)) { + // These notifications are temporarily setup as "messages" to the notification background. + // This will be structured differently in a future refactor. + await this.notificationBackground.changedPassword( + { + command: "bgChangedPassword", + data: { + url: modifyLoginData.uri, + currentPassword: modifyLoginData.password, + newPassword: modifyLoginData.newPassword, + }, + }, + { tab }, + ); + this.clearCompletedWebRequest(requestId, tab); + return; + } + + if (this.shouldTriggerAddLoginNotification(modifyLoginData)) { + await this.notificationBackground.addLogin( + { + command: "bgAddLogin", + login: { + url: modifyLoginData.uri, + username: modifyLoginData.username, + password: modifyLoginData.password || modifyLoginData.newPassword, + }, + }, + { tab }, + ); + this.clearCompletedWebRequest(requestId, tab); + } + }; + + /** + * Determines if the change password notification should be triggered. + * + * @param modifyLoginData - The modified login form data + */ + private shouldTriggerChangePasswordNotification = ( + modifyLoginData: ModifyLoginCipherFormData, + ) => { + return modifyLoginData.newPassword && !modifyLoginData.username; + }; + + /** + * Determines if the add login notification should be triggered. + * + * @param modifyLoginData - The modified login form data + */ + private shouldTriggerAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => { + return modifyLoginData.username && (modifyLoginData.password || modifyLoginData.newPassword); + }; + + /** + * Clears the completed web request and removes the modified login form data for the tab. + * + * @param requestId - The request id of the web request + * @param tab - The tab details + */ + private clearCompletedWebRequest = ( + requestId: chrome.webRequest.ResourceRequest["requestId"], + tab: chrome.tabs.Tab, + ) => { + this.activeFormSubmissionRequests.delete(requestId); + this.modifyLoginCipherFormData.delete(tab.id); + this.websiteOriginsWithFields.delete(tab.id); + this.setupWebRequestsListeners(); + }; + + /** + * Determines if the status code of the web response is invalid. An invalid status code is + * any status code that is not in the 200-299 range. + * + * @param statusCode - The status code of the web response + */ + private isInvalidStatusCode = (statusCode: number) => { + return statusCode < 200 || statusCode >= 300; + }; + + /** + * Determines if the host of the web request is invalid. An invalid host is any host that does not + * start with "http" or a tab id that is less than 0. + * + * @param details - The details of the web request + */ + private requestHostIsInvalid = (details: chrome.webRequest.ResourceRequest) => { + return !details.url?.startsWith("http") || details.tabId < 0; + }; + + /** + * Sets up the listeners for the extension messages and the tab events. + */ + private setupExtensionListeners() { + BrowserApi.messageListener("overlay-notifications", this.handleExtensionMessage); + chrome.tabs.onRemoved.addListener(this.handleTabRemoved); + chrome.tabs.onUpdated.addListener(this.handleTabUpdated); + } + + /** + * Handles messages that are sent to the extension background. + * + * @param message - The message from the content script + * @param sender - The sender of the message + * @param sendResponse - The response to send back to the content script + */ + private handleExtensionMessage = ( + message: OverlayNotificationsExtensionMessage, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void, + ) => { + const handler: CallableFunction = this.extensionMessageHandlers[message.command]; + if (!handler) { + return null; + } + + const messageResponse = handler({ message, sender }); + if (typeof messageResponse === "undefined") { + return null; + } + + Promise.resolve(messageResponse) + .then((response) => sendResponse(response)) + .catch((error) => this.logService.error(error)); + return true; + }; + + /** + * Handles the removal of a tab. This is used to remove the modified login form data for the tab. + * + * @param tabId - The id of the tab that was removed + */ + private handleTabRemoved = (tabId: number) => { + this.modifyLoginCipherFormData.delete(tabId); + if (this.websiteOriginsWithFields.has(tabId)) { + this.websiteOriginsWithFields.delete(tabId); + this.setupWebRequestsListeners(); + } + }; + + /** + * Handles the update of a tab. This is used to remove the modified + * login form data for the tab when the tab is loading. + * + * @param tabId - The id of the tab that was updated + * @param changeInfo - The change info of the tab + */ + private handleTabUpdated = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => { + if (changeInfo.status === "loading" && this.websiteOriginsWithFields.has(tabId)) { + this.websiteOriginsWithFields.delete(tabId); + } + }; +} diff --git a/apps/browser/src/autofill/content/auto-submit-login.spec.ts b/apps/browser/src/autofill/content/auto-submit-login.spec.ts index d8a192dbca..98caee3d36 100644 --- a/apps/browser/src/autofill/content/auto-submit-login.spec.ts +++ b/apps/browser/src/autofill/content/auto-submit-login.spec.ts @@ -12,6 +12,16 @@ let pageDetailsMock: AutofillPageDetails; let fillScriptMock: AutofillScript; let autofillFieldElementByOpidMock: FormFieldElement; +jest.mock("../services/dom-query.service", () => { + const module = jest.requireActual("../services/dom-query.service"); + return { + DomQueryService: class extends module.DomQueryService { + deepQueryElements(element: HTMLElement, queryString: string): T[] { + return Array.from(element.querySelectorAll(queryString)) as T[]; + } + }, + }; +}); jest.mock("../services/collect-autofill-content.service", () => { const module = jest.requireActual("../services/collect-autofill-content.service"); return { @@ -20,10 +30,6 @@ jest.mock("../services/collect-autofill-content.service", () => { return pageDetailsMock; } - deepQueryElements(element: HTMLElement, queryString: string): T[] { - return Array.from(element.querySelectorAll(queryString)) as T[]; - } - getAutofillFieldElementByOpid(opid: string) { const mockedEl = autofillFieldElementByOpidMock; if (mockedEl) { diff --git a/apps/browser/src/autofill/content/auto-submit-login.ts b/apps/browser/src/autofill/content/auto-submit-login.ts index 9cc06f874e..19ffac61bc 100644 --- a/apps/browser/src/autofill/content/auto-submit-login.ts +++ b/apps/browser/src/autofill/content/auto-submit-login.ts @@ -4,13 +4,16 @@ import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; import { CollectAutofillContentService } from "../services/collect-autofill-content.service"; import DomElementVisibilityService from "../services/dom-element-visibility.service"; +import { DomQueryService } from "../services/dom-query.service"; import InsertAutofillContentService from "../services/insert-autofill-content.service"; import { elementIsInputElement, nodeIsFormElement, sendExtensionMessage } from "../utils"; (function (globalContext) { + const domQueryService = new DomQueryService(); const domElementVisibilityService = new DomElementVisibilityService(); const collectAutofillContentService = new CollectAutofillContentService( domElementVisibilityService, + domQueryService, ); const insertAutofillContentService = new InsertAutofillContentService( domElementVisibilityService, @@ -191,7 +194,7 @@ import { elementIsInputElement, nodeIsFormElement, sendExtensionMessage } from " element: HTMLElement, lastFieldIsPasswordInput = false, ): boolean { - const genericSubmitElement = collectAutofillContentService.deepQueryElements( + const genericSubmitElement = domQueryService.deepQueryElements( element, "[type='submit']", ); @@ -200,10 +203,7 @@ import { elementIsInputElement, nodeIsFormElement, sendExtensionMessage } from " return true; } - const buttons = collectAutofillContentService.deepQueryElements( - element, - "button", - ); + const buttons = domQueryService.deepQueryElements(element, "button"); for (let i = 0; i < buttons.length; i++) { if (isLoginButton(buttons[i])) { clickSubmitElement(buttons[i], lastFieldIsPasswordInput); @@ -274,7 +274,7 @@ import { elementIsInputElement, nodeIsFormElement, sendExtensionMessage } from " */ function getAutofillFormElements(): HTMLFormElement[] { const formElements: HTMLFormElement[] = []; - collectAutofillContentService.queryAllTreeWalkerNodes( + domQueryService.queryAllTreeWalkerNodes( globalContext.document.documentElement, (node: Node) => { if (nodeIsFormElement(node)) { diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index e27e8ef73d..ebfbda75b5 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -3,6 +3,8 @@ import { mock, MockProxy } from "jest-mock-extended"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; +import { OverlayNotificationsContentService } from "../overlay/notifications/abstractions/overlay-notifications-content.service"; +import { DomQueryService } from "../services/abstractions/dom-query.service"; import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; import { flushPromises, @@ -14,6 +16,8 @@ import { AutofillExtensionMessage } from "./abstractions/autofill-init"; import AutofillInit from "./autofill-init"; describe("AutofillInit", () => { + let domQueryService: MockProxy; + let overlayNotificationsContentService: MockProxy; let inlineMenuElements: MockProxy; let autofillOverlayContentService: MockProxy; let autofillInit: AutofillInit; @@ -27,9 +31,16 @@ describe("AutofillInit", () => { addListener: jest.fn(), }, }); + domQueryService = mock(); + overlayNotificationsContentService = mock(); inlineMenuElements = mock(); autofillOverlayContentService = mock(); - autofillInit = new AutofillInit(autofillOverlayContentService, inlineMenuElements); + autofillInit = new AutofillInit( + domQueryService, + autofillOverlayContentService, + inlineMenuElements, + overlayNotificationsContentService, + ); sendExtensionMessageSpy = jest .spyOn(autofillInit as any, "sendExtensionMessage") .mockImplementation(); @@ -171,6 +182,16 @@ describe("AutofillInit", () => { expect(inlineMenuElements.messageHandlers.messageHandler).toHaveBeenCalled(); }); + it("triggers extension message handlers from the OverlayNotificationsContentService", () => { + overlayNotificationsContentService.messageHandlers.messageHandler = jest.fn(); + + sendMockExtensionMessage({ command: "messageHandler" }, sender, sendResponse); + + expect( + overlayNotificationsContentService.messageHandlers.messageHandler, + ).toHaveBeenCalled(); + }); + describe("collectPageDetails", () => { it("sends the collected page details for autofill using a background script message", async () => { const pageDetails: AutofillPageDetails = { diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index e44956e184..c0cbac3ae6 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -2,7 +2,9 @@ import { EVENTS } from "@bitwarden/common/autofill/constants"; import AutofillPageDetails from "../models/autofill-page-details"; import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service"; +import { OverlayNotificationsContentService } from "../overlay/notifications/abstractions/overlay-notifications-content.service"; import { AutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service"; +import { DomQueryService } from "../services/abstractions/dom-query.service"; import { CollectAutofillContentService } from "../services/collect-autofill-content.service"; import DomElementVisibilityService from "../services/dom-element-visibility.service"; import InsertAutofillContentService from "../services/insert-autofill-content.service"; @@ -16,8 +18,6 @@ import { class AutofillInit implements AutofillInitInterface { private readonly sendExtensionMessage = sendExtensionMessage; - private readonly autofillOverlayContentService: AutofillOverlayContentService | undefined; - private readonly autofillInlineMenuContentService: AutofillInlineMenuContentService | undefined; private readonly domElementVisibilityService: DomElementVisibilityService; private readonly collectAutofillContentService: CollectAutofillContentService; private readonly insertAutofillContentService: InsertAutofillContentService; @@ -32,20 +32,23 @@ class AutofillInit implements AutofillInitInterface { * AutofillInit constructor. Initializes the DomElementVisibilityService, * CollectAutofillContentService and InsertAutofillContentService classes. * + * @param domQueryService - Service used to handle DOM queries. * @param autofillOverlayContentService - The autofill overlay content service, potentially undefined. - * @param inlineMenuElements - The inline menu elements, potentially undefined. + * @param autofillInlineMenuContentService - The inline menu content service, potentially undefined. + * @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined. */ constructor( - autofillOverlayContentService?: AutofillOverlayContentService, - inlineMenuElements?: AutofillInlineMenuContentService, + private domQueryService: DomQueryService, + private autofillOverlayContentService?: AutofillOverlayContentService, + private autofillInlineMenuContentService?: AutofillInlineMenuContentService, + private overlayNotificationsContentService?: OverlayNotificationsContentService, ) { - this.autofillOverlayContentService = autofillOverlayContentService; - this.autofillInlineMenuContentService = inlineMenuElements; this.domElementVisibilityService = new DomElementVisibilityService( this.autofillInlineMenuContentService, ); this.collectAutofillContentService = new CollectAutofillContentService( this.domElementVisibilityService, + domQueryService, this.autofillOverlayContentService, ); this.insertAutofillContentService = new InsertAutofillContentService( @@ -204,6 +207,10 @@ class AutofillInit implements AutofillInitInterface { return this.autofillInlineMenuContentService.messageHandlers[command]; } + if (this.overlayNotificationsContentService?.messageHandlers?.[command]) { + return this.overlayNotificationsContentService.messageHandlers[command]; + } + return this.extensionMessageHandlers[command]; } @@ -217,6 +224,7 @@ class AutofillInit implements AutofillInitInterface { this.collectAutofillContentService.destroy(); this.autofillOverlayContentService?.destroy(); this.autofillInlineMenuContentService?.destroy(); + this.overlayNotificationsContentService?.destroy(); } } diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts new file mode 100644 index 0000000000..aed0f6cb94 --- /dev/null +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts @@ -0,0 +1,30 @@ +import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; +import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; +import { DomQueryService } from "../services/dom-query.service"; +import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; +import { setupAutofillInitDisconnectAction } from "../utils"; + +import AutofillInit from "./autofill-init"; + +(function (windowContext) { + if (!windowContext.bitwardenAutofillInit) { + const domQueryService = new DomQueryService(); + const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + const autofillOverlayContentService = new AutofillOverlayContentService( + domQueryService, + inlineMenuFieldQualificationService, + ); + let inlineMenuElements: AutofillInlineMenuContentService; + if (globalThis.self === globalThis.top) { + inlineMenuElements = new AutofillInlineMenuContentService(); + } + windowContext.bitwardenAutofillInit = new AutofillInit( + domQueryService, + autofillOverlayContentService, + inlineMenuElements, + ); + setupAutofillInitDisconnectAction(windowContext); + + windowContext.bitwardenAutofillInit.init(); + } +})(window); diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts new file mode 100644 index 0000000000..0a810c68f5 --- /dev/null +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts @@ -0,0 +1,33 @@ +import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service"; +import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; +import { DomQueryService } from "../services/dom-query.service"; +import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; +import { setupAutofillInitDisconnectAction } from "../utils"; + +import AutofillInit from "./autofill-init"; + +(function (windowContext) { + if (!windowContext.bitwardenAutofillInit) { + const domQueryService = new DomQueryService(); + const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + const autofillOverlayContentService = new AutofillOverlayContentService( + domQueryService, + inlineMenuFieldQualificationService, + ); + + let overlayNotificationsContentService: OverlayNotificationsContentService; + if (globalThis.self === globalThis.top) { + overlayNotificationsContentService = new OverlayNotificationsContentService(); + } + + windowContext.bitwardenAutofillInit = new AutofillInit( + domQueryService, + autofillOverlayContentService, + null, + overlayNotificationsContentService, + ); + setupAutofillInitDisconnectAction(windowContext); + + windowContext.bitwardenAutofillInit.init(); + } +})(window); diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts index 2243022766..6df9397f6d 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts @@ -1,5 +1,7 @@ import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; +import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service"; import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service"; +import { DomQueryService } from "../services/dom-query.service"; import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; import { setupAutofillInitDisconnectAction } from "../utils"; @@ -7,17 +9,25 @@ import AutofillInit from "./autofill-init"; (function (windowContext) { if (!windowContext.bitwardenAutofillInit) { + const domQueryService = new DomQueryService(); const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); const autofillOverlayContentService = new AutofillOverlayContentService( + domQueryService, inlineMenuFieldQualificationService, ); + let inlineMenuElements: AutofillInlineMenuContentService; + let overlayNotificationsContentService: OverlayNotificationsContentService; if (globalThis.self === globalThis.top) { inlineMenuElements = new AutofillInlineMenuContentService(); + overlayNotificationsContentService = new OverlayNotificationsContentService(); } + windowContext.bitwardenAutofillInit = new AutofillInit( + domQueryService, autofillOverlayContentService, inlineMenuElements, + overlayNotificationsContentService, ); setupAutofillInitDisconnectAction(windowContext); diff --git a/apps/browser/src/autofill/content/bootstrap-autofill.ts b/apps/browser/src/autofill/content/bootstrap-autofill.ts index f98d4bc1d7..3de750cd67 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill.ts @@ -1,10 +1,12 @@ +import { DomQueryService } from "../services/dom-query.service"; import { setupAutofillInitDisconnectAction } from "../utils"; import AutofillInit from "./autofill-init"; (function (windowContext) { if (!windowContext.bitwardenAutofillInit) { - windowContext.bitwardenAutofillInit = new AutofillInit(); + const domQueryService = new DomQueryService(); + windowContext.bitwardenAutofillInit = new AutofillInit(domQueryService); setupAutofillInitDisconnectAction(windowContext); windowContext.bitwardenAutofillInit.init(); diff --git a/apps/browser/src/autofill/content/notification-bar.ts b/apps/browser/src/autofill/content/notification-bar.ts index 2bcf4394fd..5217ebbe8e 100644 --- a/apps/browser/src/autofill/content/notification-bar.ts +++ b/apps/browser/src/autofill/content/notification-bar.ts @@ -6,6 +6,7 @@ import { import AutofillField from "../models/autofill-field"; import { WatchedForm } from "../models/watched-form"; import { NotificationBarIframeInitData } from "../notification/abstractions/notification-bar"; +import { NotificationTypeData } from "../overlay/notifications/abstractions/overlay-notifications-content.service"; import { FormData } from "../services/abstractions/autofill.service"; import { sendExtensionMessage, setupExtensionDisconnectAction } from "../utils"; @@ -832,7 +833,7 @@ async function loadNotificationBar() { // End Form Detection and Submission Handling // Notification Bar Functions (open, close, height adjustment, etc.) - function closeExistingAndOpenBar(type: string, typeData: any) { + function closeExistingAndOpenBar(type: string, typeData: NotificationTypeData) { const notificationBarInitData: NotificationBarIframeInitData = { type, isVaultLocked: typeData.isVaultLocked, diff --git a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts index 211e3bf925..b3ee2637b0 100644 --- a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts +++ b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts @@ -2,6 +2,7 @@ import { AutofillInit } from "../../content/abstractions/autofill-init"; import AutofillPageDetails from "../../models/autofill-page-details"; import { CollectAutofillContentService } from "../../services/collect-autofill-content.service"; import DomElementVisibilityService from "../../services/dom-element-visibility.service"; +import { DomQueryService } from "../../services/dom-query.service"; import InsertAutofillContentService from "../../services/insert-autofill-content.service"; import { sendExtensionMessage } from "../../utils"; import { LegacyAutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service"; @@ -40,8 +41,10 @@ class LegacyAutofillInit implements AutofillInit { constructor(autofillOverlayContentService?: LegacyAutofillOverlayContentService) { this.autofillOverlayContentService = autofillOverlayContentService; this.domElementVisibilityService = new DomElementVisibilityService(); + const domQueryService = new DomQueryService(); this.collectAutofillContentService = new CollectAutofillContentService( this.domElementVisibilityService, + domQueryService, this.autofillOverlayContentService, ); this.insertAutofillContentService = new InsertAutofillContentService( diff --git a/apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.ts b/apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.ts index 6526f6993d..87af2518dd 100644 --- a/apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.ts +++ b/apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.ts @@ -73,7 +73,7 @@ class LegacyAutofillOverlayContentService implements LegacyAutofillOverlayConten * Satisfy the AutofillOverlayContentService interface. */ messageHandlers = {} as AutofillOverlayContentExtensionMessageHandlers; - async setupInlineMenu( + async setupOverlayListeners( autofillFieldElement: ElementWithOpId, autofillFieldData: AutofillField, pageDetails: AutofillPageDetails, diff --git a/apps/browser/src/autofill/enums/autofill-field.enums.ts b/apps/browser/src/autofill/enums/autofill-field.enums.ts index 4fd7c0fe88..68408f2b67 100644 --- a/apps/browser/src/autofill/enums/autofill-field.enums.ts +++ b/apps/browser/src/autofill/enums/autofill-field.enums.ts @@ -1,5 +1,6 @@ export const AutofillFieldQualifier = { password: "password", + newPassword: "newPassword", username: "username", cardholderName: "cardholderName", cardNumber: "cardNumber", diff --git a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts index 268617c419..6dfcac4abe 100644 --- a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts +++ b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts @@ -4,6 +4,8 @@ type NotificationBarIframeInitData = { theme?: string; removeIndividualVault?: boolean; importType?: string; + applyRedesign?: boolean; + launchTimestamp?: number; }; type NotificationBarWindowMessage = { diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html index 26d9d7086d..6b0e76b516 100644 --- a/apps/browser/src/autofill/notification/bar.html +++ b/apps/browser/src/autofill/notification/bar.html @@ -13,16 +13,11 @@
-
+
@@ -32,8 +27,8 @@