diff --git a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts index a70ffe25310..009f5ced3e3 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts @@ -21,6 +21,7 @@ export type WebsiteOriginsWithFields = Map>; export type ActiveFormSubmissionRequests = Set; +/** This type represents an expectation of nullish values being represented as empty strings */ export type ModifyLoginCipherFormData = { uri: string; username: string; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index ab16788ea6f..ca2cf46a90f 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -67,8 +67,10 @@ describe("NotificationBackground", () => { }); const folderService = mock(); const enableChangedPasswordPromptMock$ = new BehaviorSubject(true); + const enableAddedLoginPromptMock$ = new BehaviorSubject(true); const userNotificationSettingsService = mock(); userNotificationSettingsService.enableChangedPasswordPrompt$ = enableChangedPasswordPromptMock$; + userNotificationSettingsService.enableAddedLoginPrompt$ = enableAddedLoginPromptMock$; const domainSettingsService = mock(); const environmentService = mock(); @@ -90,7 +92,9 @@ describe("NotificationBackground", () => { }); beforeEach(() => { - activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Locked); + activeAccountStatusMock$ = new BehaviorSubject( + AuthenticationStatus.Locked as AuthenticationStatus, + ); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; accountService.activeAccount$ = activeAccountSubject; @@ -290,7 +294,7 @@ describe("NotificationBackground", () => { username: "test", password: "password", uri: "https://example.com", - newPassword: null, + newPassword: "", }; beforeEach(() => { tab = createChromeTabMock(); @@ -323,7 +327,7 @@ describe("NotificationBackground", () => { ...mockModifyLoginCipherFormData, uri: "", }; - activeAccountStatusMock$.next(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); await notificationBackground.triggerAddLoginNotification(data, tab); @@ -396,7 +400,7 @@ describe("NotificationBackground", () => { it("adds the login to the queue if the user has an unlocked account and the login is new", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, - username: null, + username: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -423,18 +427,20 @@ describe("NotificationBackground", () => { let tab: chrome.tabs.Tab; let sender: chrome.runtime.MessageSender; let getEnableChangedPasswordPromptSpy: jest.SpyInstance; + let getEnableAddedLoginPromptSpy: jest.SpyInstance; let pushChangePasswordToQueueSpy: jest.SpyInstance; + let pushAddLoginToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; - const mockModifyLoginCipherFormData: ModifyLoginCipherFormData = { - username: null, - uri: null, - password: "currentPassword", - newPassword: "newPassword", - }; + const mockFormattedURI = "archive.org"; + const mockFormURI = "https://www.archive.org"; beforeEach(() => { tab = createChromeTabMock(); sender = mock({ tab }); + getEnableAddedLoginPromptSpy = jest.spyOn( + notificationBackground as any, + "getEnableAddedLoginPrompt", + ); getEnableChangedPasswordPromptSpy = jest.spyOn( notificationBackground as any, "getEnableChangedPasswordPrompt", @@ -444,245 +450,1462 @@ describe("NotificationBackground", () => { notificationBackground as any, "pushChangePasswordToQueue", ); + pushAddLoginToQueueSpy = jest.spyOn(notificationBackground as any, "pushAddLoginToQueue"); getAllDecryptedForUrlSpy = jest.spyOn(cipherService, "getAllDecryptedForUrl"); }); afterEach(() => { + getEnableAddedLoginPromptSpy.mockRestore(); getEnableChangedPasswordPromptSpy.mockRestore(); pushChangePasswordToQueueSpy.mockRestore(); + pushAddLoginToQueueSpy.mockRestore(); getAllDecryptedForUrlSpy.mockRestore(); }); - it("skips attempting to change the password for an existing login if the user has disabled changing the password notification", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, + it("skips checking if a notification should trigger if no fields were filled", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "", + uri: mockFormURI, + username: "", }; + + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "I<3VogonPoetry", username: "ADent" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("skips checking if a notification should trigger if the passed url is not valid", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: "", + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "I<3VogonPoetry", username: "ADent" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("skips checking if a notification should trigger if the user has disabled both the new login and update password notification", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ login: { username: "test", password: "oldPassword" } }), - ]); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); - await notificationBackground.triggerChangedPasswordNotification(data, tab); + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); }); - it("skips attempting to add the change password message to the queue if the user is logged out", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", + it("skips checking if a notification should trigger if the user is logged out", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", }; + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); - await notificationBackground.triggerChangedPasswordNotification(data, tab); + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); }); - it("skips attempting to add the change password message to the queue if the passed url is not valid", async () => { - const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; + it("skips checking if a notification should trigger if there is no active account", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; - await notificationBackground.triggerChangedPasswordNotification(data, tab); + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + accountService.activeAccount$ = new BehaviorSubject(null); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); }); - it("only only includes ciphers in notification data matching a username if username was present in the modify form data", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", - username: "userName", + it("skips checking if a notification should trigger if the values for the `password` and `newPassword` fields match (no change)", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Beeblebrox4Prez", + password: "Beeblebrox4Prez", + uri: mockFormURI, + username: "ADent", }; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ - id: "cipher-id-1", - login: { username: "test", password: "currentPassword" }, - }), - mock({ - id: "cipher-id-2", - login: { username: "username", password: "currentPassword" }, - }), - mock({ - id: "cipher-id-3", - login: { username: "uSeRnAmE", password: "currentPassword" }, - }), - ]); + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; - await notificationBackground.triggerChangedPasswordNotification(data, tab); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); - expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - ["cipher-id-2", "cipher-id-3"], - "example.com", - data?.newPassword, - sender.tab, - ); + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); }); - it("adds a change password message to the queue with current password, if there is a current password, but no new password", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", - password: "newPasswordUpdatedElsewhere", - newPassword: null, + it("skips checking if a notification should trigger if the vault is locked and there is no value for the `newPassword` field", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Beeblebrox4Prez", + password: "Beeblebrox4Prez", + uri: mockFormURI, + username: "ADent", }; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ - id: "cipher-id-1", - login: { password: "currentPassword" }, - }), - ]); - await notificationBackground.triggerChangedPasswordNotification(data, tab); - expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - ["cipher-id-1"], - "example.com", - data?.password, - sender.tab, - ); - }); - - it("adds a change password message to the queue with new password, if new password is provided", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", - password: "password2", - newPassword: "password3", - }; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ - id: "cipher-id-1", - login: { password: "password1" }, - }), - mock({ - id: "cipher-id-4", - login: { password: "password4" }, - }), - ]); - await notificationBackground.triggerChangedPasswordNotification(data, tab); - - expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - ["cipher-id-1", "cipher-id-4"], - "example.com", - data?.newPassword, - sender.tab, - ); - }); - - it("adds a change password message to the queue if the user has a locked account", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", - }; + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; activeAccountStatusMock$.next(AuthenticationStatus.Locked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); - await notificationBackground.triggerChangedPasswordNotification(data, tab); + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); - expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - null, - "example.com", - data?.newPassword, - sender.tab, - true, - ); - }); - - it("doesn't add a password if there is no current or new password", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", - password: null, - newPassword: null, - }; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ login: { username: "test", password: "password" } }), - ]); - await notificationBackground.triggerChangedPasswordNotification(data, tab); - - expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); - it("adds a change password message to the queue if a single cipher matches the passed current password", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", + describe("when `username` and `password` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Edro2x", + password: "UShallKnotPassword", + uri: mockFormURI, + username: "gandalfG", }; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ - id: "cipher-id", - login: { username: "test", password: "currentPassword" }, - }), - ]); - await notificationBackground.triggerChangedPasswordNotification(data, tab); + it("and the user vault is locked, trigger an unlock notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; - expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - ["cipher-id"], - "example.com", - data?.newPassword, - sender.tab, - ); + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, + ); + }); + + it("and cipher update candidates match `newPassword` only, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and cipher update candidates match `newPassword` only, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `password` only, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `password` only, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `password` only, as well as `newPassword` only, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` only, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-2"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` only, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` only, as well as `password` or `newPassword` only, trigger an update cipher notification with the candidates `username`", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` and `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "sting123", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "Edro2x", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `newPassword` as well as any other combination of `username`, `password`, and/or `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "sting123", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "Edro2x", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-5", + login: { password: "Edro2x", username: "FBaggins" }, + }), + mock({ + id: "cipher-id-6", + login: { password: "UShallKnotPassword", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-7", + login: { password: "ShyerH1re", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` and `password`, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` AND `password` as well as any OTHER combination of `username`, `password`, and/or `newPassword` (excluding `username` AND `newPassword`), trigger an update cipher notification with the candidates matching `username` AND `password`", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "flyUPh00lz", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-5", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-6", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, `password`, nor `newPassword`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, `password`, nor `newPassword`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); }); - it("adds a change password message with all matching ciphers if no current password is passed and more than one cipher is found for a url", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", - password: null, + describe("when `username` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "2ndBreakf4st", + password: "", + uri: mockFormURI, + username: "BBaggins", }; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ - id: "cipher-id-1", - login: { username: "test", password: "password" }, - }), - mock({ - id: "cipher-id-2", - login: { username: "test2", password: "password" }, - }), - ]); - await notificationBackground.triggerChangedPasswordNotification(data, tab); + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); - expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - ["cipher-id-1", "cipher-id-2"], - "example.com", - data?.newPassword, - sender.tab, - ); + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, + ); + }); + + it("and cipher update candidates match only `newPassword`, do not trigger a notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "2ndBreakf4st" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match only `username`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Frodo", password: "differentPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Pippin", password: "2ndBreakf4st" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and at least one cipher update candidate matches both `username` and `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "2ndBreakf4st" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Frodo", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username` nor `newPassword`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username` nor `newPassword`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); }); - it("adds a change password message to the queue if no current password is passed with the message, but a single cipher is matched for the uri", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", - password: null, + describe("when only `username` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "", + uri: mockFormURI, + username: "BBaggins", }; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ - id: "cipher-id", - login: { username: "test", password: "password" }, - }), - ]); - await notificationBackground.triggerChangedPasswordNotification(data, tab); + it("and the user vault is locked, do not trigger a notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); - expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - ["cipher-id"], - "example.com", - data?.newPassword, - sender.tab, - ); + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and at least one cipher update candidate matches `username`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Frodo", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: "", + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `password` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "4WzrdIzN0tLa7e", + password: "UShallKnotPassword", + username: "", + uri: mockFormURI, + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, + ); + }); + + it("and cipher update candidates only match `newPassword`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "GaldalfG", password: "4WzrdIzN0tLa7e" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "GaldalfW", password: "4WzrdIzN0tLa7e" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates only match `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Merry", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates only match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Merry", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `password` or `newPassword`, do not trigger a notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "11sies" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when only `password` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "UShallKnotPassword", + uri: mockFormURI, + username: "", + }; + + it("and cipher update candidates only match `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `password`, trigger an update cipher notification with ALL cipher update candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "MahPr3c10us" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "PTook", password: "f00lOfAT00k" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and no cipher update candidates match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "MahPr3c10us" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "PTook", password: "f00lOfAT00k" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `username` and `password` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "ShyerH1re", + uri: mockFormURI, + username: "BBaggins", + }; + + it("and cipher update candidates only match `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FrodoB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and cipher update candidates only match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FrodoB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates only match `username`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BilboB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and cipher update candidates only match `username`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BilboB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` AND `password` as well as `username` OR `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-4", + login: { username: "TBombadil", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username` or `password`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BilboB", password: "PutAR1ngOnIt" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.password, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username` or `password`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BilboB", password: "PutAR1ngOnIt" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when only `newPassword` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "ShyerH1re", + password: "", + uri: mockFormURI, + username: "", + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, + ); + }); + + it("and cipher update candidates only match `newPassword`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `newPassword`, trigger an update cipher notification with ALL cipher update candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "SamwiseG", password: "P0t4toes" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and no cipher update candidates match `newPassword`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "SamwiseG", password: "P0t4toes" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerChangedPasswordNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 6aa5e169171..7192082c135 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -79,6 +79,30 @@ import { } from "./abstractions/overlay-notifications.background"; import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.background"; +const inputScenarios = { + usernamePasswordNewPassword: "usernamePasswordNewPassword", + usernameNewPassword: "usernameNewPassword", + usernamePassword: "usernamePassword", + username: "username", + passwordNewPassword: "passwordNewPassword", + newPassword: "newPassword", + password: "password", +} as const; + +type InputScenarioKey = keyof typeof inputScenarios; +type InputScenario = (typeof inputScenarios)[InputScenarioKey]; + +type CiphersByInputMatchCategory = { + allFieldMatches: CipherView["id"][]; + newPasswordOnlyMatches: CipherView["id"][]; + noFieldMatches: CipherView["id"][]; + passwordNewPasswordMatches: CipherView["id"][]; + passwordOnlyMatches: CipherView["id"][]; + usernameNewPasswordMatches: CipherView["id"][]; + usernameOnlyMatches: CipherView["id"][]; + usernamePasswordMatches: CipherView["id"][]; +}; + export default class NotificationBackground { private openUnlockPopout = openUnlockPopout; private openAddEditVaultItemPopout = openAddEditVaultItemPopout; @@ -514,6 +538,7 @@ export default class NotificationBackground { return true; } + // @TODO we should probably roll this into `triggerChangedPasswordNotification` and handle the prequalified scenarios for locked vaults there; we're likely going to have to call new logic notifications from `triggerChangedPasswordNotification` anyway /** * Adds a login message to the notification queue, prompting the user to save * the login if it does not already exist in the vault. If the cipher exists @@ -603,146 +628,420 @@ export default class NotificationBackground { * Returns `true` or `false` to indicate if such an update notification was * triggered or not. * + * For the purposes of this function, form field inputs should be assumed to be qualified accurately. + * * @param message - The message to add to the queue * @param sender - The contextual sender of the message */ + // @TODO Merge to triggerLoginCipherNotification async triggerChangedPasswordNotification( data: ModifyLoginCipherFormData, tab: chrome.tabs.Tab, ): Promise { - const changePasswordIsEnabled = await this.getEnableChangedPasswordPrompt(); - if (!changePasswordIsEnabled) { + const usernameFieldValue: string | null = data.username || null; + const currentPasswordFieldValue = data.password || null; + const newPasswordFieldValue = data.newPassword || null; + + // If no values were entered, exit early + if (!usernameFieldValue && !currentPasswordFieldValue && !newPasswordFieldValue) { return false; } + + // If the entered data doesn't have an associated URI, exit early + const loginDomain = Utils.getDomain(data.uri); + if (loginDomain === null) { + return false; + } + + // If no cipher add/update notifications are enabled, we can exit early + const changePasswordNotificationIsEnabled = await this.getEnableChangedPasswordPrompt(); + const newLoginNotificationIsEnabled = await this.getEnableAddedLoginPrompt(); + if (!changePasswordNotificationIsEnabled && !newLoginNotificationIsEnabled) { + return false; + } + + // If there is no account logged in (as opposed to only being locked), exit early const authStatus = await this.getAuthStatus(); if (authStatus === AuthenticationStatus.LoggedOut) { return false; } + + // If there is no active user, exit early const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getOptionalUserId), ); if (activeUserId === null) { return false; } - const loginDomain = Utils.getDomain(data.uri); - if (loginDomain === null) { - return false; - } - - const usernameFieldValue: string | null = data.username || null; - const currentPasswordFieldValue = data.password || null; - const newPasswordFieldValue = data.newPassword || null; - - /* - * We only show the unlock notification if a new password field was filled, since - * it's very likely to represent an updated cipher value and the other - * scenarios below require the vault to be unlocked in order to determine - * if an update has been made. - */ - if (authStatus === AuthenticationStatus.Locked) { - if (newPasswordFieldValue !== null) { - await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true); - return true; - } - - return false; - } - - let updateCandidateCiphers: CipherView[] = await this.cipherService.getAllDecryptedForUrl( - data.uri, - activeUserId, - ); const normalizedUsername: string = usernameFieldValue ? usernameFieldValue.toLowerCase() : ""; const currentPasswordFieldHasValue = typeof currentPasswordFieldValue === "string" && currentPasswordFieldValue.length > 0; const newPasswordFieldHasValue = typeof newPasswordFieldValue === "string" && newPasswordFieldValue.length > 0; - - const checkForUsernameMatch = + const usernameFieldHasValue = typeof usernameFieldValue === "string" && usernameFieldValue.length > 0; - const checkForPasswordMatch = newPasswordFieldHasValue && currentPasswordFieldHasValue; - // If a username was entered, use that value as the basis for finding stored cipher to update - if (checkForUsernameMatch) { - updateCandidateCiphers = updateCandidateCiphers.filter( - (cipher) => - // Presence of a stored username should filter ciphers further. - cipher.login.username !== null && - cipher.login.username.toLowerCase() === normalizedUsername, - ); - - /* - * Because we have stored ciphers with username values matching the entered - * username value, we can now further filter ciphers on the basis of password - * matches (do not offer to update ciphers that already match the username and - * password of what was entered). - */ - - /* - * If a new password value was entered (ignore current password value, assume - * password update), filter out stored ciphers that have a password matching the - * new password value entered (because there's no change for those). - */ - if (newPasswordFieldHasValue) { - updateCandidateCiphers = updateCandidateCiphers.filter( - (cipher) => - // include ciphers without passwords as update candidates - !cipher.login.password || cipher.login.password !== newPasswordFieldValue, - ); - } else if (currentPasswordFieldHasValue) { - /* - * Otherwise, if a _current_ password value was entered, and _no_ new password - * value was entered (account creation or login with non-stored credentials), - * filter out ciphers that have a password matching the current password value - * entered (because there's no change for those). - */ - updateCandidateCiphers = updateCandidateCiphers.filter( - (cipher) => - // include ciphers without passwords as update candidates - !cipher.login.password || cipher.login.password !== currentPasswordFieldValue, - ); - } - } else if (checkForPasswordMatch) { - /* - * Since no username was provided, use the password field entries to filter - * matching cipher update candidates. Both current and new values are needed. - */ - updateCandidateCiphers = updateCandidateCiphers.filter( - (cipher) => - cipher.login.password && - cipher.login.password === currentPasswordFieldValue && - // If the new password value also matches a stored cipher password, - // there is no change, and we should filter out that cipher - // from update candidates - cipher.login.password !== newPasswordFieldValue, - ); - } - // Insufficient field changes were given (e.g. new password only, - // no field values at all, etc); no update to make, may be a new cipher case - // (handled by `triggerAddLoginNotification`) - else { + // If the current and new password inputs both have values and those values + // match, return early, since no change was made + if ( + currentPasswordFieldHasValue && + newPasswordFieldHasValue && + currentPasswordFieldValue === newPasswordFieldValue + ) { return false; } - // If any ciphers remain after filtering, trigger an update notification - // with those ciphers - const resolvedPasswordUpdateValue = newPasswordFieldHasValue - ? newPasswordFieldValue - : currentPasswordFieldHasValue - ? currentPasswordFieldValue - : null; - if (updateCandidateCiphers.length && resolvedPasswordUpdateValue) { - await this.pushChangePasswordToQueue( - updateCandidateCiphers.map((cipher) => cipher.id), - loginDomain, - resolvedPasswordUpdateValue, - tab, - ); + /* + * We only show the unlock notification if a new password field was filled, since + * it's very likely to blindly represent an updated cipher value whereas other + * scenarios below require the vault to be unlocked in order to determine + * if an update has been made. + */ + if (authStatus === AuthenticationStatus.Locked) { + if (!newPasswordFieldHasValue) { + return false; + } + // This needs to be the call that includes the full form data + await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true); return true; } + const ciphersForURL: CipherView[] = await this.cipherService.getAllDecryptedForUrl( + data.uri, + activeUserId, + ); + + // Reducer structured to avoid subsequent array iterations + const ciphersByInputMatchCategory = ciphersForURL.reduce( + (acc, { id, login }) => { + const usernameInputMatchesCipher = + usernameFieldHasValue && login.username.toLowerCase() === normalizedUsername; + const passwordInputMatchesCipher = + currentPasswordFieldHasValue && login.password === currentPasswordFieldValue; + const newPasswordInputMatchesCipher = + newPasswordFieldHasValue && login.password === newPasswordFieldValue; + + if ( + !newPasswordInputMatchesCipher && + !usernameInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, noFieldMatches: [...acc.noFieldMatches, id] }; + } else if ( + newPasswordInputMatchesCipher && + usernameInputMatchesCipher && + passwordInputMatchesCipher + ) { + // Note: this case should be unreachable due to the early exit comparing + // the password input values against each other, but leaving this bit here + // as a defense against future changes to the pre-match checks. + return { ...acc, allFieldMatches: [...acc.allFieldMatches, id] }; + } else if ( + newPasswordInputMatchesCipher && + !usernameInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, newPasswordOnlyMatches: [...acc.newPasswordOnlyMatches, id] }; + } else if ( + passwordInputMatchesCipher && + !usernameInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, passwordOnlyMatches: [...acc.passwordOnlyMatches, id] }; + } else if ( + passwordInputMatchesCipher && + newPasswordInputMatchesCipher && + !usernameInputMatchesCipher + ) { + // Note: this case should be unreachable due to the early exit comparing + // the password input values against each other, but leaving this bit here + // as a defense against future changes to the pre-match checks. + return { ...acc, passwordNewPasswordMatches: [...acc.passwordNewPasswordMatches, id] }; + } else if ( + usernameInputMatchesCipher && + !passwordInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, usernameOnlyMatches: [...acc.usernameOnlyMatches, id] }; + } else if ( + usernameInputMatchesCipher && + passwordInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, usernamePasswordMatches: [...acc.usernamePasswordMatches, id] }; + } else if ( + usernameInputMatchesCipher && + newPasswordInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, usernameNewPasswordMatches: [...acc.usernameNewPasswordMatches, id] }; + } + + return acc; + }, + { + allFieldMatches: [], + newPasswordOnlyMatches: [], + noFieldMatches: [], + passwordNewPasswordMatches: [], + passwordOnlyMatches: [], + usernameNewPasswordMatches: [], + usernameOnlyMatches: [], + usernamePasswordMatches: [], + }, + ); + + let inputScenario = null; + + // Handle different field fill combinations + if (currentPasswordFieldHasValue && newPasswordFieldHasValue && usernameFieldHasValue) { + inputScenario = inputScenarios.usernamePasswordNewPassword; + } else if (newPasswordFieldHasValue && usernameFieldHasValue && !currentPasswordFieldHasValue) { + inputScenario = inputScenarios.usernameNewPassword; + } else if ( + usernameFieldHasValue && + !currentPasswordFieldHasValue && + !newPasswordFieldHasValue + ) { + inputScenario = inputScenarios.username; + } else if (currentPasswordFieldHasValue && newPasswordFieldHasValue && !usernameFieldHasValue) { + inputScenario = inputScenarios.passwordNewPassword; + } else if ( + currentPasswordFieldHasValue && + !newPasswordFieldHasValue && + !usernameFieldHasValue + ) { + inputScenario = inputScenarios.password; + } else if (currentPasswordFieldHasValue && usernameFieldHasValue && !newPasswordFieldHasValue) { + inputScenario = inputScenarios.usernamePassword; + } else if ( + newPasswordFieldHasValue && + !currentPasswordFieldHasValue && + !usernameFieldHasValue + ) { + inputScenario = inputScenarios.newPassword; + } + + if (inputScenario) { + return await this.handleInputMatchScenario({ + ciphersByInputMatchCategory, + ciphersForURL, + loginDomain, + tab, + data, + inputScenario, + changePasswordNotificationIsEnabled, + newLoginNotificationIsEnabled, + }); + } + + return false; + } + + // Order of conditionals matters, here; later evaluations depend on the early exits of preceding logic + private async handleInputMatchScenario({ + inputScenario, + ciphersByInputMatchCategory, + ciphersForURL, + loginDomain, + tab, + data, + changePasswordNotificationIsEnabled, + newLoginNotificationIsEnabled, + }: { + ciphersByInputMatchCategory: CiphersByInputMatchCategory; + ciphersForURL: CipherView[]; + loginDomain: string; + tab: chrome.tabs.Tab; + data: ModifyLoginCipherFormData; + inputScenario: InputScenario; + changePasswordNotificationIsEnabled: boolean; + newLoginNotificationIsEnabled: boolean; + }): Promise { + const { + newPasswordOnlyMatches, + noFieldMatches, + passwordOnlyMatches, + usernameNewPasswordMatches, + usernameOnlyMatches, + usernamePasswordMatches, + } = ciphersByInputMatchCategory; + + // If no ciphers match any filled input values + // Note, this block may uniquely exit early since this match scenario + // involves all ciphers, making it mutually exclusive from any other scenario + if (noFieldMatches.length === ciphersForURL.length) { + // trigger a new cipher notification in these input scenarios + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + inputScenarios.usernamePassword, + inputScenarios.username, + ] as InputScenario[] + ).includes(inputScenario) && + newLoginNotificationIsEnabled + ) { + await this.pushAddLoginToQueue( + loginDomain, + { username: data.username, url: data.uri, password: data.newPassword || data.password }, + tab, + ); + + return true; + } + + // Trigger an update cipher notification with all URI ciphers + // in these input scenarios + if ( + ([inputScenarios.password, inputScenarios.newPassword] as InputScenario[]).includes( + inputScenario, + ) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + ciphersForURL.map((c) => c.id), + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + return false; + } + + // If ciphers match entered username and new password values + if (usernameNewPasswordMatches.length) { + // Early exit in these scenarios as they represent "no change" + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + ] as InputScenario[] + ).includes(inputScenario) + ) { + return false; + } + } + + // If ciphers match entered username and password values + if (usernamePasswordMatches.length) { + // and username, password, and new password values were entered + if ( + inputScenario === inputScenarios.usernamePasswordNewPassword && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + usernamePasswordMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + if (inputScenario === inputScenarios.usernamePassword) { + return false; + } + } + + // If ciphers match entered username value (only) + if (usernameOnlyMatches.length) { + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + inputScenarios.usernamePassword, + ] as InputScenario[] + ).includes(inputScenario) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + usernameOnlyMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + // Early exit in this scenario as it represents "no change" + if (inputScenario === inputScenarios.username) { + return false; + } + } + + // If ciphers match entered new password value (only) + if (newPasswordOnlyMatches.length) { + // Early exit in these scenarios + if ( + ( + [ + inputScenarios.usernameNewPassword, // unclear user expectation + inputScenarios.password, // likely nothing to change + inputScenarios.newPassword, // nothing to change + ] as InputScenario[] + ).includes(inputScenario) + ) { + return false; + } + + // and username, password, and new password values were entered + if ( + inputScenario === inputScenarios.usernamePasswordNewPassword && + newLoginNotificationIsEnabled + ) { + await this.pushAddLoginToQueue( + loginDomain, + { username: data.username, url: data.uri, password: data.newPassword || data.password }, + tab, + ); + + return true; + } + } + + // If ciphers match entered password value (only) + if (passwordOnlyMatches.length) { + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernamePassword, + inputScenarios.passwordNewPassword, + ] as InputScenario[] + ).includes(inputScenario) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + passwordOnlyMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + // Early exit in this scenario as it represents "no change" + if (inputScenario === inputScenarios.password) { + return false; + } + } + return false; } @@ -766,6 +1065,7 @@ export default class NotificationBackground { }); } + // @TODO this needs the whole input record, and not just newPassword private async pushChangePasswordToQueue( cipherIds: CipherView["id"][], loginDomain: string,