1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-22 11:45:59 +01:00

[PM-5950] Fix Context Menu Update Race Condition and Refactor Implementation (#7724)

* [PM-5949] Refactor typing information for notification bar

* [PM-5949] Fix jest tests for overlay background

* [PM-5949] Removing unnused typing data

* [PM-5949] Fixing lint error

* [PM-5949] Adding jest tests for convertAddLoginQueueMessageToCipherView method

* [PM-5949] Fixing jest test for overlay

* [PM-5950] Fix Context Menu Update Race Condition and Refactor Implementation

* [PM-5950] Adding jest test for cipherContextMenu.update method

* [PM-5950] Adding documentation for method within MainContextMenuHandler

* [PM-5950] Adding jest tests for the mainContextMenuHandler

* [PM-5950] Removing unnecessary return value from MainContextMenuHandler.create method

* [PM-5950] Fixing unawaited context menu promise
This commit is contained in:
Cesar Gonzalez 2024-02-21 11:02:15 -06:00 committed by GitHub
parent 7629652a47
commit ba4e59783b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 257 additions and 145 deletions

View File

@ -0,0 +1,5 @@
type InitContextMenuItems = Omit<chrome.contextMenus.CreateProperties, "contexts"> & {
checkPremiumAccess?: boolean;
};
export { InitContextMenuItems };

View File

@ -18,6 +18,7 @@ describe("CipherContextMenuHandler", () => {
beforeEach(() => {
mainContextMenuHandler = mock();
mainContextMenuHandler.initRunning = false;
authService = mock();
cipherService = mock();
@ -29,6 +30,14 @@ describe("CipherContextMenuHandler", () => {
afterEach(() => jest.resetAllMocks());
describe("update", () => {
it("skips updating if the init process for the mainContextMenuHandler is running", async () => {
mainContextMenuHandler.initRunning = true;
await sut.update("https://test.com");
expect(authService.getAuthStatus).not.toHaveBeenCalled();
});
it("locked, updates for no access", async () => {
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);

View File

@ -146,6 +146,10 @@ export class CipherContextMenuHandler {
}
async update(url: string) {
if (this.mainContextMenuHandler.initRunning) {
return;
}
const authStatus = await this.authService.getAuthStatus();
await MainContextMenuHandler.removeAll();
if (authStatus !== AuthenticationStatus.Unlocked) {

View File

@ -7,6 +7,7 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service";
import { NOOP_COMMAND_SUFFIX } from "../constants";
import { MainContextMenuHandler } from "./main-context-menu-handler";
@ -39,6 +40,7 @@ describe("context-menu", () => {
return props.id;
});
i18nService.t.mockImplementation((key) => key);
sut = new MainContextMenuHandler(stateService, i18nService, logService);
});
@ -136,4 +138,75 @@ describe("context-menu", () => {
expect(createSpy).toHaveBeenCalledTimes(6);
});
});
describe("creating noAccess context menu items", () => {
let loadOptionsSpy: jest.SpyInstance;
beforeEach(() => {
loadOptionsSpy = jest.spyOn(sut, "loadOptions").mockResolvedValue();
});
it("Loads context menu items that ask the user to unlock their vault if they are authed", async () => {
stateService.getIsAuthenticated.mockResolvedValue(true);
await sut.noAccess();
expect(loadOptionsSpy).toHaveBeenCalledWith("unlockVaultMenu", NOOP_COMMAND_SUFFIX);
});
it("Loads context menu items that ask the user to login to their vault if they are not authed", async () => {
stateService.getIsAuthenticated.mockResolvedValue(false);
await sut.noAccess();
expect(loadOptionsSpy).toHaveBeenCalledWith("loginToVaultMenu", NOOP_COMMAND_SUFFIX);
});
});
describe("creating noCards context menu items", () => {
it("Loads a noCards context menu item and an addCardMenu context item", async () => {
const noCardsContextMenuItems = sut["noCardsContextMenuItems"];
await sut.noCards();
expect(createSpy).toHaveBeenCalledTimes(3);
expect(createSpy).toHaveBeenCalledWith(noCardsContextMenuItems[0], expect.any(Function));
expect(createSpy).toHaveBeenCalledWith(noCardsContextMenuItems[1], expect.any(Function));
expect(createSpy).toHaveBeenCalledWith(noCardsContextMenuItems[2], expect.any(Function));
});
});
describe("creating noIdentities context menu items", () => {
it("Loads a noIdentities context menu item and an addIdentityMenu context item", async () => {
const noIdentitiesContextMenuItems = sut["noIdentitiesContextMenuItems"];
await sut.noIdentities();
expect(createSpy).toHaveBeenCalledTimes(3);
expect(createSpy).toHaveBeenCalledWith(noIdentitiesContextMenuItems[0], expect.any(Function));
expect(createSpy).toHaveBeenCalledWith(noIdentitiesContextMenuItems[1], expect.any(Function));
expect(createSpy).toHaveBeenCalledWith(noIdentitiesContextMenuItems[2], expect.any(Function));
});
});
describe("creating noLogins context menu items", () => {
it("Loads a noLogins context menu item and an addLoginMenu context item", async () => {
const noLoginsContextMenuItems = sut["noLoginsContextMenuItems"];
await sut.noLogins();
expect(createSpy).toHaveBeenCalledTimes(5);
expect(createSpy).toHaveBeenCalledWith(noLoginsContextMenuItems[0], expect.any(Function));
expect(createSpy).toHaveBeenCalledWith(noLoginsContextMenuItems[1], expect.any(Function));
expect(createSpy).toHaveBeenCalledWith(
{
enabled: false,
id: "autofill_NOTICE",
parentId: "autofill",
title: "noMatchingLogins",
type: "normal",
},
expect.any(Function),
);
});
});
});

View File

@ -38,32 +38,127 @@ import {
SEPARATOR_ID,
} from "../constants";
export class MainContextMenuHandler {
private initRunning = false;
import { InitContextMenuItems } from "./abstractions/main-context-menu-handler";
create: (options: chrome.contextMenus.CreateProperties) => Promise<void>;
export class MainContextMenuHandler {
initRunning = false;
private initContextMenuItems: InitContextMenuItems[] = [
{
id: ROOT_ID,
title: "Bitwarden",
},
{
id: AUTOFILL_ID,
parentId: ROOT_ID,
title: this.i18nService.t("autoFillLogin"),
},
{
id: COPY_USERNAME_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyUsername"),
},
{
id: COPY_PASSWORD_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyPassword"),
},
{
id: COPY_VERIFICATION_CODE_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyVerificationCode"),
checkPremiumAccess: true,
},
{
id: SEPARATOR_ID + 1,
type: "separator",
parentId: ROOT_ID,
},
{
id: AUTOFILL_IDENTITY_ID,
parentId: ROOT_ID,
title: this.i18nService.t("autoFillIdentity"),
},
{
id: AUTOFILL_CARD_ID,
parentId: ROOT_ID,
title: this.i18nService.t("autoFillCard"),
},
{
id: SEPARATOR_ID + 2,
type: "separator",
parentId: ROOT_ID,
},
{
id: GENERATE_PASSWORD_ID,
parentId: ROOT_ID,
title: this.i18nService.t("generatePasswordCopied"),
},
{
id: COPY_IDENTIFIER_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyElementIdentifier"),
},
];
private noCardsContextMenuItems: chrome.contextMenus.CreateProperties[] = [
{
id: `${AUTOFILL_CARD_ID}_NOTICE`,
enabled: false,
parentId: AUTOFILL_CARD_ID,
title: this.i18nService.t("noCards"),
type: "normal",
},
{
id: `${AUTOFILL_CARD_ID}_${SEPARATOR_ID}`,
parentId: AUTOFILL_CARD_ID,
type: "separator",
},
{
id: `${AUTOFILL_CARD_ID}_${CREATE_CARD_ID}`,
parentId: AUTOFILL_CARD_ID,
title: this.i18nService.t("addCardMenu"),
type: "normal",
},
];
private noIdentitiesContextMenuItems: chrome.contextMenus.CreateProperties[] = [
{
id: `${AUTOFILL_IDENTITY_ID}_NOTICE`,
enabled: false,
parentId: AUTOFILL_IDENTITY_ID,
title: this.i18nService.t("noIdentities"),
type: "normal",
},
{
id: `${AUTOFILL_IDENTITY_ID}_${SEPARATOR_ID}`,
parentId: AUTOFILL_IDENTITY_ID,
type: "separator",
},
{
id: `${AUTOFILL_IDENTITY_ID}_${CREATE_IDENTITY_ID}`,
parentId: AUTOFILL_IDENTITY_ID,
title: this.i18nService.t("addIdentityMenu"),
type: "normal",
},
];
private noLoginsContextMenuItems: chrome.contextMenus.CreateProperties[] = [
{
id: `${AUTOFILL_ID}_NOTICE`,
enabled: false,
parentId: AUTOFILL_ID,
title: this.i18nService.t("noMatchingLogins"),
type: "normal",
},
{
id: `${AUTOFILL_ID}_${SEPARATOR_ID}1`,
parentId: AUTOFILL_ID,
type: "separator",
},
];
constructor(
private stateService: BrowserStateService,
private i18nService: I18nService,
private logService: LogService,
) {
if (chrome.contextMenus) {
this.create = (options) => {
return new Promise<void>((resolve, reject) => {
chrome.contextMenus.create(options, () => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
return;
}
resolve();
});
});
};
} else {
this.create = (_options) => Promise.resolve();
}
}
) {}
static async mv3Create(cachedServices: CachedServices) {
const stateFactory = new StateFactory(GlobalState, Account);
@ -110,76 +205,14 @@ export class MainContextMenuHandler {
this.initRunning = true;
try {
const create = async (options: Omit<chrome.contextMenus.CreateProperties, "contexts">) => {
await this.create({ ...options, contexts: ["all"] });
};
for (const options of this.initContextMenuItems) {
if (options.checkPremiumAccess && !(await this.stateService.getCanAccessPremium())) {
continue;
}
await create({
id: ROOT_ID,
title: "Bitwarden",
});
await create({
id: AUTOFILL_ID,
parentId: ROOT_ID,
title: this.i18nService.t("autoFillLogin"),
});
await create({
id: COPY_USERNAME_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyUsername"),
});
await create({
id: COPY_PASSWORD_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyPassword"),
});
if (await this.stateService.getCanAccessPremium()) {
await create({
id: COPY_VERIFICATION_CODE_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyVerificationCode"),
});
delete options.checkPremiumAccess;
await MainContextMenuHandler.create({ ...options, contexts: ["all"] });
}
await create({
id: SEPARATOR_ID + 1,
type: "separator",
parentId: ROOT_ID,
});
await create({
id: AUTOFILL_IDENTITY_ID,
parentId: ROOT_ID,
title: this.i18nService.t("autoFillIdentity"),
});
await create({
id: AUTOFILL_CARD_ID,
parentId: ROOT_ID,
title: this.i18nService.t("autoFillCard"),
});
await create({
id: SEPARATOR_ID + 2,
type: "separator",
parentId: ROOT_ID,
});
await create({
id: GENERATE_PASSWORD_ID,
parentId: ROOT_ID,
title: this.i18nService.t("generatePasswordCopied"),
});
await create({
id: COPY_IDENTIFIER_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyElementIdentifier"),
});
} catch (error) {
this.logService.warning(error.message);
} finally {
@ -188,6 +221,26 @@ export class MainContextMenuHandler {
return true;
}
/**
* Creates a context menu item
*
* @param options - the options for the context menu item
*/
private static create = async (options: chrome.contextMenus.CreateProperties) => {
if (!chrome.contextMenus) {
return;
}
return new Promise<void>((resolve, reject) => {
chrome.contextMenus.create(options, () => {
if (chrome.runtime.lastError) {
return reject(chrome.runtime.lastError);
}
resolve();
});
});
};
static async removeAll() {
return new Promise<void>((resolve, reject) => {
chrome.contextMenus.removeAll(() => {
@ -221,7 +274,7 @@ export class MainContextMenuHandler {
const createChildItem = async (parentId: string) => {
const menuItemId = `${parentId}_${optionId}`;
return await this.create({
return await MainContextMenuHandler.create({
type: "normal",
id: menuItemId,
parentId,
@ -272,74 +325,42 @@ export class MainContextMenuHandler {
async noAccess() {
if (await this.init()) {
const authed = await this.stateService.getIsAuthenticated();
await this.loadOptions(
this.loadOptions(
this.i18nService.t(authed ? "unlockVaultMenu" : "loginToVaultMenu"),
NOOP_COMMAND_SUFFIX,
);
).catch((error) => this.logService.warning(error.message));
}
}
async noCards() {
await this.create({
id: `${AUTOFILL_CARD_ID}_NOTICE`,
enabled: false,
parentId: AUTOFILL_CARD_ID,
title: this.i18nService.t("noCards"),
type: "normal",
});
await this.create({
id: `${AUTOFILL_CARD_ID}_${SEPARATOR_ID}`,
parentId: AUTOFILL_CARD_ID,
type: "separator",
});
await this.create({
id: `${AUTOFILL_CARD_ID}_${CREATE_CARD_ID}`,
parentId: AUTOFILL_CARD_ID,
title: this.i18nService.t("addCardMenu"),
type: "normal",
});
try {
for (const option of this.noCardsContextMenuItems) {
await MainContextMenuHandler.create(option);
}
} catch (error) {
this.logService.warning(error.message);
}
}
async noIdentities() {
await this.create({
id: `${AUTOFILL_IDENTITY_ID}_NOTICE`,
enabled: false,
parentId: AUTOFILL_IDENTITY_ID,
title: this.i18nService.t("noIdentities"),
type: "normal",
});
await this.create({
id: `${AUTOFILL_IDENTITY_ID}_${SEPARATOR_ID}`,
parentId: AUTOFILL_IDENTITY_ID,
type: "separator",
});
await this.create({
id: `${AUTOFILL_IDENTITY_ID}_${CREATE_IDENTITY_ID}`,
parentId: AUTOFILL_IDENTITY_ID,
title: this.i18nService.t("addIdentityMenu"),
type: "normal",
});
try {
for (const option of this.noIdentitiesContextMenuItems) {
await MainContextMenuHandler.create(option);
}
} catch (error) {
this.logService.warning(error.message);
}
}
async noLogins() {
await this.create({
id: `${AUTOFILL_ID}_NOTICE`,
enabled: false,
parentId: AUTOFILL_ID,
title: this.i18nService.t("noMatchingLogins"),
type: "normal",
});
try {
for (const option of this.noLoginsContextMenuItems) {
await MainContextMenuHandler.create(option);
}
await this.create({
id: `${AUTOFILL_ID}_${SEPARATOR_ID}` + 1,
parentId: AUTOFILL_ID,
type: "separator",
});
await this.loadOptions(this.i18nService.t("addLoginMenu"), CREATE_LOGIN_ID);
await this.loadOptions(this.i18nService.t("addLoginMenu"), CREATE_LOGIN_ID);
} catch (error) {
this.logService.warning(error.message);
}
}
}