1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-13 14:53:33 +00:00
Files
browser/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts
2025-06-11 10:20:53 -04:00

719 lines
24 KiB
TypeScript

import { mock, MockProxy } from "jest-mock-extended";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
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<LogService>;
let notificationBackground: NotificationBackground;
let taskService: TaskService;
let accountService: AccountService;
let cipherService: CipherService;
let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
let getEnableAddedLoginPromptSpy: jest.SpyInstance;
let overlayNotificationsBackground: OverlayNotificationsBackground;
beforeEach(async () => {
jest.useFakeTimers();
logService = mock<LogService>();
notificationBackground = mock<NotificationBackground>();
taskService = mock<TaskService>();
accountService = mock<AccountService>();
cipherService = mock<CipherService>();
getEnableChangedPasswordPromptSpy = jest
.spyOn(notificationBackground, "getEnableChangedPasswordPrompt")
.mockResolvedValue(true);
getEnableAddedLoginPromptSpy = jest
.spyOn(notificationBackground, "getEnableAddedLoginPrompt")
.mockResolvedValue(true);
overlayNotificationsBackground = new OverlayNotificationsBackground(
logService,
notificationBackground,
taskService,
accountService,
cipherService,
);
await overlayNotificationsBackground.init();
});
afterEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});
describe("setting up the form submission listeners", () => {
let fields: MockProxy<AutofillField>[];
let details: MockProxy<AutofillPageDetails>;
beforeEach(() => {
fields = [mock<AutofillField>(), mock<AutofillField>(), mock<AutofillField>()];
details = mock<AutofillPageDetails>({ 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<chrome.runtime.MessageSender>({ origin: vault });
jest
.spyOn(notificationBackground, "getActiveUserServerConfig")
.mockResolvedValue(
mock<ServerConfig>({ environment: mock<EnvironmentServerConfigData>({ 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<chrome.runtime.MessageSender>({ 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<chrome.runtime.MessageSender>({ 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<chrome.runtime.MessageSender>({ 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<chrome.runtime.MessageSender>({
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<chrome.runtime.MessageSender>({
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 pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
const sender = mock<chrome.runtime.MessageSender>({ tab: { id: 1 } });
beforeEach(async () => {
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
);
await flushPromises();
});
it("ignores the store request if the sender is not within the website origins set", () => {
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "username",
password: "password",
newPassword: "newPassword",
},
mock<chrome.runtime.MessageSender>({ tab: { id: 2 } }),
);
expect(
overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id),
).toBeUndefined();
});
it("ignores the store request if the form submission does not include a username, password, or newPassword", () => {
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "",
password: "",
newPassword: "",
},
sender,
);
expect(
overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id),
).toBeUndefined();
});
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("overrides previously stored modified login cipher form data with a subsequent store request", async () => {
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "oldUsername",
password: "oldPassword",
newPassword: "oldNewPassword",
},
sender,
);
await flushPromises();
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "username",
password: "",
newPassword: "",
},
sender,
);
await flushPromises();
expect(
overlayNotificationsBackground["modifyLoginCipherFormData"].get(sender.tab.id),
).toEqual({
uri: "example.com",
username: "username",
password: "oldPassword",
newPassword: "oldNewPassword",
});
});
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<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
const tab = mock<chrome.tabs.Tab>({ 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<chrome.webRequest.WebRequestDetails>({
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<chrome.runtime.MessageSender>;
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
let notificationChangedPasswordSpy: jest.SpyInstance;
let notificationAddLoginSpy: jest.SpyInstance;
beforeEach(async () => {
sender = mock<chrome.runtime.MessageSender>({
tab: { id: 1 },
url: "https://example.com",
});
notificationChangedPasswordSpy = jest.spyOn(
notificationBackground,
"triggerChangedPasswordNotification",
);
notificationAddLoginSpy = jest.spyOn(notificationBackground, "triggerAddLoginNotification");
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<chrome.webRequest.WebRequestDetails>({
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<chrome.tabs.Tab>({ id: -1 });
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
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<chrome.webRequest.WebRequestDetails>({
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<chrome.webRequest.WebResponseDetails>({
url: sender.url,
tabId: sender.tab.id,
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<chrome.webRequest.WebRequestDetails>({
url: sender.url,
tabId: sender.tab.id,
method: "POST",
requestId,
}),
);
await flushPromises();
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.WebResponseDetails>({
url: sender.url,
tabId: sender.tab.id,
requestId,
}),
);
expect(notificationChangedPasswordSpy).not.toHaveBeenCalled();
expect(notificationAddLoginSpy).not.toHaveBeenCalled();
});
it("clears the notification fallback timeout if the request is completed with an invalid status code", async () => {
const clearFallbackSpy = jest.spyOn(
overlayNotificationsBackground as any,
"clearNotificationFallbackTimeout",
);
const requestId = "123345";
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
url: sender.url,
tabId: sender.tab.id,
method: "POST",
requestId,
}),
);
await flushPromises();
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.WebResponseDetails>({
url: sender.url,
tabId: sender.tab.id,
statusCode: 404,
requestId,
}),
);
await flushPromises();
expect(clearFallbackSpy).toHaveBeenCalled();
});
});
describe("web requests that trigger notifications", () => {
const requestId = "123345";
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
beforeEach(async () => {
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
);
await flushPromises();
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "username",
password: "password",
newPassword: "newPassword",
},
sender,
);
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
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<chrome.tabs.Tab>({
status: "loading",
url: sender.url,
}),
);
});
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.WebResponseDetails>({
url: sender.url,
tabId: sender.tab.id,
requestId,
}),
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebNavigationOnCompletedEvent(
mock<chrome.webNavigation.WebNavigationFramedCallbackDetails>({
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<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.WebResponseDetails>({
url: sender.url,
tabId: sender.tab.id,
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<chrome.tabs.Tab>({ id: 4 });
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
);
await flushPromises();
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "",
password: "password",
newPassword: "newPassword",
},
sender,
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementation((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
url: sender.url,
tabId: sender.tab.id,
method: "POST",
requestId,
}),
);
await flushPromises();
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
url: "https://example.com/redirect",
tabId: sender.tab.id,
method: "GET",
requestId,
}),
);
await flushPromises();
expect(notificationChangedPasswordSpy).toHaveBeenCalled();
});
});
});
describe("tab listeners", () => {
let sender: MockProxy<chrome.runtime.MessageSender>;
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
const requestId = "123345";
beforeEach(async () => {
sender = mock<chrome.runtime.MessageSender>({
tab: { id: 1 },
url: "https://example.com",
});
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
);
await flushPromises();
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
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<chrome.tabs.TabRemoveInfo>());
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0);
});
describe("tab onUpdated", () => {
it("skips clearing the website origins if the changeInfo does not contain a `loading` status", () => {
triggerTabOnUpdatedEvent(
sender.tab.id,
mock<chrome.tabs.TabChangeInfo>({ status: "complete" }),
mock<chrome.tabs.Tab>({ status: "complete" }),
);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1);
});
it("skips clearing the website origins if the changeInfo does not contain a url", () => {
triggerTabOnUpdatedEvent(
sender.tab.id,
mock<chrome.tabs.TabChangeInfo>({ status: "loading", url: "" }),
mock<chrome.tabs.Tab>({ status: "loading" }),
);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1);
});
it("skips clearing the website origins if the tab does not contain known website origins", () => {
triggerTabOnUpdatedEvent(
199,
mock<chrome.tabs.TabChangeInfo>({ status: "loading", url: "https://example.com" }),
mock<chrome.tabs.Tab>({ status: "loading", id: 199 }),
);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1);
});
it("skips clearing the website origins if the changeInfo's url is present as part of the know website origin match patterns", () => {
triggerTabOnUpdatedEvent(
sender.tab.id,
mock<chrome.tabs.TabChangeInfo>({
status: "loading",
url: "https://subdomain.example.com",
}),
mock<chrome.tabs.Tab>({ status: "loading" }),
);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(1);
});
it("clears all associated data with a tab that is entering a `loading` state", () => {
triggerTabOnUpdatedEvent(
sender.tab.id,
mock<chrome.tabs.TabChangeInfo>({ status: "loading" }),
mock<chrome.tabs.Tab>({ status: "loading" }),
);
expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0);
});
});
});
});