mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 07:43:35 +00:00
Merge branch 'main' into task/DEVOPS-1683
This commit is contained in:
2
.github/workflows/version-bump.yml
vendored
2
.github/workflows/version-bump.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
|||||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||||
with:
|
with:
|
||||||
repository: bitwarden/clients
|
repository: bitwarden/clients
|
||||||
ref: master
|
ref: main
|
||||||
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||||
|
|
||||||
- name: Import GPG key
|
- name: Import GPG key
|
||||||
|
|||||||
@@ -673,7 +673,7 @@ class OverlayBackground implements OverlayBackgroundInterface {
|
|||||||
*/
|
*/
|
||||||
private setupExtensionMessageListeners() {
|
private setupExtensionMessageListeners() {
|
||||||
BrowserApi.messageListener("overlay.background", this.handleExtensionMessage);
|
BrowserApi.messageListener("overlay.background", this.handleExtensionMessage);
|
||||||
chrome.runtime.onConnect.addListener(this.handlePortOnConnect);
|
BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
settingsServiceFactory,
|
settingsServiceFactory,
|
||||||
SettingsServiceInitOptions,
|
SettingsServiceInitOptions,
|
||||||
} from "../../../background/service-factories/settings-service.factory";
|
} from "../../../background/service-factories/settings-service.factory";
|
||||||
|
import {
|
||||||
|
configServiceFactory,
|
||||||
|
ConfigServiceInitOptions,
|
||||||
|
} from "../../../platform/background/service-factories/config-service.factory";
|
||||||
import {
|
import {
|
||||||
CachedServices,
|
CachedServices,
|
||||||
factory,
|
factory,
|
||||||
@@ -43,7 +47,8 @@ export type AutoFillServiceInitOptions = AutoFillServiceOptions &
|
|||||||
EventCollectionServiceInitOptions &
|
EventCollectionServiceInitOptions &
|
||||||
LogServiceInitOptions &
|
LogServiceInitOptions &
|
||||||
SettingsServiceInitOptions &
|
SettingsServiceInitOptions &
|
||||||
UserVerificationServiceInitOptions;
|
UserVerificationServiceInitOptions &
|
||||||
|
ConfigServiceInitOptions;
|
||||||
|
|
||||||
export function autofillServiceFactory(
|
export function autofillServiceFactory(
|
||||||
cache: { autofillService?: AbstractAutoFillService } & CachedServices,
|
cache: { autofillService?: AbstractAutoFillService } & CachedServices,
|
||||||
@@ -62,6 +67,7 @@ export function autofillServiceFactory(
|
|||||||
await logServiceFactory(cache, opts),
|
await logServiceFactory(cache, opts),
|
||||||
await settingsServiceFactory(cache, opts),
|
await settingsServiceFactory(cache, opts),
|
||||||
await userVerificationServiceFactory(cache, opts),
|
await userVerificationServiceFactory(cache, opts),
|
||||||
|
await configServiceFactory(cache, opts),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import OverlayBackground from "./overlay.background";
|
|||||||
import TabsBackground from "./tabs.background";
|
import TabsBackground from "./tabs.background";
|
||||||
|
|
||||||
describe("TabsBackground", () => {
|
describe("TabsBackground", () => {
|
||||||
let tabsBackgorund: TabsBackground;
|
let tabsBackground: TabsBackground;
|
||||||
const mainBackground = mock<MainBackground>({
|
const mainBackground = mock<MainBackground>({
|
||||||
messagingService: {
|
messagingService: {
|
||||||
send: jest.fn(),
|
send: jest.fn(),
|
||||||
@@ -25,7 +25,7 @@ describe("TabsBackground", () => {
|
|||||||
const overlayBackground = mock<OverlayBackground>();
|
const overlayBackground = mock<OverlayBackground>();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tabsBackgorund = new TabsBackground(mainBackground, notificationBackground, overlayBackground);
|
tabsBackground = new TabsBackground(mainBackground, notificationBackground, overlayBackground);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -35,11 +35,11 @@ describe("TabsBackground", () => {
|
|||||||
describe("init", () => {
|
describe("init", () => {
|
||||||
it("sets up a window on focusChanged listener", () => {
|
it("sets up a window on focusChanged listener", () => {
|
||||||
const handleWindowOnFocusChangedSpy = jest.spyOn(
|
const handleWindowOnFocusChangedSpy = jest.spyOn(
|
||||||
tabsBackgorund as any,
|
tabsBackground as any,
|
||||||
"handleWindowOnFocusChanged",
|
"handleWindowOnFocusChanged",
|
||||||
);
|
);
|
||||||
|
|
||||||
tabsBackgorund.init();
|
tabsBackground.init();
|
||||||
|
|
||||||
expect(chrome.windows.onFocusChanged.addListener).toHaveBeenCalledWith(
|
expect(chrome.windows.onFocusChanged.addListener).toHaveBeenCalledWith(
|
||||||
handleWindowOnFocusChangedSpy,
|
handleWindowOnFocusChangedSpy,
|
||||||
@@ -49,7 +49,7 @@ describe("TabsBackground", () => {
|
|||||||
|
|
||||||
describe("tab event listeners", () => {
|
describe("tab event listeners", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tabsBackgorund.init();
|
tabsBackground["setupTabEventListeners"]();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("window onFocusChanged event", () => {
|
describe("window onFocusChanged event", () => {
|
||||||
@@ -64,7 +64,7 @@ describe("TabsBackground", () => {
|
|||||||
triggerWindowOnFocusedChangedEvent(10);
|
triggerWindowOnFocusedChangedEvent(10);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(tabsBackgorund["focusedWindowId"]).toBe(10);
|
expect(tabsBackground["focusedWindowId"]).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the current tab data", async () => {
|
it("updates the current tab data", async () => {
|
||||||
@@ -144,7 +144,7 @@ describe("TabsBackground", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mainBackground.onUpdatedRan = false;
|
mainBackground.onUpdatedRan = false;
|
||||||
tabsBackgorund["focusedWindowId"] = focusedWindowId;
|
tabsBackground["focusedWindowId"] = focusedWindowId;
|
||||||
tab = mock<chrome.tabs.Tab>({
|
tab = mock<chrome.tabs.Tab>({
|
||||||
windowId: focusedWindowId,
|
windowId: focusedWindowId,
|
||||||
active: true,
|
active: true,
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ export default class TabsBackground {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateCurrentTabData();
|
||||||
|
this.setupTabEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up the tab and window event listeners.
|
||||||
|
*/
|
||||||
|
private setupTabEventListeners() {
|
||||||
chrome.windows.onFocusChanged.addListener(this.handleWindowOnFocusChanged);
|
chrome.windows.onFocusChanged.addListener(this.handleWindowOnFocusChanged);
|
||||||
chrome.tabs.onActivated.addListener(this.handleTabOnActivated);
|
chrome.tabs.onActivated.addListener(this.handleTabOnActivated);
|
||||||
chrome.tabs.onReplaced.addListener(this.handleTabOnReplaced);
|
chrome.tabs.onReplaced.addListener(this.handleTabOnReplaced);
|
||||||
@@ -33,7 +41,7 @@ export default class TabsBackground {
|
|||||||
* @param windowId - The ID of the window that was focused.
|
* @param windowId - The ID of the window that was focused.
|
||||||
*/
|
*/
|
||||||
private handleWindowOnFocusChanged = async (windowId: number) => {
|
private handleWindowOnFocusChanged = async (windowId: number) => {
|
||||||
if (!windowId) {
|
if (windowId == null || windowId < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +124,10 @@ export default class TabsBackground {
|
|||||||
* for the current tab. Also updates the overlay ciphers.
|
* for the current tab. Also updates the overlay ciphers.
|
||||||
*/
|
*/
|
||||||
private updateCurrentTabData = async () => {
|
private updateCurrentTabData = async () => {
|
||||||
await this.main.refreshBadge();
|
await Promise.all([
|
||||||
await this.main.refreshMenu();
|
this.main.refreshBadge(),
|
||||||
await this.overlayBackground.updateOverlayCiphers();
|
this.main.refreshMenu(),
|
||||||
|
this.overlayBackground.updateOverlayCiphers(),
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type AutofillExtensionMessage = {
|
|||||||
direction?: "previous" | "next";
|
direction?: "previous" | "next";
|
||||||
isOpeningFullOverlay?: boolean;
|
isOpeningFullOverlay?: boolean;
|
||||||
forceCloseOverlay?: boolean;
|
forceCloseOverlay?: boolean;
|
||||||
|
autofillOverlayVisibility?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,10 +35,12 @@ type AutofillExtensionMessageHandlers = {
|
|||||||
updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void;
|
updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void;
|
||||||
bgUnlockPopoutOpened: () => void;
|
bgUnlockPopoutOpened: () => void;
|
||||||
bgVaultItemRepromptPopoutOpened: () => void;
|
bgVaultItemRepromptPopoutOpened: () => void;
|
||||||
|
updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AutofillInit {
|
interface AutofillInit {
|
||||||
init(): void;
|
init(): void;
|
||||||
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { AutofillExtensionMessage, AutofillExtensionMessageHandlers, AutofillInit };
|
export { AutofillExtensionMessage, AutofillExtensionMessageHandlers, AutofillInit };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { flushPromises, sendExtensionRuntimeMessage } from "../jest/testing-util
|
|||||||
import AutofillPageDetails from "../models/autofill-page-details";
|
import AutofillPageDetails from "../models/autofill-page-details";
|
||||||
import AutofillScript from "../models/autofill-script";
|
import AutofillScript from "../models/autofill-script";
|
||||||
import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
|
import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
|
||||||
import { RedirectFocusDirection } from "../utils/autofill-overlay.enum";
|
import { AutofillOverlayVisibility, RedirectFocusDirection } from "../utils/autofill-overlay.enum";
|
||||||
|
|
||||||
import { AutofillExtensionMessage } from "./abstractions/autofill-init";
|
import { AutofillExtensionMessage } from "./abstractions/autofill-init";
|
||||||
import AutofillInit from "./autofill-init";
|
import AutofillInit from "./autofill-init";
|
||||||
@@ -16,6 +16,11 @@ describe("AutofillInit", () => {
|
|||||||
const autofillOverlayContentService = mock<AutofillOverlayContentService>();
|
const autofillOverlayContentService = mock<AutofillOverlayContentService>();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
chrome.runtime.connect = jest.fn().mockReturnValue({
|
||||||
|
onDisconnect: {
|
||||||
|
addListener: jest.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
autofillInit = new AutofillInit(autofillOverlayContentService);
|
autofillInit = new AutofillInit(autofillOverlayContentService);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -477,6 +482,57 @@ describe("AutofillInit", () => {
|
|||||||
expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled();
|
expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("updateAutofillOverlayVisibility", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
autofillInit["autofillOverlayContentService"].autofillOverlayVisibility =
|
||||||
|
AutofillOverlayVisibility.OnButtonClick;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => {
|
||||||
|
sendExtensionRuntimeMessage({
|
||||||
|
command: "updateAutofillOverlayVisibility",
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual(
|
||||||
|
AutofillOverlayVisibility.OnButtonClick,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the overlay visibility value", () => {
|
||||||
|
const message = {
|
||||||
|
command: "updateAutofillOverlayVisibility",
|
||||||
|
data: {
|
||||||
|
autofillOverlayVisibility: AutofillOverlayVisibility.Off,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
sendExtensionRuntimeMessage(message);
|
||||||
|
|
||||||
|
expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual(
|
||||||
|
message.data.autofillOverlayVisibility,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("destroy", () => {
|
||||||
|
it("removes the extension message listeners", () => {
|
||||||
|
autofillInit.destroy();
|
||||||
|
|
||||||
|
expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith(
|
||||||
|
autofillInit["handleExtensionMessage"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys the collectAutofillContentService", () => {
|
||||||
|
jest.spyOn(autofillInit["collectAutofillContentService"], "destroy");
|
||||||
|
|
||||||
|
autofillInit.destroy();
|
||||||
|
|
||||||
|
expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class AutofillInit implements AutofillInitInterface {
|
|||||||
updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message),
|
updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message),
|
||||||
bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(),
|
bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(),
|
||||||
bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(),
|
bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(),
|
||||||
|
updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,6 +215,19 @@ class AutofillInit implements AutofillInitInterface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the autofill overlay visibility.
|
||||||
|
*
|
||||||
|
* @param data - Contains the autoFillOverlayVisibility value
|
||||||
|
*/
|
||||||
|
private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) {
|
||||||
|
if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up the extension message listeners for the content script.
|
* Sets up the extension message listeners for the content script.
|
||||||
*/
|
*/
|
||||||
@@ -247,6 +261,16 @@ class AutofillInit implements AutofillInitInterface {
|
|||||||
Promise.resolve(messageResponse).then((response) => sendResponse(response));
|
Promise.resolve(messageResponse).then((response) => sendResponse(response));
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles destroying the autofill init content script. Removes all
|
||||||
|
* listeners, timeouts, and object instances to prevent memory leaks.
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
chrome.runtime.onMessage.removeListener(this.handleExtensionMessage);
|
||||||
|
this.collectAutofillContentService.destroy();
|
||||||
|
this.autofillOverlayContentService?.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AutofillInit;
|
export default AutofillInit;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getFromLocalStorage, setupExtensionDisconnectAction } from "../utils";
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", loadAutofiller);
|
document.addEventListener("DOMContentLoaded", loadAutofiller);
|
||||||
} else {
|
} else {
|
||||||
@@ -8,27 +10,30 @@ function loadAutofiller() {
|
|||||||
let pageHref: string = null;
|
let pageHref: string = null;
|
||||||
let filledThisHref = false;
|
let filledThisHref = false;
|
||||||
let delayFillTimeout: number;
|
let delayFillTimeout: number;
|
||||||
|
let doFillInterval: NodeJS.Timeout;
|
||||||
const activeUserIdKey = "activeUserId";
|
const handleExtensionDisconnect = () => {
|
||||||
let activeUserId: string;
|
clearDoFillInterval();
|
||||||
|
clearDelayFillTimeout();
|
||||||
chrome.storage.local.get(activeUserIdKey, (obj: any) => {
|
};
|
||||||
if (obj == null || obj[activeUserIdKey] == null) {
|
const handleExtensionMessage = (message: any) => {
|
||||||
return;
|
if (message.command === "fillForm" && pageHref === message.url) {
|
||||||
}
|
|
||||||
activeUserId = obj[activeUserIdKey];
|
|
||||||
});
|
|
||||||
|
|
||||||
chrome.storage.local.get(activeUserId, (obj: any) => {
|
|
||||||
if (obj?.[activeUserId]?.settings?.enableAutoFillOnPageLoad === true) {
|
|
||||||
setInterval(() => doFillIfNeeded(), 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
||||||
if (msg.command === "fillForm" && pageHref === msg.url) {
|
|
||||||
filledThisHref = true;
|
filledThisHref = true;
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
setupExtensionEventListeners();
|
||||||
|
triggerUserFillOnLoad();
|
||||||
|
|
||||||
|
async function triggerUserFillOnLoad() {
|
||||||
|
const activeUserIdKey = "activeUserId";
|
||||||
|
const userKeyStorage = await getFromLocalStorage(activeUserIdKey);
|
||||||
|
const activeUserId = userKeyStorage[activeUserIdKey];
|
||||||
|
const activeUserStorage = await getFromLocalStorage(activeUserId);
|
||||||
|
if (activeUserStorage?.[activeUserId]?.settings?.enableAutoFillOnPageLoad === true) {
|
||||||
|
clearDoFillInterval();
|
||||||
|
doFillInterval = setInterval(() => doFillIfNeeded(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function doFillIfNeeded(force = false) {
|
function doFillIfNeeded(force = false) {
|
||||||
if (force || pageHref !== window.location.href) {
|
if (force || pageHref !== window.location.href) {
|
||||||
@@ -36,9 +41,7 @@ function loadAutofiller() {
|
|||||||
// Some websites are slow and rendering all page content. Try to fill again later
|
// Some websites are slow and rendering all page content. Try to fill again later
|
||||||
// if we haven't already.
|
// if we haven't already.
|
||||||
filledThisHref = false;
|
filledThisHref = false;
|
||||||
if (delayFillTimeout != null) {
|
clearDelayFillTimeout();
|
||||||
window.clearTimeout(delayFillTimeout);
|
|
||||||
}
|
|
||||||
delayFillTimeout = window.setTimeout(() => {
|
delayFillTimeout = window.setTimeout(() => {
|
||||||
if (!filledThisHref) {
|
if (!filledThisHref) {
|
||||||
doFillIfNeeded(true);
|
doFillIfNeeded(true);
|
||||||
@@ -55,4 +58,21 @@ function loadAutofiller() {
|
|||||||
chrome.runtime.sendMessage(msg);
|
chrome.runtime.sendMessage(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearDoFillInterval() {
|
||||||
|
if (doFillInterval) {
|
||||||
|
window.clearInterval(doFillInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDelayFillTimeout() {
|
||||||
|
if (delayFillTimeout) {
|
||||||
|
window.clearTimeout(delayFillTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupExtensionEventListeners() {
|
||||||
|
setupExtensionDisconnectAction(handleExtensionDisconnect);
|
||||||
|
chrome.runtime.onMessage.addListener(handleExtensionMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
|
import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
|
||||||
|
import { setupAutofillInitDisconnectAction } from "../utils";
|
||||||
|
|
||||||
import AutofillInit from "./autofill-init";
|
import AutofillInit from "./autofill-init";
|
||||||
|
|
||||||
@@ -6,6 +7,8 @@ import AutofillInit from "./autofill-init";
|
|||||||
if (!windowContext.bitwardenAutofillInit) {
|
if (!windowContext.bitwardenAutofillInit) {
|
||||||
const autofillOverlayContentService = new AutofillOverlayContentService();
|
const autofillOverlayContentService = new AutofillOverlayContentService();
|
||||||
windowContext.bitwardenAutofillInit = new AutofillInit(autofillOverlayContentService);
|
windowContext.bitwardenAutofillInit = new AutofillInit(autofillOverlayContentService);
|
||||||
|
setupAutofillInitDisconnectAction(windowContext);
|
||||||
|
|
||||||
windowContext.bitwardenAutofillInit.init();
|
windowContext.bitwardenAutofillInit.init();
|
||||||
}
|
}
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import { setupAutofillInitDisconnectAction } from "../utils";
|
||||||
|
|
||||||
import AutofillInit from "./autofill-init";
|
import AutofillInit from "./autofill-init";
|
||||||
|
|
||||||
(function (windowContext) {
|
(function (windowContext) {
|
||||||
if (!windowContext.bitwardenAutofillInit) {
|
if (!windowContext.bitwardenAutofillInit) {
|
||||||
windowContext.bitwardenAutofillInit = new AutofillInit();
|
windowContext.bitwardenAutofillInit = new AutofillInit();
|
||||||
|
setupAutofillInitDisconnectAction(windowContext);
|
||||||
|
|
||||||
windowContext.bitwardenAutofillInit.init();
|
windowContext.bitwardenAutofillInit.init();
|
||||||
}
|
}
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
@@ -1,31 +1,4 @@
|
|||||||
window.addEventListener(
|
import { setupExtensionDisconnectAction } from "../utils";
|
||||||
"message",
|
|
||||||
(event) => {
|
|
||||||
if (event.source !== window) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.data.command && event.data.command === "authResult") {
|
|
||||||
chrome.runtime.sendMessage({
|
|
||||||
command: event.data.command,
|
|
||||||
code: event.data.code,
|
|
||||||
state: event.data.state,
|
|
||||||
lastpass: event.data.lastpass,
|
|
||||||
referrer: event.source.location.hostname,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.data.command && event.data.command === "webAuthnResult") {
|
|
||||||
chrome.runtime.sendMessage({
|
|
||||||
command: event.data.command,
|
|
||||||
data: event.data.data,
|
|
||||||
remember: event.data.remember,
|
|
||||||
referrer: event.source.location.hostname,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
const forwardCommands = [
|
const forwardCommands = [
|
||||||
"bgUnlockPopoutOpened",
|
"bgUnlockPopoutOpened",
|
||||||
@@ -34,8 +7,59 @@ const forwardCommands = [
|
|||||||
"addedCipher",
|
"addedCipher",
|
||||||
];
|
];
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((event) => {
|
/**
|
||||||
if (forwardCommands.includes(event.command)) {
|
* Handles sending extension messages to the background
|
||||||
chrome.runtime.sendMessage(event);
|
* script based on window messages from the page.
|
||||||
|
*
|
||||||
|
* @param event - Window message event
|
||||||
|
*/
|
||||||
|
const handleWindowMessage = (event: MessageEvent) => {
|
||||||
|
if (event.source !== window) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
if (event.data.command && event.data.command === "authResult") {
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
command: event.data.command,
|
||||||
|
code: event.data.code,
|
||||||
|
state: event.data.state,
|
||||||
|
lastpass: event.data.lastpass,
|
||||||
|
referrer: event.source.location.hostname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.command && event.data.command === "webAuthnResult") {
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
command: event.data.command,
|
||||||
|
data: event.data.data,
|
||||||
|
remember: event.data.remember,
|
||||||
|
referrer: event.source.location.hostname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles forwarding any commands that need to trigger
|
||||||
|
* an action from one service of the extension background
|
||||||
|
* to another.
|
||||||
|
*
|
||||||
|
* @param message - Message from the extension
|
||||||
|
*/
|
||||||
|
const handleExtensionMessage = (message: any) => {
|
||||||
|
if (forwardCommands.includes(message.command)) {
|
||||||
|
chrome.runtime.sendMessage(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles cleaning up any event listeners that were
|
||||||
|
* added to the window or extension.
|
||||||
|
*/
|
||||||
|
const handleExtensionDisconnect = () => {
|
||||||
|
window.removeEventListener("message", handleWindowMessage);
|
||||||
|
chrome.runtime.onMessage.removeListener(handleExtensionMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", handleWindowMessage, false);
|
||||||
|
chrome.runtime.onMessage.addListener(handleExtensionMessage);
|
||||||
|
setupExtensionDisconnectAction(handleExtensionDisconnect);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import AddLoginRuntimeMessage from "../notification/models/add-login-runtime-mes
|
|||||||
import ChangePasswordRuntimeMessage from "../notification/models/change-password-runtime-message";
|
import ChangePasswordRuntimeMessage from "../notification/models/change-password-runtime-message";
|
||||||
import { FormData } from "../services/abstractions/autofill.service";
|
import { FormData } from "../services/abstractions/autofill.service";
|
||||||
import { GlobalSettings, UserSettings } from "../types";
|
import { GlobalSettings, UserSettings } from "../types";
|
||||||
|
import { getFromLocalStorage, setupExtensionDisconnectAction } from "../utils";
|
||||||
|
|
||||||
interface HTMLElementWithFormOpId extends HTMLElement {
|
interface HTMLElementWithFormOpId extends HTMLElement {
|
||||||
formOpId: string;
|
formOpId: string;
|
||||||
@@ -122,6 +123,8 @@ async function loadNotificationBar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupExtensionDisconnectAction(handleExtensionDisconnection);
|
||||||
|
|
||||||
if (!showNotificationBar) {
|
if (!showNotificationBar) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -999,11 +1002,23 @@ async function loadNotificationBar() {
|
|||||||
return theEl === document;
|
return theEl === document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleExtensionDisconnection(port: chrome.runtime.Port) {
|
||||||
|
closeBar(false);
|
||||||
|
clearTimeout(domObservationCollectTimeoutId);
|
||||||
|
clearTimeout(collectPageDetailsTimeoutId);
|
||||||
|
clearTimeout(handlePageChangeTimeoutId);
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
watchedForms.forEach((wf: WatchedForm) => {
|
||||||
|
const form = wf.formEl;
|
||||||
|
form.removeEventListener("submit", formSubmitted, false);
|
||||||
|
const submitButton = getSubmitButton(
|
||||||
|
form,
|
||||||
|
unionSets(logInButtonNames, changePasswordButtonNames),
|
||||||
|
);
|
||||||
|
submitButton?.removeEventListener("click", formSubmitted, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// End Helper Functions
|
// End Helper Functions
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFromLocalStorage(keys: string | string[]): Promise<Record<string, any>> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
chrome.storage.local.get(keys, (storage: Record<string, any>) => resolve(storage));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
5
apps/browser/src/autofill/enums/autofill-port.enums.ts
Normal file
5
apps/browser/src/autofill/enums/autofill-port.enums.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const AutofillPort = {
|
||||||
|
InjectedScript: "autofill-injected-script-port",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export { AutofillPort };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EVENTS } from "../../constants";
|
import { EVENTS } from "../../constants";
|
||||||
import { setElementStyles } from "../../utils/utils";
|
import { setElementStyles } from "../../utils";
|
||||||
import {
|
import {
|
||||||
BackgroundPortMessageHandlers,
|
BackgroundPortMessageHandlers,
|
||||||
AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface,
|
AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface,
|
||||||
@@ -166,9 +166,10 @@ class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterf
|
|||||||
|
|
||||||
this.updateElementStyles(this.iframe, { opacity: "0", height: "0px", display: "block" });
|
this.updateElementStyles(this.iframe, { opacity: "0", height: "0px", display: "block" });
|
||||||
globalThis.removeEventListener("message", this.handleWindowMessage);
|
globalThis.removeEventListener("message", this.handleWindowMessage);
|
||||||
this.port.onMessage.removeListener(this.handlePortMessage);
|
this.unobserveIframe();
|
||||||
this.port.onDisconnect.removeListener(this.handlePortDisconnect);
|
this.port?.onMessage.removeListener(this.handlePortMessage);
|
||||||
this.port.disconnect();
|
this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
|
||||||
|
this.port?.disconnect();
|
||||||
this.port = null;
|
this.port = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -369,7 +370,7 @@ class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterf
|
|||||||
* Unobserves the iframe element for mutations to its style attribute.
|
* Unobserves the iframe element for mutations to its style attribute.
|
||||||
*/
|
*/
|
||||||
private unobserveIframe() {
|
private unobserveIframe() {
|
||||||
this.iframeMutationObserver.disconnect();
|
this.iframeMutationObserver?.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import "lit/polyfill-support.js";
|
|||||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
|
|
||||||
import { EVENTS } from "../../../constants";
|
import { EVENTS } from "../../../constants";
|
||||||
|
import { buildSvgDomElement } from "../../../utils";
|
||||||
import { logoIcon, logoLockedIcon } from "../../../utils/svg-icons";
|
import { logoIcon, logoLockedIcon } from "../../../utils/svg-icons";
|
||||||
import { buildSvgDomElement } from "../../../utils/utils";
|
|
||||||
import {
|
import {
|
||||||
InitAutofillOverlayButtonMessage,
|
InitAutofillOverlayButtonMessage,
|
||||||
OverlayButtonWindowMessageHandlers,
|
OverlayButtonWindowMessageHandlers,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
|
|||||||
|
|
||||||
import { OverlayCipherData } from "../../../background/abstractions/overlay.background";
|
import { OverlayCipherData } from "../../../background/abstractions/overlay.background";
|
||||||
import { EVENTS } from "../../../constants";
|
import { EVENTS } from "../../../constants";
|
||||||
|
import { buildSvgDomElement } from "../../../utils";
|
||||||
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../utils/svg-icons";
|
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../utils/svg-icons";
|
||||||
import { buildSvgDomElement } from "../../../utils/utils";
|
|
||||||
import {
|
import {
|
||||||
InitAutofillOverlayListMessage,
|
InitAutofillOverlayListMessage,
|
||||||
OverlayListWindowMessageHandlers,
|
OverlayListWindowMessageHandlers,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { UriMatchType } from "@bitwarden/common/vault/enums";
|
|||||||
|
|
||||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||||
import { flagEnabled } from "../../../platform/flags";
|
import { flagEnabled } from "../../../platform/flags";
|
||||||
|
import { AutofillService } from "../../services/abstractions/autofill.service";
|
||||||
import { AutofillOverlayVisibility } from "../../utils/autofill-overlay.enum";
|
import { AutofillOverlayVisibility } from "../../utils/autofill-overlay.enum";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -35,6 +36,7 @@ export class AutofillComponent implements OnInit {
|
|||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private configService: ConfigServiceAbstraction,
|
private configService: ConfigServiceAbstraction,
|
||||||
private settingsService: SettingsService,
|
private settingsService: SettingsService,
|
||||||
|
private autofillService: AutofillService,
|
||||||
) {
|
) {
|
||||||
this.autoFillOverlayVisibilityOptions = [
|
this.autoFillOverlayVisibilityOptions = [
|
||||||
{
|
{
|
||||||
@@ -86,7 +88,10 @@ export class AutofillComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateAutoFillOverlayVisibility() {
|
async updateAutoFillOverlayVisibility() {
|
||||||
|
const previousAutoFillOverlayVisibility =
|
||||||
|
await this.settingsService.getAutoFillOverlayVisibility();
|
||||||
await this.settingsService.setAutoFillOverlayVisibility(this.autoFillOverlayVisibility);
|
await this.settingsService.setAutoFillOverlayVisibility(this.autoFillOverlayVisibility);
|
||||||
|
await this.handleUpdatingAutofillOverlayContentScripts(previousAutoFillOverlayVisibility);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAutoFillOnPageLoad() {
|
async updateAutoFillOnPageLoad() {
|
||||||
@@ -144,4 +149,25 @@ export class AutofillComponent implements OnInit {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
BrowserApi.createNewTab(this.disablePasswordManagerLink);
|
BrowserApi.createNewTab(this.disablePasswordManagerLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleUpdatingAutofillOverlayContentScripts(
|
||||||
|
previousAutoFillOverlayVisibility: number,
|
||||||
|
) {
|
||||||
|
const autofillOverlayPreviouslyDisabled =
|
||||||
|
previousAutoFillOverlayVisibility === AutofillOverlayVisibility.Off;
|
||||||
|
const autofillOverlayCurrentlyDisabled =
|
||||||
|
this.autoFillOverlayVisibility === AutofillOverlayVisibility.Off;
|
||||||
|
|
||||||
|
if (!autofillOverlayPreviouslyDisabled && !autofillOverlayCurrentlyDisabled) {
|
||||||
|
const tabs = await BrowserApi.tabsQuery({});
|
||||||
|
tabs.forEach((tab) =>
|
||||||
|
BrowserApi.tabSendMessageData(tab, "updateAutofillOverlayVisibility", {
|
||||||
|
autofillOverlayVisibility: this.autoFillOverlayVisibility,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.autofillService.reloadAutofillScripts();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface AutofillOverlayContentService {
|
|||||||
isCurrentlyFilling: boolean;
|
isCurrentlyFilling: boolean;
|
||||||
isOverlayCiphersPopulated: boolean;
|
isOverlayCiphersPopulated: boolean;
|
||||||
pageDetailsUpdateRequired: boolean;
|
pageDetailsUpdateRequired: boolean;
|
||||||
|
autofillOverlayVisibility: number;
|
||||||
init(): void;
|
init(): void;
|
||||||
setupAutofillOverlayListenerOnField(
|
setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement: ElementWithOpId<FormFieldElement>,
|
autofillFieldElement: ElementWithOpId<FormFieldElement>,
|
||||||
@@ -27,6 +28,7 @@ interface AutofillOverlayContentService {
|
|||||||
redirectOverlayFocusOut(direction: "previous" | "next"): void;
|
redirectOverlayFocusOut(direction: "previous" | "next"): void;
|
||||||
focusMostRecentOverlayField(): void;
|
focusMostRecentOverlayField(): void;
|
||||||
blurMostRecentOverlayField(): void;
|
blurMostRecentOverlayField(): void;
|
||||||
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { OpenAutofillOverlayOptions, AutofillOverlayContentService };
|
export { OpenAutofillOverlayOptions, AutofillOverlayContentService };
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ export interface GenerateFillScriptOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export abstract class AutofillService {
|
export abstract class AutofillService {
|
||||||
|
loadAutofillScriptsOnInstall: () => Promise<void>;
|
||||||
|
reloadAutofillScripts: () => Promise<void>;
|
||||||
injectAutofillScripts: (
|
injectAutofillScripts: (
|
||||||
sender: chrome.runtime.MessageSender,
|
tab: chrome.tabs.Tab,
|
||||||
autofillV2?: boolean,
|
frameId?: number,
|
||||||
autofillOverlay?: boolean,
|
triggeringOnPageLoad?: boolean,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[];
|
getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[];
|
||||||
doAutoFill: (options: AutoFillOptions) => Promise<string | null>;
|
doAutoFill: (options: AutoFillOptions) => Promise<string | null>;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface CollectAutofillContentService {
|
|||||||
filterCallback: CallableFunction,
|
filterCallback: CallableFunction,
|
||||||
isObservingShadowRoot?: boolean,
|
isObservingShadowRoot?: boolean,
|
||||||
): Node[];
|
): Node[];
|
||||||
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1609,4 +1609,100 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
expect(autofillOverlayContentService["removeAutofillOverlay"]).toHaveBeenCalled();
|
expect(autofillOverlayContentService["removeAutofillOverlay"]).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("destroy", () => {
|
||||||
|
let autofillFieldElement: ElementWithOpId<FormFieldElement>;
|
||||||
|
let autofillFieldData: AutofillField;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<form id="validFormId">
|
||||||
|
<input type="text" id="username-field" placeholder="username" />
|
||||||
|
<input type="password" id="password-field" placeholder="password" />
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
autofillFieldElement = document.getElementById(
|
||||||
|
"username-field",
|
||||||
|
) as ElementWithOpId<FormFieldElement>;
|
||||||
|
autofillFieldElement.opid = "op-1";
|
||||||
|
autofillFieldData = createAutofillFieldMock({
|
||||||
|
opid: "username-field",
|
||||||
|
form: "validFormId",
|
||||||
|
placeholder: "username",
|
||||||
|
elementNumber: 1,
|
||||||
|
});
|
||||||
|
autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
|
autofillFieldElement,
|
||||||
|
autofillFieldData,
|
||||||
|
);
|
||||||
|
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disconnects all mutation observers", () => {
|
||||||
|
autofillOverlayContentService["setupMutationObserver"]();
|
||||||
|
jest.spyOn(autofillOverlayContentService["bodyElementMutationObserver"], "disconnect");
|
||||||
|
jest.spyOn(autofillOverlayContentService["documentElementMutationObserver"], "disconnect");
|
||||||
|
|
||||||
|
autofillOverlayContentService.destroy();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
autofillOverlayContentService["documentElementMutationObserver"].disconnect,
|
||||||
|
).toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
autofillOverlayContentService["bodyElementMutationObserver"].disconnect,
|
||||||
|
).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the user interaction event timeout", () => {
|
||||||
|
jest.spyOn(autofillOverlayContentService as any, "clearUserInteractionEventTimeout");
|
||||||
|
|
||||||
|
autofillOverlayContentService.destroy();
|
||||||
|
|
||||||
|
expect(autofillOverlayContentService["clearUserInteractionEventTimeout"]).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-registers all global event listeners", () => {
|
||||||
|
jest.spyOn(globalThis.document, "removeEventListener");
|
||||||
|
jest.spyOn(globalThis, "removeEventListener");
|
||||||
|
jest.spyOn(autofillOverlayContentService as any, "removeOverlayRepositionEventListeners");
|
||||||
|
|
||||||
|
autofillOverlayContentService.destroy();
|
||||||
|
|
||||||
|
expect(globalThis.document.removeEventListener).toHaveBeenCalledWith(
|
||||||
|
EVENTS.VISIBILITYCHANGE,
|
||||||
|
autofillOverlayContentService["handleVisibilityChangeEvent"],
|
||||||
|
);
|
||||||
|
expect(globalThis.removeEventListener).toHaveBeenCalledWith(
|
||||||
|
EVENTS.FOCUSOUT,
|
||||||
|
autofillOverlayContentService["handleFormFieldBlurEvent"],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
autofillOverlayContentService["removeOverlayRepositionEventListeners"],
|
||||||
|
).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-registers any event listeners that are attached to the form field elements", () => {
|
||||||
|
jest.spyOn(autofillOverlayContentService as any, "removeCachedFormFieldEventListeners");
|
||||||
|
jest.spyOn(autofillFieldElement, "removeEventListener");
|
||||||
|
jest.spyOn(autofillOverlayContentService["formFieldElements"], "delete");
|
||||||
|
|
||||||
|
autofillOverlayContentService.destroy();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
autofillOverlayContentService["removeCachedFormFieldEventListeners"],
|
||||||
|
).toHaveBeenCalledWith(autofillFieldElement);
|
||||||
|
expect(autofillFieldElement.removeEventListener).toHaveBeenCalledWith(
|
||||||
|
EVENTS.BLUR,
|
||||||
|
autofillOverlayContentService["handleFormFieldBlurEvent"],
|
||||||
|
);
|
||||||
|
expect(autofillFieldElement.removeEventListener).toHaveBeenCalledWith(
|
||||||
|
EVENTS.KEYUP,
|
||||||
|
autofillOverlayContentService["handleFormFieldKeyupEvent"],
|
||||||
|
);
|
||||||
|
expect(autofillOverlayContentService["formFieldElements"].delete).toHaveBeenCalledWith(
|
||||||
|
autofillFieldElement,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ import AutofillField from "../models/autofill-field";
|
|||||||
import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe";
|
import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe";
|
||||||
import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe";
|
import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe";
|
||||||
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
|
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
|
||||||
|
import { generateRandomCustomElementName, sendExtensionMessage, setElementStyles } from "../utils";
|
||||||
import {
|
import {
|
||||||
AutofillOverlayElement,
|
AutofillOverlayElement,
|
||||||
RedirectFocusDirection,
|
RedirectFocusDirection,
|
||||||
AutofillOverlayVisibility,
|
AutofillOverlayVisibility,
|
||||||
} from "../utils/autofill-overlay.enum";
|
} from "../utils/autofill-overlay.enum";
|
||||||
import {
|
|
||||||
generateRandomCustomElementName,
|
|
||||||
sendExtensionMessage,
|
|
||||||
setElementStyles,
|
|
||||||
} from "../utils/utils";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AutofillOverlayContentService as AutofillOverlayContentServiceInterface,
|
AutofillOverlayContentService as AutofillOverlayContentServiceInterface,
|
||||||
@@ -32,9 +28,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
|
|||||||
isCurrentlyFilling = false;
|
isCurrentlyFilling = false;
|
||||||
isOverlayCiphersPopulated = false;
|
isOverlayCiphersPopulated = false;
|
||||||
pageDetailsUpdateRequired = false;
|
pageDetailsUpdateRequired = false;
|
||||||
|
autofillOverlayVisibility: number;
|
||||||
private readonly findTabs = tabbable;
|
private readonly findTabs = tabbable;
|
||||||
private readonly sendExtensionMessage = sendExtensionMessage;
|
private readonly sendExtensionMessage = sendExtensionMessage;
|
||||||
private autofillOverlayVisibility: number;
|
private formFieldElements: Set<ElementWithOpId<FormFieldElement>> = new Set([]);
|
||||||
private userFilledFields: Record<string, FillableFormFieldElement> = {};
|
private userFilledFields: Record<string, FillableFormFieldElement> = {};
|
||||||
private authStatus: AuthenticationStatus;
|
private authStatus: AuthenticationStatus;
|
||||||
private focusableElements: FocusableElement[] = [];
|
private focusableElements: FocusableElement[] = [];
|
||||||
@@ -47,6 +44,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
|
|||||||
private userInteractionEventTimeout: NodeJS.Timeout;
|
private userInteractionEventTimeout: NodeJS.Timeout;
|
||||||
private overlayElementsMutationObserver: MutationObserver;
|
private overlayElementsMutationObserver: MutationObserver;
|
||||||
private bodyElementMutationObserver: MutationObserver;
|
private bodyElementMutationObserver: MutationObserver;
|
||||||
|
private documentElementMutationObserver: MutationObserver;
|
||||||
private mutationObserverIterations = 0;
|
private mutationObserverIterations = 0;
|
||||||
private mutationObserverIterationsResetTimeout: NodeJS.Timeout;
|
private mutationObserverIterationsResetTimeout: NodeJS.Timeout;
|
||||||
private autofillFieldKeywordsMap: WeakMap<AutofillField, string> = new WeakMap();
|
private autofillFieldKeywordsMap: WeakMap<AutofillField, string> = new WeakMap();
|
||||||
@@ -86,6 +84,8 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.formFieldElements.add(formFieldElement);
|
||||||
|
|
||||||
if (!this.autofillOverlayVisibility) {
|
if (!this.autofillOverlayVisibility) {
|
||||||
await this.getAutofillOverlayVisibility();
|
await this.getAutofillOverlayVisibility();
|
||||||
}
|
}
|
||||||
@@ -901,10 +901,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
|
|||||||
this.handleBodyElementMutationObserverUpdate,
|
this.handleBodyElementMutationObserverUpdate,
|
||||||
);
|
);
|
||||||
|
|
||||||
const documentElementMutationObserver = new MutationObserver(
|
this.documentElementMutationObserver = new MutationObserver(
|
||||||
this.handleDocumentElementMutationObserverUpdate,
|
this.handleDocumentElementMutationObserverUpdate,
|
||||||
);
|
);
|
||||||
documentElementMutationObserver.observe(globalThis.document.documentElement, {
|
this.documentElementMutationObserver.observe(globalThis.document.documentElement, {
|
||||||
childList: true,
|
childList: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1117,6 +1117,28 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
|
|||||||
const documentRoot = element.getRootNode() as ShadowRoot | Document;
|
const documentRoot = element.getRootNode() as ShadowRoot | Document;
|
||||||
return documentRoot?.activeElement;
|
return documentRoot?.activeElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the autofill overlay content service. This method will
|
||||||
|
* disconnect the mutation observers and remove all event listeners.
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.documentElementMutationObserver?.disconnect();
|
||||||
|
this.clearUserInteractionEventTimeout();
|
||||||
|
this.formFieldElements.forEach((formFieldElement) => {
|
||||||
|
this.removeCachedFormFieldEventListeners(formFieldElement);
|
||||||
|
formFieldElement.removeEventListener(EVENTS.BLUR, this.handleFormFieldBlurEvent);
|
||||||
|
formFieldElement.removeEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent);
|
||||||
|
this.formFieldElements.delete(formFieldElement);
|
||||||
|
});
|
||||||
|
globalThis.document.removeEventListener(
|
||||||
|
EVENTS.VISIBILITYCHANGE,
|
||||||
|
this.handleVisibilityChangeEvent,
|
||||||
|
);
|
||||||
|
globalThis.removeEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent);
|
||||||
|
this.removeAutofillOverlay();
|
||||||
|
this.removeOverlayRepositionEventListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AutofillOverlayContentService;
|
export default AutofillOverlayContentService;
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { mock, mockReset } from "jest-mock-extended";
|
|||||||
|
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
|
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
|
||||||
import { EventType } from "@bitwarden/common/enums";
|
import { EventType } from "@bitwarden/common/enums";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/services/config/config.service";
|
||||||
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
|
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
|
||||||
import { SettingsService } from "@bitwarden/common/services/settings.service";
|
import { SettingsService } from "@bitwarden/common/services/settings.service";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +26,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service";
|
|||||||
|
|
||||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||||
import { BrowserStateService } from "../../platform/services/browser-state.service";
|
import { BrowserStateService } from "../../platform/services/browser-state.service";
|
||||||
|
import { AutofillPort } from "../enums/autofill-port.enums";
|
||||||
import {
|
import {
|
||||||
createAutofillFieldMock,
|
createAutofillFieldMock,
|
||||||
createAutofillPageDetailsMock,
|
createAutofillPageDetailsMock,
|
||||||
@@ -54,6 +57,7 @@ describe("AutofillService", () => {
|
|||||||
const logService = mock<LogService>();
|
const logService = mock<LogService>();
|
||||||
const settingsService = mock<SettingsService>();
|
const settingsService = mock<SettingsService>();
|
||||||
const userVerificationService = mock<UserVerificationService>();
|
const userVerificationService = mock<UserVerificationService>();
|
||||||
|
const configService = mock<ConfigService>();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
autofillService = new AutofillService(
|
autofillService = new AutofillService(
|
||||||
@@ -64,6 +68,7 @@ describe("AutofillService", () => {
|
|||||||
logService,
|
logService,
|
||||||
settingsService,
|
settingsService,
|
||||||
userVerificationService,
|
userVerificationService,
|
||||||
|
configService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +77,72 @@ describe("AutofillService", () => {
|
|||||||
mockReset(cipherService);
|
mockReset(cipherService);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("loadAutofillScriptsOnInstall", () => {
|
||||||
|
let tab1: chrome.tabs.Tab;
|
||||||
|
let tab2: chrome.tabs.Tab;
|
||||||
|
let tab3: chrome.tabs.Tab;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tab1 = createChromeTabMock({ id: 1, url: "https://some-url.com" });
|
||||||
|
tab2 = createChromeTabMock({ id: 2, url: "http://some-url.com" });
|
||||||
|
tab3 = createChromeTabMock({ id: 3, url: "chrome-extension://some-extension-route" });
|
||||||
|
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([tab1, tab2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queries all browser tabs and injects the autofill scripts into them", async () => {
|
||||||
|
jest.spyOn(autofillService, "injectAutofillScripts");
|
||||||
|
|
||||||
|
await autofillService.loadAutofillScriptsOnInstall();
|
||||||
|
|
||||||
|
expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({});
|
||||||
|
expect(autofillService.injectAutofillScripts).toHaveBeenCalledWith(tab1, 0, false);
|
||||||
|
expect(autofillService.injectAutofillScripts).toHaveBeenCalledWith(tab2, 0, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips injecting scripts into tabs that do not have an http(s) protocol", async () => {
|
||||||
|
jest.spyOn(autofillService, "injectAutofillScripts");
|
||||||
|
|
||||||
|
await autofillService.loadAutofillScriptsOnInstall();
|
||||||
|
|
||||||
|
expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({});
|
||||||
|
expect(autofillService.injectAutofillScripts).not.toHaveBeenCalledWith(tab3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets up an extension runtime onConnect listener", async () => {
|
||||||
|
await autofillService.loadAutofillScriptsOnInstall();
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
expect(chrome.runtime.onConnect.addListener).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reloadAutofillScripts", () => {
|
||||||
|
it("disconnects and removes all autofill script ports", () => {
|
||||||
|
const port1 = mock<chrome.runtime.Port>({
|
||||||
|
disconnect: jest.fn(),
|
||||||
|
});
|
||||||
|
const port2 = mock<chrome.runtime.Port>({
|
||||||
|
disconnect: jest.fn(),
|
||||||
|
});
|
||||||
|
autofillService["autofillScriptPortsSet"] = new Set([port1, port2]);
|
||||||
|
|
||||||
|
autofillService.reloadAutofillScripts();
|
||||||
|
|
||||||
|
expect(port1.disconnect).toHaveBeenCalled();
|
||||||
|
expect(port2.disconnect).toHaveBeenCalled();
|
||||||
|
expect(autofillService["autofillScriptPortsSet"].size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-injects the autofill scripts in all tabs", () => {
|
||||||
|
autofillService["autofillScriptPortsSet"] = new Set([mock<chrome.runtime.Port>()]);
|
||||||
|
jest.spyOn(autofillService as any, "injectAutofillScriptsInAllTabs");
|
||||||
|
|
||||||
|
autofillService.reloadAutofillScripts();
|
||||||
|
|
||||||
|
expect(autofillService["injectAutofillScriptsInAllTabs"]).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("injectAutofillScripts", () => {
|
describe("injectAutofillScripts", () => {
|
||||||
const autofillV1Script = "autofill.js";
|
const autofillV1Script = "autofill.js";
|
||||||
const autofillV2BootstrapScript = "bootstrap-autofill.js";
|
const autofillV2BootstrapScript = "bootstrap-autofill.js";
|
||||||
@@ -83,12 +154,12 @@ describe("AutofillService", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tabMock = createChromeTabMock();
|
tabMock = createChromeTabMock();
|
||||||
sender = { tab: tabMock };
|
sender = { tab: tabMock, frameId: 1 };
|
||||||
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
|
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts an extension message sender and injects the autofill scripts into the tab of the sender", async () => {
|
it("accepts an extension message sender and injects the autofill scripts into the tab of the sender", async () => {
|
||||||
await autofillService.injectAutofillScripts(sender);
|
await autofillService.injectAutofillScripts(sender.tab, sender.frameId, true);
|
||||||
|
|
||||||
[autofillV1Script, ...defaultAutofillScripts].forEach((scriptName) => {
|
[autofillV1Script, ...defaultAutofillScripts].forEach((scriptName) => {
|
||||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
||||||
@@ -105,7 +176,11 @@ describe("AutofillService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("will inject the bootstrap-autofill script if the enableAutofillV2 flag is set", async () => {
|
it("will inject the bootstrap-autofill script if the enableAutofillV2 flag is set", async () => {
|
||||||
await autofillService.injectAutofillScripts(sender, true);
|
jest
|
||||||
|
.spyOn(configService, "getFeatureFlag")
|
||||||
|
.mockImplementation((flag) => Promise.resolve(flag === FeatureFlag.AutofillV2));
|
||||||
|
|
||||||
|
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
|
||||||
|
|
||||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
||||||
file: `content/${autofillV2BootstrapScript}`,
|
file: `content/${autofillV2BootstrapScript}`,
|
||||||
@@ -120,11 +195,16 @@ describe("AutofillService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("will inject the bootstrap-autofill-overlay script if the enableAutofillOverlay flag is set and the user has the autofill overlay enabled", async () => {
|
it("will inject the bootstrap-autofill-overlay script if the enableAutofillOverlay flag is set and the user has the autofill overlay enabled", async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(configService, "getFeatureFlag")
|
||||||
|
.mockImplementation((flag) =>
|
||||||
|
Promise.resolve(flag === FeatureFlag.AutofillOverlay || flag === FeatureFlag.AutofillV2),
|
||||||
|
);
|
||||||
jest
|
jest
|
||||||
.spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility")
|
.spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility")
|
||||||
.mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus);
|
.mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus);
|
||||||
|
|
||||||
await autofillService.injectAutofillScripts(sender, true, true);
|
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
|
||||||
|
|
||||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
||||||
file: `content/${autofillOverlayBootstrapScript}`,
|
file: `content/${autofillOverlayBootstrapScript}`,
|
||||||
@@ -144,18 +224,25 @@ describe("AutofillService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("will inject the bootstrap-autofill script if the enableAutofillOverlay flag is set but the user does not have the autofill overlay enabled", async () => {
|
it("will inject the bootstrap-autofill script if the enableAutofillOverlay flag is set but the user does not have the autofill overlay enabled", async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(configService, "getFeatureFlag")
|
||||||
|
.mockImplementation((flag) =>
|
||||||
|
Promise.resolve(flag === FeatureFlag.AutofillOverlay || flag === FeatureFlag.AutofillV2),
|
||||||
|
);
|
||||||
jest
|
jest
|
||||||
.spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility")
|
.spyOn(autofillService["settingsService"], "getAutoFillOverlayVisibility")
|
||||||
.mockResolvedValue(AutofillOverlayVisibility.Off);
|
.mockResolvedValue(AutofillOverlayVisibility.Off);
|
||||||
|
|
||||||
await autofillService.injectAutofillScripts(sender, true, true);
|
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
|
||||||
|
|
||||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
||||||
file: `content/${autofillV2BootstrapScript}`,
|
file: `content/${autofillV2BootstrapScript}`,
|
||||||
|
frameId: sender.frameId,
|
||||||
...defaultExecuteScriptOptions,
|
...defaultExecuteScriptOptions,
|
||||||
});
|
});
|
||||||
expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, {
|
expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, {
|
||||||
file: `content/${autofillV1Script}`,
|
file: `content/${autofillV1Script}`,
|
||||||
|
frameId: sender.frameId,
|
||||||
...defaultExecuteScriptOptions,
|
...defaultExecuteScriptOptions,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4436,4 +4523,58 @@ describe("AutofillService", () => {
|
|||||||
expect(autofillService["currentlyOpeningPasswordRepromptPopout"]).toBe(false);
|
expect(autofillService["currentlyOpeningPasswordRepromptPopout"]).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handleInjectedScriptPortConnection", () => {
|
||||||
|
it("ignores port connections that do not have the correct port name", () => {
|
||||||
|
const port = mock<chrome.runtime.Port>({
|
||||||
|
name: "some-invalid-port-name",
|
||||||
|
onDisconnect: { addListener: jest.fn() },
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
autofillService["handleInjectedScriptPortConnection"](port);
|
||||||
|
|
||||||
|
expect(port.onDisconnect.addListener).not.toHaveBeenCalled();
|
||||||
|
expect(autofillService["autofillScriptPortsSet"].size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the connect port to the set of injected script ports and sets up an onDisconnect listener", () => {
|
||||||
|
const port = mock<chrome.runtime.Port>({
|
||||||
|
name: AutofillPort.InjectedScript,
|
||||||
|
onDisconnect: { addListener: jest.fn() },
|
||||||
|
}) as any;
|
||||||
|
jest.spyOn(autofillService as any, "handleInjectScriptPortOnDisconnect");
|
||||||
|
|
||||||
|
autofillService["handleInjectedScriptPortConnection"](port);
|
||||||
|
|
||||||
|
expect(port.onDisconnect.addListener).toHaveBeenCalledWith(
|
||||||
|
autofillService["handleInjectScriptPortOnDisconnect"],
|
||||||
|
);
|
||||||
|
expect(autofillService["autofillScriptPortsSet"].size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleInjectScriptPortOnDisconnect", () => {
|
||||||
|
it("ignores port disconnections that do not have the correct port name", () => {
|
||||||
|
autofillService["autofillScriptPortsSet"].add(mock<chrome.runtime.Port>());
|
||||||
|
|
||||||
|
autofillService["handleInjectScriptPortOnDisconnect"](
|
||||||
|
mock<chrome.runtime.Port>({
|
||||||
|
name: "some-invalid-port-name",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(autofillService["autofillScriptPortsSet"].size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the port from the set of injected script ports", () => {
|
||||||
|
const port = mock<chrome.runtime.Port>({
|
||||||
|
name: AutofillPort.InjectedScript,
|
||||||
|
}) as any;
|
||||||
|
autofillService["autofillScriptPortsSet"].add(port);
|
||||||
|
|
||||||
|
autofillService["handleInjectScriptPortOnDisconnect"](port);
|
||||||
|
|
||||||
|
expect(autofillService["autofillScriptPortsSet"].size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve
|
|||||||
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
|
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
import { EventType } from "@bitwarden/common/enums";
|
import { EventType } from "@bitwarden/common/enums";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||||
@@ -13,6 +15,7 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
|||||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||||
import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service";
|
import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service";
|
||||||
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
|
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
|
||||||
|
import { AutofillPort } from "../enums/autofill-port.enums";
|
||||||
import AutofillField from "../models/autofill-field";
|
import AutofillField from "../models/autofill-field";
|
||||||
import AutofillPageDetails from "../models/autofill-page-details";
|
import AutofillPageDetails from "../models/autofill-page-details";
|
||||||
import AutofillScript from "../models/autofill-script";
|
import AutofillScript from "../models/autofill-script";
|
||||||
@@ -35,6 +38,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
private openVaultItemPasswordRepromptPopout = openVaultItemPasswordRepromptPopout;
|
private openVaultItemPasswordRepromptPopout = openVaultItemPasswordRepromptPopout;
|
||||||
private openPasswordRepromptPopoutDebounce: NodeJS.Timeout;
|
private openPasswordRepromptPopoutDebounce: NodeJS.Timeout;
|
||||||
private currentlyOpeningPasswordRepromptPopout = false;
|
private currentlyOpeningPasswordRepromptPopout = false;
|
||||||
|
private autofillScriptPortsSet = new Set<chrome.runtime.Port>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private cipherService: CipherService,
|
private cipherService: CipherService,
|
||||||
@@ -44,23 +48,54 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
private settingsService: SettingsService,
|
private settingsService: SettingsService,
|
||||||
private userVerificationService: UserVerificationService,
|
private userVerificationService: UserVerificationService,
|
||||||
|
private configService: ConfigServiceAbstraction,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers on installation of the extension Handles injecting
|
||||||
|
* content scripts into all tabs that are currently open, and
|
||||||
|
* sets up a listener to ensure content scripts can identify
|
||||||
|
* if the extension context has been disconnected.
|
||||||
|
*/
|
||||||
|
async loadAutofillScriptsOnInstall() {
|
||||||
|
BrowserApi.addListener(chrome.runtime.onConnect, this.handleInjectedScriptPortConnection);
|
||||||
|
|
||||||
|
this.injectAutofillScriptsInAllTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a complete reload of all autofill scripts on tabs open within
|
||||||
|
* the user's browsing session. This is done by first disconnecting all
|
||||||
|
* existing autofill content script ports, which cleans up existing object
|
||||||
|
* instances, and then re-injecting the autofill scripts into all tabs.
|
||||||
|
*/
|
||||||
|
async reloadAutofillScripts() {
|
||||||
|
this.autofillScriptPortsSet.forEach((port) => {
|
||||||
|
port.disconnect();
|
||||||
|
this.autofillScriptPortsSet.delete(port);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.injectAutofillScriptsInAllTabs();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injects the autofill scripts into the current tab and all frames
|
* Injects the autofill scripts into the current tab and all frames
|
||||||
* found within the tab. Temporarily, will conditionally inject
|
* found within the tab. Temporarily, will conditionally inject
|
||||||
* the refactor of the core autofill script if the feature flag
|
* the refactor of the core autofill script if the feature flag
|
||||||
* is enabled.
|
* is enabled.
|
||||||
* @param {chrome.runtime.MessageSender} sender
|
* @param {chrome.tabs.Tab} tab
|
||||||
* @param {boolean} autofillV2
|
* @param {number} frameId
|
||||||
* @param {boolean} autofillOverlay
|
* @param {boolean} triggeringOnPageLoad
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
*/
|
||||||
async injectAutofillScripts(
|
async injectAutofillScripts(
|
||||||
sender: chrome.runtime.MessageSender,
|
tab: chrome.tabs.Tab,
|
||||||
autofillV2 = false,
|
frameId = 0,
|
||||||
autofillOverlay = false,
|
triggeringOnPageLoad = true,
|
||||||
) {
|
): Promise<void> {
|
||||||
|
const autofillV2 = await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillV2);
|
||||||
|
const autofillOverlay = await this.configService.getFeatureFlag<boolean>(
|
||||||
|
FeatureFlag.AutofillOverlay,
|
||||||
|
);
|
||||||
let mainAutofillScript = "autofill.js";
|
let mainAutofillScript = "autofill.js";
|
||||||
|
|
||||||
const isUsingAutofillOverlay =
|
const isUsingAutofillOverlay =
|
||||||
@@ -73,20 +108,24 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
: "bootstrap-autofill.js";
|
: "bootstrap-autofill.js";
|
||||||
}
|
}
|
||||||
|
|
||||||
const injectedScripts = [
|
const injectedScripts = [mainAutofillScript];
|
||||||
mainAutofillScript,
|
if (triggeringOnPageLoad) {
|
||||||
"autofiller.js",
|
injectedScripts.push("autofiller.js");
|
||||||
"notificationBar.js",
|
}
|
||||||
"contextMenuHandler.js",
|
injectedScripts.push("notificationBar.js", "contextMenuHandler.js");
|
||||||
];
|
|
||||||
|
|
||||||
for (const injectedScript of injectedScripts) {
|
for (const injectedScript of injectedScripts) {
|
||||||
await BrowserApi.executeScriptInTab(sender.tab.id, {
|
await BrowserApi.executeScriptInTab(tab.id, {
|
||||||
file: `content/${injectedScript}`,
|
file: `content/${injectedScript}`,
|
||||||
frameId: sender.frameId,
|
frameId,
|
||||||
runAt: "document_start",
|
runAt: "document_start",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await BrowserApi.executeScriptInTab(tab.id, {
|
||||||
|
file: "content/message_handler.js",
|
||||||
|
runAt: "document_start",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1877,4 +1916,47 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming long-lived connections from injected autofill scripts.
|
||||||
|
* Stores the port in a set to facilitate disconnecting ports if the extension
|
||||||
|
* needs to re-inject the autofill scripts.
|
||||||
|
*
|
||||||
|
* @param port - The port that was connected
|
||||||
|
*/
|
||||||
|
private handleInjectedScriptPortConnection = (port: chrome.runtime.Port) => {
|
||||||
|
if (port.name !== AutofillPort.InjectedScript) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.autofillScriptPortsSet.add(port);
|
||||||
|
port.onDisconnect.addListener(this.handleInjectScriptPortOnDisconnect);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles disconnecting ports that relate to injected autofill scripts.
|
||||||
|
|
||||||
|
* @param port - The port that was disconnected
|
||||||
|
*/
|
||||||
|
private handleInjectScriptPortOnDisconnect = (port: chrome.runtime.Port) => {
|
||||||
|
if (port.name !== AutofillPort.InjectedScript) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.autofillScriptPortsSet.delete(port);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries all open tabs in the user's browsing session
|
||||||
|
* and injects the autofill scripts into the page.
|
||||||
|
*/
|
||||||
|
private async injectAutofillScriptsInAllTabs() {
|
||||||
|
const tabs = await BrowserApi.tabsQuery({});
|
||||||
|
for (let index = 0; index < tabs.length; index++) {
|
||||||
|
const tab = tabs[index];
|
||||||
|
if (tab.url?.startsWith("http")) {
|
||||||
|
this.injectAutofillScripts(tab, 0, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1249,6 +1249,17 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
|
|
||||||
return attributeValue;
|
return attributeValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the CollectAutofillContentService. Clears all
|
||||||
|
* timeouts and disconnects the mutation observer.
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
if (this.updateAutofillElementsAfterMutationTimeout) {
|
||||||
|
clearTimeout(this.updateAutofillElementsAfterMutationTimeout);
|
||||||
|
}
|
||||||
|
this.mutationObserver?.disconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CollectAutofillContentService;
|
export default CollectAutofillContentService;
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
import { AutofillPort } from "../enums/autofill-port.enums";
|
||||||
|
import { triggerPortOnDisconnectEvent } from "../jest/testing-utils";
|
||||||
|
|
||||||
import { logoIcon, logoLockedIcon } from "./svg-icons";
|
import { logoIcon, logoLockedIcon } from "./svg-icons";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildSvgDomElement,
|
buildSvgDomElement,
|
||||||
generateRandomCustomElementName,
|
generateRandomCustomElementName,
|
||||||
sendExtensionMessage,
|
sendExtensionMessage,
|
||||||
setElementStyles,
|
setElementStyles,
|
||||||
} from "./utils";
|
getFromLocalStorage,
|
||||||
|
setupExtensionDisconnectAction,
|
||||||
|
setupAutofillInitDisconnectAction,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
describe("buildSvgDomElement", () => {
|
describe("buildSvgDomElement", () => {
|
||||||
it("returns an SVG DOM element", () => {
|
it("returns an SVG DOM element", () => {
|
||||||
@@ -116,3 +123,107 @@ describe("setElementStyles", () => {
|
|||||||
expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);
|
expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getFromLocalStorage", () => {
|
||||||
|
it("returns a promise with the storage object pulled from the extension storage api", async () => {
|
||||||
|
const localStorage: Record<string, any> = {
|
||||||
|
testValue: "test",
|
||||||
|
another: "another",
|
||||||
|
};
|
||||||
|
jest.spyOn(chrome.storage.local, "get").mockImplementation((keys, callback) => {
|
||||||
|
const localStorageObject: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (typeof keys === "string") {
|
||||||
|
localStorageObject[keys] = localStorage[keys];
|
||||||
|
} else if (Array.isArray(keys)) {
|
||||||
|
for (const key of keys) {
|
||||||
|
localStorageObject[key] = localStorage[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(localStorageObject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const returnValue = await getFromLocalStorage("testValue");
|
||||||
|
|
||||||
|
expect(chrome.storage.local.get).toHaveBeenCalled();
|
||||||
|
expect(returnValue).toEqual({ testValue: "test" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setupExtensionDisconnectAction", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects a port to the extension background and sets up an onDisconnect listener", () => {
|
||||||
|
const onDisconnectCallback = jest.fn();
|
||||||
|
let port: chrome.runtime.Port;
|
||||||
|
jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
|
||||||
|
port = {
|
||||||
|
onDisconnect: {
|
||||||
|
addListener: onDisconnectCallback,
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
},
|
||||||
|
} as unknown as chrome.runtime.Port;
|
||||||
|
|
||||||
|
return port;
|
||||||
|
});
|
||||||
|
|
||||||
|
setupExtensionDisconnectAction(onDisconnectCallback);
|
||||||
|
|
||||||
|
expect(chrome.runtime.connect).toHaveBeenCalledWith({
|
||||||
|
name: AutofillPort.InjectedScript,
|
||||||
|
});
|
||||||
|
expect(port.onDisconnect.addListener).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setupAutofillInitDisconnectAction", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips setting up the extension disconnect action if the bitwardenAutofillInit object is not populated", () => {
|
||||||
|
const onDisconnectCallback = jest.fn();
|
||||||
|
window.bitwardenAutofillInit = undefined;
|
||||||
|
const portConnectSpy = jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
onDisconnect: {
|
||||||
|
addListener: onDisconnectCallback,
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
},
|
||||||
|
} as unknown as chrome.runtime.Port;
|
||||||
|
});
|
||||||
|
|
||||||
|
setupAutofillInitDisconnectAction(window);
|
||||||
|
|
||||||
|
expect(portConnectSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys the autofill init instance when the port is disconnected", () => {
|
||||||
|
let port: chrome.runtime.Port;
|
||||||
|
const autofillInitDestroy: CallableFunction = jest.fn();
|
||||||
|
window.bitwardenAutofillInit = {
|
||||||
|
destroy: autofillInitDestroy,
|
||||||
|
} as any;
|
||||||
|
jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
|
||||||
|
port = {
|
||||||
|
onDisconnect: {
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
},
|
||||||
|
} as unknown as chrome.runtime.Port;
|
||||||
|
|
||||||
|
return port;
|
||||||
|
});
|
||||||
|
|
||||||
|
setupAutofillInitDisconnectAction(window);
|
||||||
|
triggerPortOnDisconnectEvent(port as chrome.runtime.Port);
|
||||||
|
|
||||||
|
expect(chrome.runtime.connect).toHaveBeenCalled();
|
||||||
|
expect(port.onDisconnect.addListener).toHaveBeenCalled();
|
||||||
|
expect(autofillInitDestroy).toHaveBeenCalled();
|
||||||
|
expect(window.bitwardenAutofillInit).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AutofillPort } from "../enums/autofill-port.enums";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a random string of characters that formatted as a custom element name.
|
* Generates a random string of characters that formatted as a custom element name.
|
||||||
*/
|
*/
|
||||||
@@ -103,9 +105,57 @@ function setElementStyles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data from local storage based on the keys provided.
|
||||||
|
*
|
||||||
|
* @param keys - String or array of strings of keys to get from local storage
|
||||||
|
*/
|
||||||
|
async function getFromLocalStorage(keys: string | string[]): Promise<Record<string, any>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.get(keys, (storage: Record<string, any>) => resolve(storage));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a long-lived connection with the extension background
|
||||||
|
* and triggers an onDisconnect event if the extension context
|
||||||
|
* is invalidated.
|
||||||
|
*
|
||||||
|
* @param callback - Callback function to run when the extension disconnects
|
||||||
|
*/
|
||||||
|
function setupExtensionDisconnectAction(callback: (port: chrome.runtime.Port) => void) {
|
||||||
|
const port = chrome.runtime.connect({ name: AutofillPort.InjectedScript });
|
||||||
|
const onDisconnectCallback = (disconnectedPort: chrome.runtime.Port) => {
|
||||||
|
callback(disconnectedPort);
|
||||||
|
port.onDisconnect.removeListener(onDisconnectCallback);
|
||||||
|
};
|
||||||
|
port.onDisconnect.addListener(onDisconnectCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles setup of the extension disconnect action for the autofill init class
|
||||||
|
* in both instances where the overlay might or might not be initialized.
|
||||||
|
*
|
||||||
|
* @param windowContext - The global window context
|
||||||
|
*/
|
||||||
|
function setupAutofillInitDisconnectAction(windowContext: Window) {
|
||||||
|
if (!windowContext.bitwardenAutofillInit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDisconnectCallback = () => {
|
||||||
|
windowContext.bitwardenAutofillInit.destroy();
|
||||||
|
delete windowContext.bitwardenAutofillInit;
|
||||||
|
};
|
||||||
|
setupExtensionDisconnectAction(onDisconnectCallback);
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
generateRandomCustomElementName,
|
generateRandomCustomElementName,
|
||||||
buildSvgDomElement,
|
buildSvgDomElement,
|
||||||
sendExtensionMessage,
|
sendExtensionMessage,
|
||||||
setElementStyles,
|
setElementStyles,
|
||||||
|
getFromLocalStorage,
|
||||||
|
setupExtensionDisconnectAction,
|
||||||
|
setupAutofillInitDisconnectAction,
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
||||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||||
|
|
||||||
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
|
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
|
||||||
@@ -7,7 +10,7 @@ import { BrowserStateService } from "../platform/services/abstractions/browser-s
|
|||||||
const IdleInterval = 60 * 5; // 5 minutes
|
const IdleInterval = 60 * 5; // 5 minutes
|
||||||
|
|
||||||
export default class IdleBackground {
|
export default class IdleBackground {
|
||||||
private idle: any;
|
private idle: typeof chrome.idle | typeof browser.idle | null;
|
||||||
private idleTimer: number = null;
|
private idleTimer: number = null;
|
||||||
private idleState = "active";
|
private idleState = "active";
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ export default class IdleBackground {
|
|||||||
private vaultTimeoutService: VaultTimeoutService,
|
private vaultTimeoutService: VaultTimeoutService,
|
||||||
private stateService: BrowserStateService,
|
private stateService: BrowserStateService,
|
||||||
private notificationsService: NotificationsService,
|
private notificationsService: NotificationsService,
|
||||||
|
private accountService: AccountService,
|
||||||
) {
|
) {
|
||||||
this.idle = chrome.idle || (browser != null ? browser.idle : null);
|
this.idle = chrome.idle || (browser != null ? browser.idle : null);
|
||||||
}
|
}
|
||||||
@@ -39,21 +43,27 @@ export default class IdleBackground {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.idle.onStateChanged) {
|
if (this.idle.onStateChanged) {
|
||||||
this.idle.onStateChanged.addListener(async (newState: string) => {
|
this.idle.onStateChanged.addListener(
|
||||||
if (newState === "locked") {
|
async (newState: chrome.idle.IdleState | browser.idle.IdleState) => {
|
||||||
// If the screen is locked or the screensaver activates
|
if (newState === "locked") {
|
||||||
const timeout = await this.stateService.getVaultTimeout();
|
// Need to check if any of the current users have their timeout set to `onLocked`
|
||||||
if (timeout === -2) {
|
const allUsers = await firstValueFrom(this.accountService.accounts$);
|
||||||
// On System Lock vault timeout option
|
for (const userId in allUsers) {
|
||||||
const action = await this.stateService.getVaultTimeoutAction();
|
// If the screen is locked or the screensaver activates
|
||||||
if (action === VaultTimeoutAction.LogOut) {
|
const timeout = await this.stateService.getVaultTimeout({ userId: userId });
|
||||||
await this.vaultTimeoutService.logOut();
|
if (timeout === -2) {
|
||||||
} else {
|
// On System Lock vault timeout option
|
||||||
await this.vaultTimeoutService.lock();
|
const action = await this.stateService.getVaultTimeoutAction({ userId: userId });
|
||||||
|
if (action === VaultTimeoutAction.LogOut) {
|
||||||
|
await this.vaultTimeoutService.logOut(userId);
|
||||||
|
} else {
|
||||||
|
await this.vaultTimeoutService.lock(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -566,6 +566,7 @@ export default class MainBackground {
|
|||||||
this.logService,
|
this.logService,
|
||||||
this.settingsService,
|
this.settingsService,
|
||||||
this.userVerificationService,
|
this.userVerificationService,
|
||||||
|
this.configService,
|
||||||
);
|
);
|
||||||
this.auditService = new AuditService(this.cryptoFunctionService, this.apiService);
|
this.auditService = new AuditService(this.cryptoFunctionService, this.apiService);
|
||||||
|
|
||||||
@@ -729,6 +730,7 @@ export default class MainBackground {
|
|||||||
this.vaultTimeoutService,
|
this.vaultTimeoutService,
|
||||||
this.stateService,
|
this.stateService,
|
||||||
this.notificationsService,
|
this.notificationsService,
|
||||||
|
this.accountService,
|
||||||
);
|
);
|
||||||
this.webRequestBackground = new WebRequestBackground(
|
this.webRequestBackground = new WebRequestBackground(
|
||||||
this.platformUtilsService,
|
this.platformUtilsService,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
|
||||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
@@ -97,9 +96,9 @@ export default class RuntimeBackground {
|
|||||||
await closeUnlockPopout();
|
await closeUnlockPopout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.notificationsService.updateConnection(msg.command === "loggedIn");
|
||||||
await this.main.refreshBadge();
|
await this.main.refreshBadge();
|
||||||
await this.main.refreshMenu(false);
|
await this.main.refreshMenu(false);
|
||||||
this.notificationsService.updateConnection(msg.command === "unlocked");
|
|
||||||
this.systemService.cancelProcessReload();
|
this.systemService.cancelProcessReload();
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -133,11 +132,7 @@ export default class RuntimeBackground {
|
|||||||
await this.main.openPopup();
|
await this.main.openPopup();
|
||||||
break;
|
break;
|
||||||
case "triggerAutofillScriptInjection":
|
case "triggerAutofillScriptInjection":
|
||||||
await this.autofillService.injectAutofillScripts(
|
await this.autofillService.injectAutofillScripts(sender.tab, sender.frameId);
|
||||||
sender,
|
|
||||||
await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillV2),
|
|
||||||
await this.configService.getFeatureFlag<boolean>(FeatureFlag.AutofillOverlay),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "bgCollectPageDetails":
|
case "bgCollectPageDetails":
|
||||||
await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId);
|
await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId);
|
||||||
@@ -325,6 +320,8 @@ export default class RuntimeBackground {
|
|||||||
|
|
||||||
private async checkOnInstalled() {
|
private async checkOnInstalled() {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
|
this.autofillService.loadAutofillScriptsOnInstall();
|
||||||
|
|
||||||
if (this.onInstalledReason != null) {
|
if (this.onInstalledReason != null) {
|
||||||
if (this.onInstalledReason === "install") {
|
if (this.onInstalledReason === "install") {
|
||||||
BrowserApi.createNewTab("https://bitwarden.com/browser-start/");
|
BrowserApi.createNewTab("https://bitwarden.com/browser-start/");
|
||||||
|
|||||||
@@ -24,12 +24,6 @@
|
|||||||
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
||||||
"run_at": "document_start"
|
"run_at": "document_start"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"all_frames": false,
|
|
||||||
"js": ["content/message_handler.js"],
|
|
||||||
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
|
||||||
"run_at": "document_start"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"css": ["content/autofill.css"],
|
"css": ["content/autofill.css"],
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ const runtime = {
|
|||||||
removeListener: jest.fn(),
|
removeListener: jest.fn(),
|
||||||
},
|
},
|
||||||
sendMessage: jest.fn(),
|
sendMessage: jest.fn(),
|
||||||
getManifest: jest.fn(),
|
getManifest: jest.fn(() => ({ version: 2 })),
|
||||||
getURL: jest.fn((path) => `chrome-extension://id/${path}`),
|
getURL: jest.fn((path) => `chrome-extension://id/${path}`),
|
||||||
connect: jest.fn(),
|
connect: jest.fn(),
|
||||||
onConnect: {
|
onConnect: {
|
||||||
addListener: jest.fn(),
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
394
apps/desktop/src/auth/lock.component.spec.ts
Normal file
394
apps/desktop/src/auth/lock.component.spec.ts
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||||
|
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
|
||||||
|
import { ActivatedRoute } from "@angular/router";
|
||||||
|
import { MockProxy, mock } from "jest-mock-extended";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
|
import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component";
|
||||||
|
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||||
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||||
|
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||||
|
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||||
|
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
|
||||||
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||||
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { ElectronStateService } from "../platform/services/electron-state.service.abstraction";
|
||||||
|
|
||||||
|
import { LockComponent } from "./lock.component";
|
||||||
|
|
||||||
|
// ipc mock global
|
||||||
|
const isWindowVisibleMock = jest.fn();
|
||||||
|
(global as any).ipc = {
|
||||||
|
platform: {
|
||||||
|
biometric: {
|
||||||
|
enabled: jest.fn(),
|
||||||
|
},
|
||||||
|
isWindowVisible: isWindowVisibleMock,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("LockComponent", () => {
|
||||||
|
let component: LockComponent;
|
||||||
|
let fixture: ComponentFixture<LockComponent>;
|
||||||
|
let stateServiceMock: MockProxy<ElectronStateService>;
|
||||||
|
let messagingServiceMock: MockProxy<MessagingService>;
|
||||||
|
let broadcasterServiceMock: MockProxy<BroadcasterService>;
|
||||||
|
let platformUtilsServiceMock: MockProxy<PlatformUtilsService>;
|
||||||
|
let activatedRouteMock: MockProxy<ActivatedRoute>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stateServiceMock = mock<ElectronStateService>();
|
||||||
|
stateServiceMock.activeAccount$ = of(null);
|
||||||
|
|
||||||
|
messagingServiceMock = mock<MessagingService>();
|
||||||
|
broadcasterServiceMock = mock<BroadcasterService>();
|
||||||
|
platformUtilsServiceMock = mock<PlatformUtilsService>();
|
||||||
|
|
||||||
|
activatedRouteMock = mock<ActivatedRoute>();
|
||||||
|
activatedRouteMock.queryParams = mock<ActivatedRoute["queryParams"]>();
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [LockComponent, I18nPipe],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: I18nService,
|
||||||
|
useValue: mock<I18nService>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PlatformUtilsService,
|
||||||
|
useValue: platformUtilsServiceMock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MessagingService,
|
||||||
|
useValue: messagingServiceMock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CryptoService,
|
||||||
|
useValue: mock<CryptoService>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: VaultTimeoutService,
|
||||||
|
useValue: mock<VaultTimeoutService>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: VaultTimeoutSettingsService,
|
||||||
|
useValue: mock<VaultTimeoutSettingsService>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: EnvironmentService,
|
||||||
|
useValue: mock<EnvironmentService>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ElectronStateService,
|
||||||
|
useValue: stateServiceMock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ApiService,
|
||||||
|
useValue: mock<ApiService>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: activatedRouteMock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: BroadcasterService,
|
||||||
|
useValue: broadcasterServiceMock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PolicyApiServiceAbstraction,
|
||||||
|
useValue: mock<PolicyApiServiceAbstraction>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: InternalPolicyService,
|
||||||
|
useValue: mock<InternalPolicyService>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PasswordStrengthServiceAbstraction,
|
||||||
|
useValue: mock<PasswordStrengthServiceAbstraction>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: LogService,
|
||||||
|
useValue: mock<LogService>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: DialogService,
|
||||||
|
useValue: mock<DialogService>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: DeviceTrustCryptoServiceAbstraction,
|
||||||
|
useValue: mock<DeviceTrustCryptoServiceAbstraction>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: UserVerificationService,
|
||||||
|
useValue: mock<UserVerificationService>(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(LockComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ngOnInit", () => {
|
||||||
|
it("should call super.ngOnInit() once", async () => {
|
||||||
|
const superNgOnInitSpy = jest.spyOn(BaseLockComponent.prototype, "ngOnInit");
|
||||||
|
await component.ngOnInit();
|
||||||
|
expect(superNgOnInitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set "autoPromptBiometric" to true if "stateService.getDisableAutoBiometricsPrompt()" resolves to false', async () => {
|
||||||
|
stateServiceMock.getDisableAutoBiometricsPrompt.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await component.ngOnInit();
|
||||||
|
expect(component["autoPromptBiometric"]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set "autoPromptBiometric" to false if "stateService.getDisableAutoBiometricsPrompt()" resolves to true', async () => {
|
||||||
|
stateServiceMock.getDisableAutoBiometricsPrompt.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await component.ngOnInit();
|
||||||
|
expect(component["autoPromptBiometric"]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set "biometricReady" to true if "stateService.getBiometricReady()" resolves to true', async () => {
|
||||||
|
component["canUseBiometric"] = jest.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
await component.ngOnInit();
|
||||||
|
expect(component["biometricReady"]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set "biometricReady" to false if "stateService.getBiometricReady()" resolves to false', async () => {
|
||||||
|
component["canUseBiometric"] = jest.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
|
await component.ngOnInit();
|
||||||
|
expect(component["biometricReady"]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call displayBiometricUpdateWarning", async () => {
|
||||||
|
component["displayBiometricUpdateWarning"] = jest.fn();
|
||||||
|
await component.ngOnInit();
|
||||||
|
expect(component["displayBiometricUpdateWarning"]).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call delayedAskForBiometric", async () => {
|
||||||
|
component["delayedAskForBiometric"] = jest.fn();
|
||||||
|
await component.ngOnInit();
|
||||||
|
expect(component["delayedAskForBiometric"]).toHaveBeenCalledTimes(1);
|
||||||
|
expect(component["delayedAskForBiometric"]).toHaveBeenCalledWith(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call delayedAskForBiometric when queryParams change", async () => {
|
||||||
|
activatedRouteMock.queryParams = of({ promptBiometric: true });
|
||||||
|
component["delayedAskForBiometric"] = jest.fn();
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
expect(component["delayedAskForBiometric"]).toHaveBeenCalledTimes(1);
|
||||||
|
expect(component["delayedAskForBiometric"]).toHaveBeenCalledWith(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call messagingService.send", async () => {
|
||||||
|
await component.ngOnInit();
|
||||||
|
expect(messagingServiceMock.send).toHaveBeenCalledWith("getWindowIsFocused");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("broadcasterService.subscribe", () => {
|
||||||
|
it('should call onWindowHidden() when "broadcasterService.subscribe" is called with "windowHidden"', async () => {
|
||||||
|
component["onWindowHidden"] = jest.fn();
|
||||||
|
await component.ngOnInit();
|
||||||
|
broadcasterServiceMock.subscribe.mock.calls[0][1]({ command: "windowHidden" });
|
||||||
|
expect(component["onWindowHidden"]).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call focusInput() when "broadcasterService.subscribe" is called with "windowIsFocused" is true and deferFocus is false', async () => {
|
||||||
|
component["focusInput"] = jest.fn();
|
||||||
|
component["deferFocus"] = null;
|
||||||
|
await component.ngOnInit();
|
||||||
|
broadcasterServiceMock.subscribe.mock.calls[0][1]({
|
||||||
|
command: "windowIsFocused",
|
||||||
|
windowIsFocused: true,
|
||||||
|
} as any);
|
||||||
|
expect(component["deferFocus"]).toBe(false);
|
||||||
|
expect(component["focusInput"]).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call focusInput() when "broadcasterService.subscribe" is called with "windowIsFocused" is true and deferFocus is true', async () => {
|
||||||
|
component["focusInput"] = jest.fn();
|
||||||
|
component["deferFocus"] = null;
|
||||||
|
await component.ngOnInit();
|
||||||
|
broadcasterServiceMock.subscribe.mock.calls[0][1]({
|
||||||
|
command: "windowIsFocused",
|
||||||
|
windowIsFocused: false,
|
||||||
|
} as any);
|
||||||
|
expect(component["deferFocus"]).toBe(true);
|
||||||
|
expect(component["focusInput"]).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call focusInput() when "broadcasterService.subscribe" is called with "windowIsFocused" is true and deferFocus is true', async () => {
|
||||||
|
component["focusInput"] = jest.fn();
|
||||||
|
component["deferFocus"] = true;
|
||||||
|
await component.ngOnInit();
|
||||||
|
broadcasterServiceMock.subscribe.mock.calls[0][1]({
|
||||||
|
command: "windowIsFocused",
|
||||||
|
windowIsFocused: true,
|
||||||
|
} as any);
|
||||||
|
expect(component["deferFocus"]).toBe(false);
|
||||||
|
expect(component["focusInput"]).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call focusInput() when "broadcasterService.subscribe" is called with "windowIsFocused" is false and deferFocus is true', async () => {
|
||||||
|
component["focusInput"] = jest.fn();
|
||||||
|
component["deferFocus"] = true;
|
||||||
|
await component.ngOnInit();
|
||||||
|
broadcasterServiceMock.subscribe.mock.calls[0][1]({
|
||||||
|
command: "windowIsFocused",
|
||||||
|
windowIsFocused: false,
|
||||||
|
} as any);
|
||||||
|
expect(component["deferFocus"]).toBe(true);
|
||||||
|
expect(component["focusInput"]).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ngOnDestroy", () => {
|
||||||
|
it("should call super.ngOnDestroy()", () => {
|
||||||
|
const superNgOnDestroySpy = jest.spyOn(BaseLockComponent.prototype, "ngOnDestroy");
|
||||||
|
component.ngOnDestroy();
|
||||||
|
expect(superNgOnDestroySpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call broadcasterService.unsubscribe()", () => {
|
||||||
|
component.ngOnDestroy();
|
||||||
|
expect(broadcasterServiceMock.unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("focusInput", () => {
|
||||||
|
it('should call "focus" on #pin input if pinEnabled is true', () => {
|
||||||
|
component["pinEnabled"] = true;
|
||||||
|
global.document.getElementById = jest.fn().mockReturnValue({ focus: jest.fn() });
|
||||||
|
component["focusInput"]();
|
||||||
|
expect(global.document.getElementById).toHaveBeenCalledWith("pin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call "focus" on #masterPassword input if pinEnabled is false', () => {
|
||||||
|
component["pinEnabled"] = false;
|
||||||
|
global.document.getElementById = jest.fn().mockReturnValue({ focus: jest.fn() });
|
||||||
|
component["focusInput"]();
|
||||||
|
expect(global.document.getElementById).toHaveBeenCalledWith("masterPassword");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delayedAskForBiometric", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
component["supportsBiometric"] = true;
|
||||||
|
component["autoPromptBiometric"] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should wait for "delay" milliseconds', fakeAsync(async () => {
|
||||||
|
const delaySpy = jest.spyOn(global, "setTimeout");
|
||||||
|
component["delayedAskForBiometric"](5000);
|
||||||
|
|
||||||
|
tick(4000);
|
||||||
|
component["biometricAsked"] = false;
|
||||||
|
|
||||||
|
tick(1000);
|
||||||
|
component["biometricAsked"] = true;
|
||||||
|
|
||||||
|
expect(delaySpy).toHaveBeenCalledWith(expect.any(Function), 5000);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should return; if "params" is defined and "params.promptBiometric" is false', fakeAsync(async () => {
|
||||||
|
component["delayedAskForBiometric"](5000, { promptBiometric: false });
|
||||||
|
tick(5000);
|
||||||
|
expect(component["biometricAsked"]).toBe(false);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should not return; if "params" is defined and "params.promptBiometric" is true', fakeAsync(async () => {
|
||||||
|
component["delayedAskForBiometric"](5000, { promptBiometric: true });
|
||||||
|
tick(5000);
|
||||||
|
expect(component["biometricAsked"]).toBe(true);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should not return; if "params" is undefined', fakeAsync(async () => {
|
||||||
|
component["delayedAskForBiometric"](5000);
|
||||||
|
tick(5000);
|
||||||
|
expect(component["biometricAsked"]).toBe(true);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should return; if "supportsBiometric" is false', fakeAsync(async () => {
|
||||||
|
component["supportsBiometric"] = false;
|
||||||
|
component["delayedAskForBiometric"](5000);
|
||||||
|
tick(5000);
|
||||||
|
expect(component["biometricAsked"]).toBe(false);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should return; if "autoPromptBiometric" is false', fakeAsync(async () => {
|
||||||
|
component["autoPromptBiometric"] = false;
|
||||||
|
component["delayedAskForBiometric"](5000);
|
||||||
|
tick(5000);
|
||||||
|
expect(component["biometricAsked"]).toBe(false);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("should call unlockBiometric() if biometricAsked is false and window is visible", fakeAsync(async () => {
|
||||||
|
isWindowVisibleMock.mockResolvedValue(true);
|
||||||
|
component["unlockBiometric"] = jest.fn();
|
||||||
|
component["biometricAsked"] = false;
|
||||||
|
component["delayedAskForBiometric"](5000);
|
||||||
|
tick(5000);
|
||||||
|
|
||||||
|
expect(component["unlockBiometric"]).toHaveBeenCalledTimes(1);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("should not call unlockBiometric() if biometricAsked is false and window is not visible", fakeAsync(async () => {
|
||||||
|
isWindowVisibleMock.mockResolvedValue(false);
|
||||||
|
component["unlockBiometric"] = jest.fn();
|
||||||
|
component["biometricAsked"] = false;
|
||||||
|
component["delayedAskForBiometric"](5000);
|
||||||
|
tick(5000);
|
||||||
|
|
||||||
|
expect(component["unlockBiometric"]).toHaveBeenCalledTimes(0);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("should not call unlockBiometric() if biometricAsked is true", fakeAsync(async () => {
|
||||||
|
isWindowVisibleMock.mockResolvedValue(true);
|
||||||
|
component["unlockBiometric"] = jest.fn();
|
||||||
|
component["biometricAsked"] = true;
|
||||||
|
|
||||||
|
component["delayedAskForBiometric"](5000);
|
||||||
|
tick(5000);
|
||||||
|
|
||||||
|
expect(component["unlockBiometric"]).toHaveBeenCalledTimes(0);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canUseBiometric", () => {
|
||||||
|
it("should call getUserId() on stateService", async () => {
|
||||||
|
stateServiceMock.getUserId.mockResolvedValue("userId");
|
||||||
|
await component["canUseBiometric"]();
|
||||||
|
|
||||||
|
expect(ipc.platform.biometric.enabled).toHaveBeenCalledWith("userId");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onWindowHidden() should set "showPassword" to false', () => {
|
||||||
|
component["showPassword"] = true;
|
||||||
|
component["onWindowHidden"]();
|
||||||
|
expect(component["showPassword"]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, NgZone } from "@angular/core";
|
import { Component, NgZone } from "@angular/core";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
|
import { switchMap } from "rxjs";
|
||||||
|
|
||||||
import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component";
|
import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
@@ -31,6 +32,8 @@ const BroadcasterSubscriptionId = "LockComponent";
|
|||||||
export class LockComponent extends BaseLockComponent {
|
export class LockComponent extends BaseLockComponent {
|
||||||
private deferFocus: boolean = null;
|
private deferFocus: boolean = null;
|
||||||
protected biometricReady = false;
|
protected biometricReady = false;
|
||||||
|
private biometricAsked = false;
|
||||||
|
private autoPromptBiometric = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
router: Router,
|
router: Router,
|
||||||
@@ -78,23 +81,14 @@ export class LockComponent extends BaseLockComponent {
|
|||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
await super.ngOnInit();
|
await super.ngOnInit();
|
||||||
const autoPromptBiometric = !(await this.stateService.getDisableAutoBiometricsPrompt());
|
this.autoPromptBiometric = !(await this.stateService.getDisableAutoBiometricsPrompt());
|
||||||
this.biometricReady = await this.canUseBiometric();
|
this.biometricReady = await this.canUseBiometric();
|
||||||
|
|
||||||
await this.displayBiometricUpdateWarning();
|
await this.displayBiometricUpdateWarning();
|
||||||
|
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
this.delayedAskForBiometric(500);
|
||||||
this.route.queryParams.subscribe((params) => {
|
this.route.queryParams.pipe(switchMap((params) => this.delayedAskForBiometric(500, params)));
|
||||||
setTimeout(async () => {
|
|
||||||
if (!params.promptBiometric || !this.supportsBiometric || !autoPromptBiometric) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await ipc.platform.isWindowVisible()) {
|
|
||||||
this.unlockBiometric();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
|
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
|
||||||
this.ngZone.run(() => {
|
this.ngZone.run(() => {
|
||||||
switch (message.command) {
|
switch (message.command) {
|
||||||
@@ -128,6 +122,23 @@ export class LockComponent extends BaseLockComponent {
|
|||||||
this.showPassword = false;
|
this.showPassword = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async delayedAskForBiometric(delay: number, params?: any) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
|
||||||
|
if (params && !params.promptBiometric) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.supportsBiometric || !this.autoPromptBiometric || this.biometricAsked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.biometricAsked = true;
|
||||||
|
if (await ipc.platform.isWindowVisible()) {
|
||||||
|
this.unlockBiometric();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async canUseBiometric() {
|
private async canUseBiometric() {
|
||||||
const userId = await this.stateService.getUserId();
|
const userId = await this.stateService.getUserId();
|
||||||
return await ipc.platform.biometric.enabled(userId);
|
return await ipc.platform.biometric.enabled(userId);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request sent to the server to save a newly created prf key set for a credential.
|
||||||
|
*/
|
||||||
|
export class EnableCredentialEncryptionRequest {
|
||||||
|
/**
|
||||||
|
* The response received from the authenticator.
|
||||||
|
*/
|
||||||
|
deviceResponse: WebAuthnLoginAssertionResponseRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An encrypted token containing information the server needs to verify the credential.
|
||||||
|
*/
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
/** Used for vault encryption. See {@link RotateableKeySet.encryptedUserKey } */
|
||||||
|
encryptedUserKey?: string;
|
||||||
|
|
||||||
|
/** Used for vault encryption. See {@link RotateableKeySet.encryptedPublicKey } */
|
||||||
|
encryptedPublicKey?: string;
|
||||||
|
|
||||||
|
/** Used for vault encryption. See {@link RotateableKeySet.encryptedPrivateKey } */
|
||||||
|
encryptedPrivateKey?: string;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
|||||||
import { WebauthnLoginAuthenticatorResponseRequest } from "./webauthn-login-authenticator-response.request";
|
import { WebauthnLoginAuthenticatorResponseRequest } from "./webauthn-login-authenticator-response.request";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The response received from an authentiator after a successful attestation.
|
* The response received from an authenticator after a successful attestation.
|
||||||
* This request is used to save newly created webauthn login credentials to the server.
|
* This request is used to save newly created webauthn login credentials to the server.
|
||||||
*/
|
*/
|
||||||
export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthenticatorResponseRequest {
|
export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthenticatorResponseRequest {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Injectable } from "@angular/core";
|
|||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
|
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
|
||||||
|
import { CredentialAssertionOptionsResponse } from "@bitwarden/common/auth/services/webauthn-login/response/credential-assertion-options.response";
|
||||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||||
|
|
||||||
|
import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request";
|
||||||
import { SaveCredentialRequest } from "./request/save-credential.request";
|
import { SaveCredentialRequest } from "./request/save-credential.request";
|
||||||
import { WebauthnLoginCredentialCreateOptionsResponse } from "./response/webauthn-login-credential-create-options.response";
|
import { WebauthnLoginCredentialCreateOptionsResponse } from "./response/webauthn-login-credential-create-options.response";
|
||||||
import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response";
|
import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response";
|
||||||
@@ -15,10 +17,29 @@ export class WebAuthnLoginAdminApiService {
|
|||||||
async getCredentialCreateOptions(
|
async getCredentialCreateOptions(
|
||||||
request: SecretVerificationRequest,
|
request: SecretVerificationRequest,
|
||||||
): Promise<WebauthnLoginCredentialCreateOptionsResponse> {
|
): Promise<WebauthnLoginCredentialCreateOptionsResponse> {
|
||||||
const response = await this.apiService.send("POST", "/webauthn/options", request, true, true);
|
const response = await this.apiService.send(
|
||||||
|
"POST",
|
||||||
|
"/webauthn/attestation-options",
|
||||||
|
request,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
return new WebauthnLoginCredentialCreateOptionsResponse(response);
|
return new WebauthnLoginCredentialCreateOptionsResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCredentialAssertionOptions(
|
||||||
|
request: SecretVerificationRequest,
|
||||||
|
): Promise<CredentialAssertionOptionsResponse> {
|
||||||
|
const response = await this.apiService.send(
|
||||||
|
"POST",
|
||||||
|
"/webauthn/assertion-options",
|
||||||
|
request,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return new CredentialAssertionOptionsResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
async saveCredential(request: SaveCredentialRequest): Promise<boolean> {
|
async saveCredential(request: SaveCredentialRequest): Promise<boolean> {
|
||||||
await this.apiService.send("POST", "/webauthn", request, true, true);
|
await this.apiService.send("POST", "/webauthn", request, true, true);
|
||||||
return true;
|
return true;
|
||||||
@@ -31,4 +52,8 @@ export class WebAuthnLoginAdminApiService {
|
|||||||
async deleteCredential(credentialId: string, request: SecretVerificationRequest): Promise<void> {
|
async deleteCredential(credentialId: string, request: SecretVerificationRequest): Promise<void> {
|
||||||
await this.apiService.send("POST", `/webauthn/${credentialId}/delete`, request, true, true);
|
await this.apiService.send("POST", `/webauthn/${credentialId}/delete`, request, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCredential(request: EnableCredentialEncryptionRequest): Promise<void> {
|
||||||
|
await this.apiService.send("PUT", `/webauthn`, request, true, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
import { randomBytes } from "crypto";
|
||||||
|
|
||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { RotateableKeySet } from "@bitwarden/auth";
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
|
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
|
||||||
|
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||||
|
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||||
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
|
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||||
|
import { PrfKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
|
|
||||||
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
|
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
|
||||||
import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view";
|
import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view";
|
||||||
import { RotateableKeySetService } from "../rotateable-key-set.service";
|
import { RotateableKeySetService } from "../rotateable-key-set.service";
|
||||||
|
|
||||||
|
import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request";
|
||||||
import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service";
|
import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service";
|
||||||
import { WebauthnLoginAdminService } from "./webauthn-login-admin.service";
|
import { WebauthnLoginAdminService } from "./webauthn-login-admin.service";
|
||||||
|
|
||||||
@@ -18,10 +27,13 @@ describe("WebauthnAdminService", () => {
|
|||||||
let credentials: MockProxy<CredentialsContainer>;
|
let credentials: MockProxy<CredentialsContainer>;
|
||||||
let service!: WebauthnLoginAdminService;
|
let service!: WebauthnLoginAdminService;
|
||||||
|
|
||||||
|
let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
// Polyfill missing class
|
// Polyfill missing class
|
||||||
window.PublicKeyCredential = class {} as any;
|
window.PublicKeyCredential = class {} as any;
|
||||||
window.AuthenticatorAttestationResponse = class {} as any;
|
window.AuthenticatorAttestationResponse = class {} as any;
|
||||||
|
window.AuthenticatorAssertionResponse = class {} as any;
|
||||||
apiService = mock<WebAuthnLoginAdminApiService>();
|
apiService = mock<WebAuthnLoginAdminApiService>();
|
||||||
userVerificationService = mock<UserVerificationService>();
|
userVerificationService = mock<UserVerificationService>();
|
||||||
rotateableKeySetService = mock<RotateableKeySetService>();
|
rotateableKeySetService = mock<RotateableKeySetService>();
|
||||||
@@ -34,6 +46,20 @@ describe("WebauthnAdminService", () => {
|
|||||||
webAuthnLoginPrfCryptoService,
|
webAuthnLoginPrfCryptoService,
|
||||||
credentials,
|
credentials,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Save original global class
|
||||||
|
originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse;
|
||||||
|
// Mock the global AuthenticatorAssertionResponse class b/c the class is only available in secure contexts
|
||||||
|
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Restore global after all tests are done
|
||||||
|
global.AuthenticatorAssertionResponse = originalAuthenticatorAssertionResponse;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createCredential", () => {
|
describe("createCredential", () => {
|
||||||
@@ -70,6 +96,94 @@ describe("WebauthnAdminService", () => {
|
|||||||
expect(result.supportsPrf).toBe(true);
|
expect(result.supportsPrf).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("enableCredentialEncryption", () => {
|
||||||
|
it("should call the necessary methods to update the credential", async () => {
|
||||||
|
// Arrange
|
||||||
|
const response = new MockPublicKeyCredential();
|
||||||
|
const prfKeySet = new RotateableKeySet<PrfKey>(
|
||||||
|
new EncString("test_encryptedUserKey"),
|
||||||
|
new EncString("test_encryptedPublicKey"),
|
||||||
|
new EncString("test_encryptedPrivateKey"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assertionOptions: WebAuthnLoginCredentialAssertionView =
|
||||||
|
new WebAuthnLoginCredentialAssertionView(
|
||||||
|
"enable_credential_encryption_test_token",
|
||||||
|
new WebAuthnLoginAssertionResponseRequest(response),
|
||||||
|
{} as PrfKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = new EnableCredentialEncryptionRequest();
|
||||||
|
request.token = assertionOptions.token;
|
||||||
|
request.deviceResponse = assertionOptions.deviceResponse;
|
||||||
|
request.encryptedUserKey = prfKeySet.encryptedUserKey.encryptedString;
|
||||||
|
request.encryptedPublicKey = prfKeySet.encryptedPublicKey.encryptedString;
|
||||||
|
request.encryptedPrivateKey = prfKeySet.encryptedPrivateKey.encryptedString;
|
||||||
|
|
||||||
|
// Mock the necessary methods and services
|
||||||
|
const createKeySetMock = jest
|
||||||
|
.spyOn(rotateableKeySetService, "createKeySet")
|
||||||
|
.mockResolvedValue(prfKeySet);
|
||||||
|
const updateCredentialMock = jest.spyOn(apiService, "updateCredential").mockResolvedValue();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.enableCredentialEncryption(assertionOptions);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(createKeySetMock).toHaveBeenCalledWith(assertionOptions.prfKey);
|
||||||
|
expect(updateCredentialMock).toHaveBeenCalledWith(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when PRF Key is undefined", async () => {
|
||||||
|
// Arrange
|
||||||
|
const response = new MockPublicKeyCredential();
|
||||||
|
|
||||||
|
const assertionOptions: WebAuthnLoginCredentialAssertionView =
|
||||||
|
new WebAuthnLoginCredentialAssertionView(
|
||||||
|
"enable_credential_encryption_test_token",
|
||||||
|
new WebAuthnLoginAssertionResponseRequest(response),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock the necessary methods and services
|
||||||
|
const createKeySetMock = jest
|
||||||
|
.spyOn(rotateableKeySetService, "createKeySet")
|
||||||
|
.mockResolvedValue(null);
|
||||||
|
const updateCredentialMock = jest.spyOn(apiService, "updateCredential").mockResolvedValue();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
await service.enableCredentialEncryption(assertionOptions);
|
||||||
|
} catch (error) {
|
||||||
|
// Assert
|
||||||
|
expect(error).toEqual(new Error("invalid credential"));
|
||||||
|
expect(createKeySetMock).not.toHaveBeenCalled();
|
||||||
|
expect(updateCredentialMock).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when WehAuthnLoginCredentialAssertionView is undefined", async () => {
|
||||||
|
// Arrange
|
||||||
|
const assertionOptions: WebAuthnLoginCredentialAssertionView = undefined;
|
||||||
|
|
||||||
|
// Mock the necessary methods and services
|
||||||
|
const createKeySetMock = jest
|
||||||
|
.spyOn(rotateableKeySetService, "createKeySet")
|
||||||
|
.mockResolvedValue(null);
|
||||||
|
const updateCredentialMock = jest.spyOn(apiService, "updateCredential").mockResolvedValue();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
await service.enableCredentialEncryption(assertionOptions);
|
||||||
|
} catch (error) {
|
||||||
|
// Assert
|
||||||
|
expect(error).toEqual(new Error("invalid credential"));
|
||||||
|
expect(createKeySetMock).not.toHaveBeenCalled();
|
||||||
|
expect(updateCredentialMock).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createCredentialCreateOptions(): CredentialCreateOptionsView {
|
function createCredentialCreateOptions(): CredentialCreateOptionsView {
|
||||||
@@ -115,3 +229,58 @@ function createDeviceResponse({ prf = false }: { prf?: boolean } = {}): PublicKe
|
|||||||
|
|
||||||
return credential;
|
return credential;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mocks for the PublicKeyCredential and AuthenticatorAssertionResponse classes copied from webauthn-login.service.spec.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
// AuthenticatorAssertionResponse && PublicKeyCredential are only available in secure contexts
|
||||||
|
// so we need to mock them and assign them to the global object to make them available
|
||||||
|
// for the tests
|
||||||
|
class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse {
|
||||||
|
clientDataJSON: ArrayBuffer = randomBytes(32).buffer;
|
||||||
|
authenticatorData: ArrayBuffer = randomBytes(196).buffer;
|
||||||
|
signature: ArrayBuffer = randomBytes(72).buffer;
|
||||||
|
userHandle: ArrayBuffer = randomBytes(16).buffer;
|
||||||
|
|
||||||
|
clientDataJSONB64Str = Utils.fromBufferToUrlB64(this.clientDataJSON);
|
||||||
|
authenticatorDataB64Str = Utils.fromBufferToUrlB64(this.authenticatorData);
|
||||||
|
signatureB64Str = Utils.fromBufferToUrlB64(this.signature);
|
||||||
|
userHandleB64Str = Utils.fromBufferToUrlB64(this.userHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockPublicKeyCredential implements PublicKeyCredential {
|
||||||
|
authenticatorAttachment = "cross-platform";
|
||||||
|
id = "mockCredentialId";
|
||||||
|
type = "public-key";
|
||||||
|
rawId: ArrayBuffer = randomBytes(32).buffer;
|
||||||
|
rawIdB64Str = Utils.fromBufferToUrlB64(this.rawId);
|
||||||
|
|
||||||
|
response: MockAuthenticatorAssertionResponse = new MockAuthenticatorAssertionResponse();
|
||||||
|
|
||||||
|
// Use random 64 character hex string (32 bytes - matters for symmetric key creation)
|
||||||
|
// to represent the prf key binary data and convert to ArrayBuffer
|
||||||
|
// Creating the array buffer from a known hex value allows us to
|
||||||
|
// assert on the value in tests
|
||||||
|
private prfKeyArrayBuffer: ArrayBuffer = Utils.hexStringToArrayBuffer(
|
||||||
|
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
);
|
||||||
|
|
||||||
|
getClientExtensionResults(): any {
|
||||||
|
return {
|
||||||
|
prf: {
|
||||||
|
results: {
|
||||||
|
first: this.prfKeyArrayBuffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static isConditionalMediationAvailable(): Promise<boolean> {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap,
|
|||||||
import { PrfKeySet } from "@bitwarden/auth";
|
import { PrfKeySet } from "@bitwarden/auth";
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
|
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
|
||||||
|
import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
|
||||||
|
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||||
import { Verification } from "@bitwarden/common/auth/types/verification";
|
import { Verification } from "@bitwarden/common/auth/types/verification";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn
|
|||||||
import { WebauthnLoginCredentialView } from "../../views/webauthn-login-credential.view";
|
import { WebauthnLoginCredentialView } from "../../views/webauthn-login-credential.view";
|
||||||
import { RotateableKeySetService } from "../rotateable-key-set.service";
|
import { RotateableKeySetService } from "../rotateable-key-set.service";
|
||||||
|
|
||||||
|
import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request";
|
||||||
import { SaveCredentialRequest } from "./request/save-credential.request";
|
import { SaveCredentialRequest } from "./request/save-credential.request";
|
||||||
import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request";
|
import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request";
|
||||||
import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service";
|
import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service";
|
||||||
@@ -52,14 +55,31 @@ export class WebauthnLoginAdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the credential attestation options needed for initiating the WebAuthnLogin credentail creation process.
|
* Get the credential assertion options needed for initiating the WebAuthnLogin credential update process.
|
||||||
|
* The options contains assertion options and other data for the authenticator.
|
||||||
|
* This method requires user verification.
|
||||||
|
*
|
||||||
|
* @param verification User verification data to be used for the request.
|
||||||
|
* @returns The credential assertion options and a token to be used for the credential update request.
|
||||||
|
*/
|
||||||
|
async getCredentialAssertOptions(
|
||||||
|
verification: Verification,
|
||||||
|
): Promise<WebAuthnLoginCredentialAssertionOptionsView> {
|
||||||
|
const request = await this.userVerificationService.buildRequest(verification);
|
||||||
|
const response = await this.apiService.getCredentialAssertionOptions(request);
|
||||||
|
return new WebAuthnLoginCredentialAssertionOptionsView(response.options, response.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the credential attestation options needed for initiating the WebAuthnLogin credential creation process.
|
||||||
* The options contains a challenge and other data for the authenticator.
|
* The options contains a challenge and other data for the authenticator.
|
||||||
* This method requires user verification.
|
* This method requires user verification.
|
||||||
*
|
*
|
||||||
* @param verification User verification data to be used for the request.
|
* @param verification User verification data to be used for the request.
|
||||||
* @returns The credential attestation options and a token to be used for the credential creation request.
|
* @returns The credential attestation options and a token to be used for the credential creation request.
|
||||||
*/
|
*/
|
||||||
async getCredentialCreateOptions(
|
|
||||||
|
async getCredentialAttestationOptions(
|
||||||
verification: Verification,
|
verification: Verification,
|
||||||
): Promise<CredentialCreateOptionsView> {
|
): Promise<CredentialCreateOptionsView> {
|
||||||
const request = await this.userVerificationService.buildRequest(verification);
|
const request = await this.userVerificationService.buildRequest(verification);
|
||||||
@@ -169,6 +189,36 @@ export class WebauthnLoginAdminService {
|
|||||||
this.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable encryption for a credential that has already been saved to the server.
|
||||||
|
* This will update the KeySet associated with the credential in the database.
|
||||||
|
* We short circuit the process here incase the WebAuthnLoginCredential doesn't support PRF or
|
||||||
|
* if there was a problem with the Credential Assertion.
|
||||||
|
*
|
||||||
|
* @param assertionOptions Options received from the server using `getCredentialAssertOptions`.
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
async enableCredentialEncryption(
|
||||||
|
assertionOptions: WebAuthnLoginCredentialAssertionView,
|
||||||
|
): Promise<void> {
|
||||||
|
if (assertionOptions === undefined || assertionOptions?.prfKey === undefined) {
|
||||||
|
throw new Error("invalid credential");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prfKeySet: PrfKeySet = await this.rotateableKeySetService.createKeySet(
|
||||||
|
assertionOptions.prfKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = new EnableCredentialEncryptionRequest();
|
||||||
|
request.token = assertionOptions.token;
|
||||||
|
request.deviceResponse = assertionOptions.deviceResponse;
|
||||||
|
request.encryptedUserKey = prfKeySet.encryptedUserKey.encryptedString;
|
||||||
|
request.encryptedPublicKey = prfKeySet.encryptedPublicKey.encryptedString;
|
||||||
|
request.encryptedPrivateKey = prfKeySet.encryptedPrivateKey.encryptedString;
|
||||||
|
await this.apiService.updateCredential(request);
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of webauthn credentials saved on the server.
|
* List of webauthn credentials saved on the server.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export class CreateCredentialDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.credentialOptions = await this.webauthnService.getCredentialCreateOptions(
|
this.credentialOptions = await this.webauthnService.getCredentialAttestationOptions(
|
||||||
this.formGroup.value.userVerification.secret,
|
this.formGroup.value.userVerification.secret,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||||
|
<bit-dialog dialogSize="large" [loading]="loading$ | async">
|
||||||
|
<span bitDialogTitle
|
||||||
|
>{{ "enablePasskeyEncryption" | i18n }}
|
||||||
|
<span *ngIf="credential" class="tw-text-sm tw-normal-case tw-text-muted">{{
|
||||||
|
credential.name
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
<ng-container bitDialogContent>
|
||||||
|
<ng-container *ngIf="!credential">
|
||||||
|
<i class="bwi bwi-spinner bwi-spin tw-ml-1" aria-hidden="true"></i>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="credential">
|
||||||
|
<p bitTypography="body1">{{ "useForVaultEncryptionInfo" | i18n }}</p>
|
||||||
|
|
||||||
|
<ng-container formGroupName="userVerification">
|
||||||
|
<app-user-verification
|
||||||
|
formControlName="secret"
|
||||||
|
[(invalidSecret)]="invalidSecret"
|
||||||
|
></app-user-verification>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container bitDialogFooter>
|
||||||
|
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||||
|
{{ "submit" | i18n }}
|
||||||
|
</button>
|
||||||
|
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
|
||||||
|
{{ "cancel" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
</bit-dialog>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||||
|
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||||
|
import { FormBuilder, Validators } from "@angular/forms";
|
||||||
|
import { Subject } from "rxjs";
|
||||||
|
import { takeUntil } from "rxjs/operators";
|
||||||
|
|
||||||
|
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
|
||||||
|
import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
|
||||||
|
import { Verification } from "@bitwarden/common/auth/types/verification";
|
||||||
|
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||||
|
import { DialogService } from "@bitwarden/components/src/dialog/dialog.service";
|
||||||
|
|
||||||
|
import { WebauthnLoginAdminService } from "../../../core/services/webauthn-login/webauthn-login-admin.service";
|
||||||
|
import { WebauthnLoginCredentialView } from "../../../core/views/webauthn-login-credential.view";
|
||||||
|
|
||||||
|
export interface EnableEncryptionDialogParams {
|
||||||
|
credentialId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
templateUrl: "enable-encryption-dialog.component.html",
|
||||||
|
})
|
||||||
|
export class EnableEncryptionDialogComponent implements OnInit, OnDestroy {
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
protected invalidSecret = false;
|
||||||
|
protected formGroup = this.formBuilder.group({
|
||||||
|
userVerification: this.formBuilder.group({
|
||||||
|
secret: [null as Verification | null, Validators.required],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected credential?: WebauthnLoginCredentialView;
|
||||||
|
protected credentialOptions?: WebAuthnLoginCredentialAssertionOptionsView;
|
||||||
|
protected loading$ = this.webauthnService.loading$;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DIALOG_DATA) private params: EnableEncryptionDialogParams,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private dialogRef: DialogRef,
|
||||||
|
private webauthnService: WebauthnLoginAdminService,
|
||||||
|
private webauthnLoginService: WebAuthnLoginServiceAbstraction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.webauthnService
|
||||||
|
.getCredential$(this.params.credentialId)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((credential: any) => (this.credential = credential));
|
||||||
|
}
|
||||||
|
|
||||||
|
submit = async () => {
|
||||||
|
if (this.credential === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dialogRef.disableClose = true;
|
||||||
|
try {
|
||||||
|
this.credentialOptions = await this.webauthnService.getCredentialAssertOptions(
|
||||||
|
this.formGroup.value.userVerification.secret,
|
||||||
|
);
|
||||||
|
await this.webauthnService.enableCredentialEncryption(
|
||||||
|
await this.webauthnLoginService.assertCredential(this.credentialOptions),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ErrorResponse && error.statusCode === 400) {
|
||||||
|
this.invalidSecret = true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dialogRef.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strongly typed helper to open a EnableEncryptionDialogComponent
|
||||||
|
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||||
|
* @param config Configuration for the dialog
|
||||||
|
*/
|
||||||
|
export const openEnableCredentialDialogComponent = (
|
||||||
|
dialogService: DialogService,
|
||||||
|
config: DialogConfig<EnableEncryptionDialogParams>,
|
||||||
|
) => {
|
||||||
|
return dialogService.open<unknown>(EnableEncryptionDialogComponent, config);
|
||||||
|
};
|
||||||
@@ -39,8 +39,16 @@
|
|||||||
<span bitTypography="body1" class="tw-text-muted">{{ "usedForEncryption" | i18n }}</span>
|
<span bitTypography="body1" class="tw-text-muted">{{ "usedForEncryption" | i18n }}</span>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Supported">
|
<ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Supported">
|
||||||
<i class="bwi bwi-lock-encrypted"></i>
|
<button
|
||||||
<span bitTypography="body1" class="tw-text-muted">{{ "encryptionNotEnabled" | i18n }}</span>
|
type="button"
|
||||||
|
bitLink
|
||||||
|
[disabled]="loading"
|
||||||
|
[attr.aria-label]="('enablePasskeyEncryption' | i18n) + ' ' + credential.name"
|
||||||
|
(click)="enableEncryption(credential.id)"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-lock-encrypted"></i>
|
||||||
|
{{ "enablePasskeyEncryption" | i18n }}
|
||||||
|
</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<span
|
<span
|
||||||
*ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Unsupported"
|
*ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Unsupported"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { WebauthnLoginCredentialView } from "../../core/views/webauthn-login-cre
|
|||||||
|
|
||||||
import { openCreateCredentialDialog } from "./create-credential-dialog/create-credential-dialog.component";
|
import { openCreateCredentialDialog } from "./create-credential-dialog/create-credential-dialog.component";
|
||||||
import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
|
import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
|
||||||
|
import { openEnableCredentialDialogComponent } from "./enable-encryption-dialog/enable-encryption-dialog.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-webauthn-login-settings",
|
selector: "app-webauthn-login-settings",
|
||||||
@@ -83,4 +84,8 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy {
|
|||||||
protected deleteCredential(credentialId: string) {
|
protected deleteCredential(credentialId: string) {
|
||||||
openDeleteCredentialDialogComponent(this.dialogService, { data: { credentialId } });
|
openDeleteCredentialDialogComponent(this.dialogService, { data: { credentialId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected enableEncryption(credentialId: string) {
|
||||||
|
openEnableCredentialDialogComponent(this.dialogService, { data: { credentialId } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { UserVerificationModule } from "../../shared/components/user-verificatio
|
|||||||
|
|
||||||
import { CreateCredentialDialogComponent } from "./create-credential-dialog/create-credential-dialog.component";
|
import { CreateCredentialDialogComponent } from "./create-credential-dialog/create-credential-dialog.component";
|
||||||
import { DeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
|
import { DeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
|
||||||
|
import { EnableEncryptionDialogComponent } from "./enable-encryption-dialog/enable-encryption-dialog.component";
|
||||||
import { WebauthnLoginSettingsComponent } from "./webauthn-login-settings.component";
|
import { WebauthnLoginSettingsComponent } from "./webauthn-login-settings.component";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@@ -16,6 +17,7 @@ import { WebauthnLoginSettingsComponent } from "./webauthn-login-settings.compon
|
|||||||
WebauthnLoginSettingsComponent,
|
WebauthnLoginSettingsComponent,
|
||||||
CreateCredentialDialogComponent,
|
CreateCredentialDialogComponent,
|
||||||
DeleteCredentialDialogComponent,
|
DeleteCredentialDialogComponent,
|
||||||
|
EnableEncryptionDialogComponent,
|
||||||
],
|
],
|
||||||
exports: [WebauthnLoginSettingsComponent],
|
exports: [WebauthnLoginSettingsComponent],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -674,8 +674,8 @@
|
|||||||
"encryptionNotSupported": {
|
"encryptionNotSupported": {
|
||||||
"message": "Encryption not supported"
|
"message": "Encryption not supported"
|
||||||
},
|
},
|
||||||
"encryptionNotEnabled": {
|
"enablePasskeyEncryption": {
|
||||||
"message": "Encryption not enabled"
|
"message": "Set up encryption"
|
||||||
},
|
},
|
||||||
"usedForEncryption": {
|
"usedForEncryption": {
|
||||||
"message": "Used for encryption"
|
"message": "Used for encryption"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class FakeStorageService implements AbstractStorageService {
|
|||||||
return Promise.resolve(this.store[key] != null);
|
return Promise.resolve(this.store[key] != null);
|
||||||
}
|
}
|
||||||
save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
|
save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
|
||||||
this.mock.save(key, options);
|
this.mock.save(key, obj, options);
|
||||||
this.store[key] = obj;
|
this.store[key] = obj;
|
||||||
this.updatesSubject.next({ key: key, updateType: "save" });
|
this.updatesSubject.next({ key: key, updateType: "save" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ export function trackEmissions<T>(observable: Observable<T>): T[] {
|
|||||||
case "boolean":
|
case "boolean":
|
||||||
emissions.push(value);
|
emissions.push(value);
|
||||||
break;
|
break;
|
||||||
|
case "symbol":
|
||||||
|
// Cheating types to make symbols work at all
|
||||||
|
emissions.push(value.toString() as T);
|
||||||
|
break;
|
||||||
default: {
|
default: {
|
||||||
emissions.push(clone(value));
|
emissions.push(clone(value));
|
||||||
}
|
}
|
||||||
@@ -85,7 +89,7 @@ function clone(value: any): any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function awaitAsync(ms = 0) {
|
export async function awaitAsync(ms = 1) {
|
||||||
if (ms < 1) {
|
if (ms < 1) {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* need to update test environment so trackEmissions works appropriately
|
* need to update test environment so trackEmissions works appropriately
|
||||||
* @jest-environment ../shared/test.environment.ts
|
* @jest-environment ../shared/test.environment.ts
|
||||||
*/
|
*/
|
||||||
import { any, mock } from "jest-mock-extended";
|
import { any, anySymbol, mock } from "jest-mock-extended";
|
||||||
import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs";
|
import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs";
|
||||||
import { Jsonify } from "type-fest";
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
|||||||
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
|
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
|
||||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
import { KeyDefinition } from "../key-definition";
|
import { KeyDefinition, userKeyBuilder } from "../key-definition";
|
||||||
import { StateDefinition } from "../state-definition";
|
import { StateDefinition } from "../state-definition";
|
||||||
|
|
||||||
import { DefaultActiveUserState } from "./default-active-user-state";
|
import { DefaultActiveUserState } from "./default-active-user-state";
|
||||||
@@ -32,9 +32,10 @@ class TestState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const testStateDefinition = new StateDefinition("fake", "disk");
|
const testStateDefinition = new StateDefinition("fake", "disk");
|
||||||
|
const cleanupDelayMs = 10;
|
||||||
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
|
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
|
||||||
deserializer: TestState.fromJSON,
|
deserializer: TestState.fromJSON,
|
||||||
|
cleanupDelayMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("DefaultActiveUserState", () => {
|
describe("DefaultActiveUserState", () => {
|
||||||
@@ -56,10 +57,14 @@ describe("DefaultActiveUserState", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeUserId = (id: string) => {
|
||||||
|
return id != null ? (`00000000-0000-1000-a000-00000000000${id}` as UserId) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const changeActiveUser = async (id: string) => {
|
const changeActiveUser = async (id: string) => {
|
||||||
const userId = id != null ? `00000000-0000-1000-a000-00000000000${id}` : undefined;
|
const userId = makeUserId(id);
|
||||||
activeAccountSubject.next({
|
activeAccountSubject.next({
|
||||||
id: userId as UserId,
|
id: userId,
|
||||||
email: `test${id}@example.com`,
|
email: `test${id}@example.com`,
|
||||||
name: `Test User ${id}`,
|
name: `Test User ${id}`,
|
||||||
status: AuthenticationStatus.Unlocked,
|
status: AuthenticationStatus.Unlocked,
|
||||||
@@ -90,7 +95,7 @@ describe("DefaultActiveUserState", () => {
|
|||||||
const emissions = trackEmissions(userState.state$);
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
|
||||||
// User signs in
|
// User signs in
|
||||||
changeActiveUser("1");
|
await changeActiveUser("1");
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
|
|
||||||
// Service does an update
|
// Service does an update
|
||||||
@@ -111,17 +116,17 @@ describe("DefaultActiveUserState", () => {
|
|||||||
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
||||||
any(),
|
any(), // options
|
||||||
);
|
);
|
||||||
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
||||||
any(),
|
any(), // options
|
||||||
);
|
);
|
||||||
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
|
||||||
3,
|
3,
|
||||||
"user_00000000-0000-1000-a000-000000000002_fake_fake",
|
"user_00000000-0000-1000-a000-000000000002_fake_fake",
|
||||||
any(),
|
any(), // options
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should only have saved data for the first user
|
// Should only have saved data for the first user
|
||||||
@@ -129,7 +134,8 @@ describe("DefaultActiveUserState", () => {
|
|||||||
expect(diskStorageService.mock.save).toHaveBeenNthCalledWith(
|
expect(diskStorageService.mock.save).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
"user_00000000-0000-1000-a000-000000000001_fake_fake",
|
||||||
any(),
|
updatedState,
|
||||||
|
any(), // options
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,15 +189,17 @@ describe("DefaultActiveUserState", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not emit a previous users value if that user is no longer active", async () => {
|
it("should not emit a previous users value if that user is no longer active", async () => {
|
||||||
|
const user1Data: Jsonify<TestState> = {
|
||||||
|
date: "2020-09-21T13:14:17.648Z",
|
||||||
|
array: ["value"],
|
||||||
|
};
|
||||||
|
const user2Data: Jsonify<TestState> = {
|
||||||
|
date: "2020-09-21T13:14:17.648Z",
|
||||||
|
array: [],
|
||||||
|
};
|
||||||
diskStorageService.internalUpdateStore({
|
diskStorageService.internalUpdateStore({
|
||||||
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
|
"user_00000000-0000-1000-a000-000000000001_fake_fake": user1Data,
|
||||||
date: "2020-09-21T13:14:17.648Z",
|
"user_00000000-0000-1000-a000-000000000002_fake_fake": user2Data,
|
||||||
array: ["value"],
|
|
||||||
} as Jsonify<TestState>,
|
|
||||||
"user_00000000-0000-1000-a000-000000000002_fake_fake": {
|
|
||||||
date: "2020-09-21T13:14:17.648Z",
|
|
||||||
array: [],
|
|
||||||
} as Jsonify<TestState>,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// This starts one subscription on the observable for tracking emissions throughout
|
// This starts one subscription on the observable for tracking emissions throughout
|
||||||
@@ -203,7 +211,7 @@ describe("DefaultActiveUserState", () => {
|
|||||||
|
|
||||||
// This should always return a value right await
|
// This should always return a value right await
|
||||||
const value = await firstValueFrom(userState.state$);
|
const value = await firstValueFrom(userState.state$);
|
||||||
expect(value).toBeTruthy();
|
expect(value).toEqual(user1Data);
|
||||||
|
|
||||||
// Make it such that there is no active user
|
// Make it such that there is no active user
|
||||||
await changeActiveUser(undefined);
|
await changeActiveUser(undefined);
|
||||||
@@ -222,20 +230,34 @@ describe("DefaultActiveUserState", () => {
|
|||||||
rejectedError = err;
|
rejectedError = err;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolvedValue).toBeFalsy();
|
expect(resolvedValue).toBeUndefined();
|
||||||
expect(rejectedError).toBeTruthy();
|
expect(rejectedError).not.toBeUndefined();
|
||||||
expect(rejectedError.message).toBe("Timeout has occurred");
|
expect(rejectedError.message).toBe("Timeout has occurred");
|
||||||
|
|
||||||
// We need to figure out if something should be emitted
|
// We need to figure out if something should be emitted
|
||||||
// when there becomes no active user, if we don't want that to emit
|
// when there becomes no active user, if we don't want that to emit
|
||||||
// this value is correct.
|
// this value is correct.
|
||||||
expect(emissions).toHaveLength(2);
|
expect(emissions).toEqual([user1Data]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not emit twice if there are two listeners", async () => {
|
||||||
|
await changeActiveUser("1");
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
const emissions2 = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([
|
||||||
|
null, // Initial value
|
||||||
|
]);
|
||||||
|
expect(emissions2).toEqual([
|
||||||
|
null, // Initial value
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
const newData = { date: new Date(), array: ["test"] };
|
const newData = { date: new Date(), array: ["test"] };
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
changeActiveUser("1");
|
await changeActiveUser("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should save on update", async () => {
|
it("should save on update", async () => {
|
||||||
@@ -315,6 +337,8 @@ describe("DefaultActiveUserState", () => {
|
|||||||
return initialData;
|
return initialData;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
await userState.update((state, dependencies) => {
|
await userState.update((state, dependencies) => {
|
||||||
expect(state).toEqual(initialData);
|
expect(state).toEqual(initialData);
|
||||||
return newData;
|
return newData;
|
||||||
@@ -329,4 +353,303 @@ describe("DefaultActiveUserState", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("update races", () => {
|
||||||
|
const newData = { date: new Date(), array: ["test"] };
|
||||||
|
const userId = makeUserId("1");
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await changeActiveUser("1");
|
||||||
|
await awaitAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscriptions during an update should receive the current and latest", async () => {
|
||||||
|
const oldData = { date: new Date(2019, 1, 1), array: ["oldValue1"] };
|
||||||
|
await userState.update(() => {
|
||||||
|
return oldData;
|
||||||
|
});
|
||||||
|
const initialData = { date: new Date(2020, 1, 1), array: ["value1", "value2"] };
|
||||||
|
await userState.update(() => {
|
||||||
|
return initialData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
expect(emissions).toEqual([initialData]);
|
||||||
|
|
||||||
|
let emissions2: TestState[];
|
||||||
|
const originalSave = diskStorageService.save.bind(diskStorageService);
|
||||||
|
diskStorageService.save = jest.fn().mockImplementation(async (key: string, obj: any) => {
|
||||||
|
emissions2 = trackEmissions(userState.state$);
|
||||||
|
await originalSave(key, obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
const val = await userState.update(() => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync(10);
|
||||||
|
|
||||||
|
expect(val).toEqual(newData);
|
||||||
|
expect(emissions).toEqual([initialData, newData]);
|
||||||
|
expect(emissions2).toEqual([initialData, newData]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscription during an aborted update should receive the last value", async () => {
|
||||||
|
// Seed with interesting data
|
||||||
|
const initialData = { date: new Date(2020, 1, 1), array: ["value1", "value2"] };
|
||||||
|
await userState.update(() => {
|
||||||
|
return initialData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
expect(emissions).toEqual([initialData]);
|
||||||
|
|
||||||
|
let emissions2: TestState[];
|
||||||
|
const val = await userState.update(
|
||||||
|
(state) => {
|
||||||
|
return newData;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shouldUpdate: () => {
|
||||||
|
emissions2 = trackEmissions(userState.state$);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(val).toEqual(initialData);
|
||||||
|
expect(emissions).toEqual([initialData]);
|
||||||
|
|
||||||
|
expect(emissions2).toEqual([initialData]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates should wait until previous update is complete", async () => {
|
||||||
|
trackEmissions(userState.state$);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
const originalSave = diskStorageService.save.bind(diskStorageService);
|
||||||
|
diskStorageService.save = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async (key: string, obj: any) => {
|
||||||
|
let resolved = false;
|
||||||
|
await Promise.race([
|
||||||
|
userState.update(() => {
|
||||||
|
// deadlocks
|
||||||
|
resolved = true;
|
||||||
|
return newData;
|
||||||
|
}),
|
||||||
|
awaitAsync(100), // limit test to 100ms
|
||||||
|
]);
|
||||||
|
expect(resolved).toBe(false);
|
||||||
|
})
|
||||||
|
.mockImplementation((...args) => {
|
||||||
|
return originalSave(...args);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userState.update(() => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates with FAKE_DEFAULT initial value should resolve correctly", async () => {
|
||||||
|
expect(userState["stateSubject"].value).toEqual(anySymbol()); // FAKE_DEFAULT
|
||||||
|
const val = await userState.update((state) => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(val).toEqual(newData);
|
||||||
|
const call = diskStorageService.mock.save.mock.calls[0];
|
||||||
|
expect(call[0]).toEqual(`user_${userId}_fake_fake`);
|
||||||
|
expect(call[1]).toEqual(newData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not await updates if the active user changes", async () => {
|
||||||
|
const initialUserId = (await firstValueFrom(accountService.activeAccount$)).id;
|
||||||
|
expect(initialUserId).toBe(userId);
|
||||||
|
trackEmissions(userState.state$);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
const originalSave = diskStorageService.save.bind(diskStorageService);
|
||||||
|
diskStorageService.save = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async (key: string, obj: any) => {
|
||||||
|
let resolved = false;
|
||||||
|
await changeActiveUser("2");
|
||||||
|
await Promise.race([
|
||||||
|
userState.update(() => {
|
||||||
|
// should not deadlock because we updated the user
|
||||||
|
resolved = true;
|
||||||
|
return newData;
|
||||||
|
}),
|
||||||
|
awaitAsync(100), // limit test to 100ms
|
||||||
|
]);
|
||||||
|
expect(resolved).toBe(true);
|
||||||
|
})
|
||||||
|
.mockImplementation((...args) => {
|
||||||
|
return originalSave(...args);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userState.update(() => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores updates for users in the correct place when active user changes mid-update", async () => {
|
||||||
|
trackEmissions(userState.state$);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
const user2Data = { date: new Date(), array: ["user 2 data"] };
|
||||||
|
|
||||||
|
const originalSave = diskStorageService.save.bind(diskStorageService);
|
||||||
|
diskStorageService.save = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async (key: string, obj: any) => {
|
||||||
|
let resolved = false;
|
||||||
|
await changeActiveUser("2");
|
||||||
|
await Promise.race([
|
||||||
|
userState.update(() => {
|
||||||
|
// should not deadlock because we updated the user
|
||||||
|
resolved = true;
|
||||||
|
return user2Data;
|
||||||
|
}),
|
||||||
|
awaitAsync(100), // limit test to 100ms
|
||||||
|
]);
|
||||||
|
expect(resolved).toBe(true);
|
||||||
|
await originalSave(key, obj);
|
||||||
|
})
|
||||||
|
.mockImplementation((...args) => {
|
||||||
|
return originalSave(...args);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userState.update(() => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(2);
|
||||||
|
const innerCall = diskStorageService.mock.save.mock.calls[0];
|
||||||
|
expect(innerCall[0]).toEqual(`user_${makeUserId("2")}_fake_fake`);
|
||||||
|
expect(innerCall[1]).toEqual(user2Data);
|
||||||
|
const outerCall = diskStorageService.mock.save.mock.calls[1];
|
||||||
|
expect(outerCall[0]).toEqual(`user_${makeUserId("1")}_fake_fake`);
|
||||||
|
expect(outerCall[1]).toEqual(newData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanup", () => {
|
||||||
|
const newData = { date: new Date(), array: ["test"] };
|
||||||
|
const userId = makeUserId("1");
|
||||||
|
let userKey: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await changeActiveUser("1");
|
||||||
|
userKey = userKeyBuilder(userId, testKeyDefinition);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function assertClean() {
|
||||||
|
const emissions = trackEmissions(userState["stateSubject"]);
|
||||||
|
const initial = structuredClone(emissions);
|
||||||
|
|
||||||
|
diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
expect(emissions).toEqual(initial); // no longer listening to storage updates
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should cleanup after last subscriber", async () => {
|
||||||
|
const subscription = userState.state$.subscribe();
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
expect(userState["subscriberCount"].getValue()).toBe(0);
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
await assertClean();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not cleanup if there are still subscribers", async () => {
|
||||||
|
const subscription1 = userState.state$.subscribe();
|
||||||
|
const sub2Emissions: TestState[] = [];
|
||||||
|
const subscription2 = userState.state$.subscribe((v) => sub2Emissions.push(v));
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
subscription1.unsubscribe();
|
||||||
|
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
expect(userState["subscriberCount"].getValue()).toBe(1);
|
||||||
|
|
||||||
|
// Still be listening to storage updates
|
||||||
|
diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
expect(sub2Emissions).toEqual([null, newData]);
|
||||||
|
|
||||||
|
subscription2.unsubscribe();
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
await assertClean();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can re-initialize after cleanup", async () => {
|
||||||
|
const subscription = userState.state$.subscribe();
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([null, newData]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not cleanup if a subscriber joins during the cleanup delay", async () => {
|
||||||
|
const subscription = userState.state$.subscribe();
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
await diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
expect(userState["subscriberCount"].getValue()).toBe(0);
|
||||||
|
// Do not wait long enough for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs / 2);
|
||||||
|
|
||||||
|
expect(userState["stateSubject"].value).toEqual(newData); // digging in to check that it hasn't been cleared
|
||||||
|
expect(userState["storageUpdateSubscription"]).not.toBeNull(); // still listening to storage updates
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state$ observables are durable to cleanup", async () => {
|
||||||
|
const observable = userState.state$;
|
||||||
|
let subscription = observable.subscribe();
|
||||||
|
|
||||||
|
await diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
subscription = observable.subscribe();
|
||||||
|
await diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(await firstValueFrom(observable)).toEqual(newData);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import {
|
|||||||
map,
|
map,
|
||||||
shareReplay,
|
shareReplay,
|
||||||
switchMap,
|
switchMap,
|
||||||
tap,
|
|
||||||
defer,
|
|
||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
combineLatestWith,
|
combineLatestWith,
|
||||||
filter,
|
filter,
|
||||||
timeout,
|
timeout,
|
||||||
|
Subscription,
|
||||||
|
tap,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||||
@@ -31,13 +31,22 @@ const FAKE_DEFAULT = Symbol("fakeDefault");
|
|||||||
export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
||||||
[activeMarker]: true;
|
[activeMarker]: true;
|
||||||
private formattedKey$: Observable<string>;
|
private formattedKey$: Observable<string>;
|
||||||
|
private updatePromise: Promise<T> | null = null;
|
||||||
|
private storageUpdateSubscription: Subscription;
|
||||||
|
private activeAccountUpdateSubscription: Subscription;
|
||||||
|
private subscriberCount = new BehaviorSubject<number>(0);
|
||||||
|
private stateObservable: Observable<T>;
|
||||||
|
private reinitialize = false;
|
||||||
|
|
||||||
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
||||||
T | typeof FAKE_DEFAULT
|
T | typeof FAKE_DEFAULT
|
||||||
>(FAKE_DEFAULT);
|
>(FAKE_DEFAULT);
|
||||||
private stateSubject$ = this.stateSubject.asObservable();
|
private stateSubject$ = this.stateSubject.asObservable();
|
||||||
|
|
||||||
state$: Observable<T>;
|
get state$() {
|
||||||
|
this.stateObservable = this.stateObservable ?? this.initializeObservable();
|
||||||
|
return this.stateObservable;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected keyDefinition: KeyDefinition<T>,
|
protected keyDefinition: KeyDefinition<T>,
|
||||||
@@ -51,62 +60,12 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
|||||||
? userKeyBuilder(account.id, this.keyDefinition)
|
? userKeyBuilder(account.id, this.keyDefinition)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
tap(() => {
|
||||||
|
// We have a new key, so we should forget about previous update promises
|
||||||
|
this.updatePromise = null;
|
||||||
|
}),
|
||||||
shareReplay({ bufferSize: 1, refCount: false }),
|
shareReplay({ bufferSize: 1, refCount: false }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeAccountData$ = this.formattedKey$.pipe(
|
|
||||||
switchMap(async (key) => {
|
|
||||||
if (key == null) {
|
|
||||||
return FAKE_DEFAULT;
|
|
||||||
}
|
|
||||||
return await getStoredValue(
|
|
||||||
key,
|
|
||||||
this.chosenStorageLocation,
|
|
||||||
this.keyDefinition.deserializer,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
// Share the execution
|
|
||||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const storageUpdates$ = this.chosenStorageLocation.updates$.pipe(
|
|
||||||
combineLatestWith(this.formattedKey$),
|
|
||||||
filter(([update, key]) => key !== null && update.key === key),
|
|
||||||
switchMap(async ([update, key]) => {
|
|
||||||
if (update.updateType === "remove") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const data = await getStoredValue(
|
|
||||||
key,
|
|
||||||
this.chosenStorageLocation,
|
|
||||||
this.keyDefinition.deserializer,
|
|
||||||
);
|
|
||||||
return data;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Whomever subscribes to this data, should be notified of updated data
|
|
||||||
// if someone calls my update() method, or the active user changes.
|
|
||||||
this.state$ = defer(() => {
|
|
||||||
const accountChangeSubscription = activeAccountData$.subscribe((data) => {
|
|
||||||
this.stateSubject.next(data);
|
|
||||||
});
|
|
||||||
const storageUpdateSubscription = storageUpdates$.subscribe((data) => {
|
|
||||||
this.stateSubject.next(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.stateSubject$.pipe(
|
|
||||||
tap({
|
|
||||||
complete: () => {
|
|
||||||
accountChangeSubscription.unsubscribe();
|
|
||||||
storageUpdateSubscription.unsubscribe();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
// I fake the generic here because I am filtering out the other union type
|
|
||||||
// and this makes it so that typescript understands the true type
|
|
||||||
.pipe(filter<T>((value) => value != FAKE_DEFAULT));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update<TCombine>(
|
async update<TCombine>(
|
||||||
@@ -114,8 +73,34 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
|||||||
options: StateUpdateOptions<T, TCombine> = {},
|
options: StateUpdateOptions<T, TCombine> = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
options = populateOptionsWithDefault(options);
|
options = populateOptionsWithDefault(options);
|
||||||
|
try {
|
||||||
|
if (this.updatePromise != null) {
|
||||||
|
await this.updatePromise;
|
||||||
|
}
|
||||||
|
this.updatePromise = this.internalUpdate(configureState, options);
|
||||||
|
const newState = await this.updatePromise;
|
||||||
|
return newState;
|
||||||
|
} finally {
|
||||||
|
this.updatePromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: this should be removed
|
||||||
|
async getFromState(): Promise<T> {
|
||||||
const key = await this.createKey();
|
const key = await this.createKey();
|
||||||
const currentState = await this.getGuaranteedState(key);
|
return await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
||||||
|
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async internalUpdate<TCombine>(
|
||||||
|
configureState: (state: T, dependency: TCombine) => T,
|
||||||
|
options: StateUpdateOptions<T, TCombine>,
|
||||||
|
) {
|
||||||
|
const key = await this.createKey();
|
||||||
|
const currentState = await this.getStateForUpdate(key);
|
||||||
const combinedDependencies =
|
const combinedDependencies =
|
||||||
options.combineLatestWith != null
|
options.combineLatestWith != null
|
||||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||||
@@ -130,13 +115,59 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
|||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFromState(): Promise<T> {
|
private initializeObservable() {
|
||||||
const key = await this.createKey();
|
this.storageUpdateSubscription = this.chosenStorageLocation.updates$
|
||||||
return await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer);
|
.pipe(
|
||||||
}
|
combineLatestWith(this.formattedKey$),
|
||||||
|
filter(([update, key]) => key !== null && update.key === key),
|
||||||
|
switchMap(async ([update, key]) => {
|
||||||
|
if (update.updateType === "remove") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await this.getState(key);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe((v) => this.stateSubject.next(v));
|
||||||
|
|
||||||
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
this.activeAccountUpdateSubscription = this.formattedKey$
|
||||||
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
|
.pipe(
|
||||||
|
switchMap(async (key) => {
|
||||||
|
if (key == null) {
|
||||||
|
return FAKE_DEFAULT;
|
||||||
|
}
|
||||||
|
return await this.getState(key);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe((v) => this.stateSubject.next(v));
|
||||||
|
|
||||||
|
this.subscriberCount.subscribe((count) => {
|
||||||
|
if (count === 0 && this.stateObservable != null) {
|
||||||
|
this.triggerCleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Observable<T>((subscriber) => {
|
||||||
|
this.incrementSubscribers();
|
||||||
|
|
||||||
|
// reinitialize listeners after cleanup
|
||||||
|
if (this.reinitialize) {
|
||||||
|
this.reinitialize = false;
|
||||||
|
this.initializeObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevUnsubscribe = subscriber.unsubscribe.bind(subscriber);
|
||||||
|
subscriber.unsubscribe = () => {
|
||||||
|
this.decrementSubscribers();
|
||||||
|
prevUnsubscribe();
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.stateSubject
|
||||||
|
.pipe(
|
||||||
|
// Filter out fake default, which is used to indicate that state is not ready to be emitted yet.
|
||||||
|
filter((i) => i !== FAKE_DEFAULT),
|
||||||
|
)
|
||||||
|
.subscribe(subscriber);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async createKey(): Promise<string> {
|
protected async createKey(): Promise<string> {
|
||||||
@@ -147,22 +178,47 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
|
|||||||
return formattedKey;
|
return formattedKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getGuaranteedState(key: string) {
|
/** For use in update methods, does not wait for update to complete before yielding state.
|
||||||
|
* The expectation is that that await is already done
|
||||||
|
*/
|
||||||
|
protected async getStateForUpdate(key: string) {
|
||||||
const currentValue = this.stateSubject.getValue();
|
const currentValue = this.stateSubject.getValue();
|
||||||
return currentValue === FAKE_DEFAULT ? await this.seedInitial(key) : currentValue;
|
return currentValue === FAKE_DEFAULT
|
||||||
|
? await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer)
|
||||||
|
: currentValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async seedInitial(key: string): Promise<T> {
|
/** To be used in observables. Awaits updates to ensure they are complete */
|
||||||
const value = await getStoredValue(
|
private async getState(key: string): Promise<T> {
|
||||||
key,
|
if (this.updatePromise != null) {
|
||||||
this.chosenStorageLocation,
|
await this.updatePromise;
|
||||||
this.keyDefinition.deserializer,
|
}
|
||||||
);
|
return await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer);
|
||||||
this.stateSubject.next(value);
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected saveToStorage(key: string, data: T): Promise<void> {
|
protected saveToStorage(key: string, data: T): Promise<void> {
|
||||||
return this.chosenStorageLocation.save(key, data);
|
return this.chosenStorageLocation.save(key, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private incrementSubscribers() {
|
||||||
|
this.subscriberCount.next(this.subscriberCount.value + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decrementSubscribers() {
|
||||||
|
this.subscriberCount.next(this.subscriberCount.value - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private triggerCleanup() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.subscriberCount.value === 0) {
|
||||||
|
this.updatePromise = null;
|
||||||
|
this.storageUpdateSubscription?.unsubscribe();
|
||||||
|
this.activeAccountUpdateSubscription?.unsubscribe();
|
||||||
|
this.subscriberCount.complete();
|
||||||
|
this.subscriberCount = new BehaviorSubject<number>(0);
|
||||||
|
this.stateSubject.next(FAKE_DEFAULT);
|
||||||
|
this.reinitialize = true;
|
||||||
|
}
|
||||||
|
}, this.keyDefinition.cleanupDelayMs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* @jest-environment ../shared/test.environment.ts
|
* @jest-environment ../shared/test.environment.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { anySymbol } from "jest-mock-extended";
|
||||||
import { firstValueFrom, of } from "rxjs";
|
import { firstValueFrom, of } from "rxjs";
|
||||||
import { Jsonify } from "type-fest";
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
@@ -28,9 +29,10 @@ class TestState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const testStateDefinition = new StateDefinition("fake", "disk");
|
const testStateDefinition = new StateDefinition("fake", "disk");
|
||||||
|
const cleanupDelayMs = 10;
|
||||||
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
|
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
|
||||||
deserializer: TestState.fromJSON,
|
deserializer: TestState.fromJSON,
|
||||||
|
cleanupDelayMs,
|
||||||
});
|
});
|
||||||
const globalKey = globalKeyBuilder(testKeyDefinition);
|
const globalKey = globalKeyBuilder(testKeyDefinition);
|
||||||
|
|
||||||
@@ -79,6 +81,19 @@ describe("DefaultGlobalState", () => {
|
|||||||
expect(diskStorageService.mock.get).toHaveBeenCalledWith("global_fake_fake", undefined);
|
expect(diskStorageService.mock.get).toHaveBeenCalledWith("global_fake_fake", undefined);
|
||||||
expect(state).toBeTruthy();
|
expect(state).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not emit twice if there are two listeners", async () => {
|
||||||
|
const emissions = trackEmissions(globalState.state$);
|
||||||
|
const emissions2 = trackEmissions(globalState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([
|
||||||
|
null, // Initial value
|
||||||
|
]);
|
||||||
|
expect(emissions2).toEqual([
|
||||||
|
null, // Initial value
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
@@ -133,6 +148,7 @@ describe("DefaultGlobalState", () => {
|
|||||||
|
|
||||||
it("should not update if shouldUpdate returns false", async () => {
|
it("should not update if shouldUpdate returns false", async () => {
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(globalState.state$);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
const result = await globalState.update(
|
const result = await globalState.update(
|
||||||
(state) => {
|
(state) => {
|
||||||
@@ -198,4 +214,212 @@ describe("DefaultGlobalState", () => {
|
|||||||
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
|
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("update races", () => {
|
||||||
|
test("subscriptions during an update should receive the current and latest data", async () => {
|
||||||
|
const oldData = { date: new Date(2019, 1, 1) };
|
||||||
|
await globalState.update(() => {
|
||||||
|
return oldData;
|
||||||
|
});
|
||||||
|
const initialData = { date: new Date(2020, 1, 1) };
|
||||||
|
await globalState.update(() => {
|
||||||
|
return initialData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
const emissions = trackEmissions(globalState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
expect(emissions).toEqual([initialData]);
|
||||||
|
|
||||||
|
let emissions2: TestState[];
|
||||||
|
const originalSave = diskStorageService.save.bind(diskStorageService);
|
||||||
|
diskStorageService.save = jest.fn().mockImplementation(async (key: string, obj: any) => {
|
||||||
|
emissions2 = trackEmissions(globalState.state$);
|
||||||
|
await originalSave(key, obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
const val = await globalState.update(() => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync(10);
|
||||||
|
|
||||||
|
expect(val).toEqual(newData);
|
||||||
|
expect(emissions).toEqual([initialData, newData]);
|
||||||
|
expect(emissions2).toEqual([initialData, newData]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscription during an aborted update should receive the last value", async () => {
|
||||||
|
// Seed with interesting data
|
||||||
|
const initialData = { date: new Date(2020, 1, 1) };
|
||||||
|
await globalState.update(() => {
|
||||||
|
return initialData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
const emissions = trackEmissions(globalState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
expect(emissions).toEqual([initialData]);
|
||||||
|
|
||||||
|
let emissions2: TestState[];
|
||||||
|
const val = await globalState.update(
|
||||||
|
() => {
|
||||||
|
return newData;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shouldUpdate: () => {
|
||||||
|
emissions2 = trackEmissions(globalState.state$);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(val).toEqual(initialData);
|
||||||
|
expect(emissions).toEqual([initialData]);
|
||||||
|
|
||||||
|
expect(emissions2).toEqual([initialData]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates should wait until previous update is complete", async () => {
|
||||||
|
trackEmissions(globalState.state$);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
const originalSave = diskStorageService.save.bind(diskStorageService);
|
||||||
|
diskStorageService.save = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
let resolved = false;
|
||||||
|
await Promise.race([
|
||||||
|
globalState.update(() => {
|
||||||
|
// deadlocks
|
||||||
|
resolved = true;
|
||||||
|
return newData;
|
||||||
|
}),
|
||||||
|
awaitAsync(100), // limit test to 100ms
|
||||||
|
]);
|
||||||
|
expect(resolved).toBe(false);
|
||||||
|
})
|
||||||
|
.mockImplementation(originalSave);
|
||||||
|
|
||||||
|
await globalState.update((state) => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates with FAKE_DEFAULT initial value should resolve correctly", async () => {
|
||||||
|
expect(globalState["stateSubject"].value).toEqual(anySymbol()); // FAKE_DEFAULT
|
||||||
|
const val = await globalState.update((state) => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(val).toEqual(newData);
|
||||||
|
const call = diskStorageService.mock.save.mock.calls[0];
|
||||||
|
expect(call[0]).toEqual("global_fake_fake");
|
||||||
|
expect(call[1]).toEqual(newData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanup", () => {
|
||||||
|
async function assertClean() {
|
||||||
|
const emissions = trackEmissions(globalState["stateSubject"]);
|
||||||
|
const initial = structuredClone(emissions);
|
||||||
|
|
||||||
|
diskStorageService.save(globalKey, newData);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
expect(emissions).toEqual(initial); // no longer listening to storage updates
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should cleanup after last subscriber", async () => {
|
||||||
|
const subscription = globalState.state$.subscribe();
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
expect(globalState["subscriberCount"].getValue()).toBe(0);
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
await assertClean();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not cleanup if there are still subscribers", async () => {
|
||||||
|
const subscription1 = globalState.state$.subscribe();
|
||||||
|
const sub2Emissions: TestState[] = [];
|
||||||
|
const subscription2 = globalState.state$.subscribe((v) => sub2Emissions.push(v));
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
subscription1.unsubscribe();
|
||||||
|
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
expect(globalState["subscriberCount"].getValue()).toBe(1);
|
||||||
|
|
||||||
|
// Still be listening to storage updates
|
||||||
|
diskStorageService.save(globalKey, newData);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
expect(sub2Emissions).toEqual([null, newData]);
|
||||||
|
|
||||||
|
subscription2.unsubscribe();
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
await assertClean();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can re-initialize after cleanup", async () => {
|
||||||
|
const subscription = globalState.state$.subscribe();
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
const emissions = trackEmissions(globalState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
diskStorageService.save(globalKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([null, newData]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not cleanup if a subscriber joins during the cleanup delay", async () => {
|
||||||
|
const subscription = globalState.state$.subscribe();
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
await diskStorageService.save(globalKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
expect(globalState["subscriberCount"].getValue()).toBe(0);
|
||||||
|
// Do not wait long enough for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs / 2);
|
||||||
|
|
||||||
|
expect(globalState["stateSubject"].value).toEqual(newData); // digging in to check that it hasn't been cleared
|
||||||
|
expect(globalState["storageUpdateSubscription"]).not.toBeNull(); // still listening to storage updates
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state$ observables are durable to cleanup", async () => {
|
||||||
|
const observable = globalState.state$;
|
||||||
|
let subscription = observable.subscribe();
|
||||||
|
|
||||||
|
await diskStorageService.save(globalKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
subscription = observable.subscribe();
|
||||||
|
await diskStorageService.save(globalKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(await firstValueFrom(observable)).toEqual(newData);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
Observable,
|
Observable,
|
||||||
defer,
|
Subscription,
|
||||||
filter,
|
filter,
|
||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
shareReplay,
|
|
||||||
switchMap,
|
switchMap,
|
||||||
tap,
|
|
||||||
timeout,
|
timeout,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
@@ -23,54 +21,26 @@ const FAKE_DEFAULT = Symbol("fakeDefault");
|
|||||||
|
|
||||||
export class DefaultGlobalState<T> implements GlobalState<T> {
|
export class DefaultGlobalState<T> implements GlobalState<T> {
|
||||||
private storageKey: string;
|
private storageKey: string;
|
||||||
|
private updatePromise: Promise<T> | null = null;
|
||||||
|
private storageUpdateSubscription: Subscription;
|
||||||
|
private subscriberCount = new BehaviorSubject<number>(0);
|
||||||
|
private stateObservable: Observable<T>;
|
||||||
|
private reinitialize = false;
|
||||||
|
|
||||||
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
||||||
T | typeof FAKE_DEFAULT
|
T | typeof FAKE_DEFAULT
|
||||||
>(FAKE_DEFAULT);
|
>(FAKE_DEFAULT);
|
||||||
|
|
||||||
state$: Observable<T>;
|
get state$() {
|
||||||
|
this.stateObservable = this.stateObservable ?? this.initializeObservable();
|
||||||
|
return this.stateObservable;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private keyDefinition: KeyDefinition<T>,
|
private keyDefinition: KeyDefinition<T>,
|
||||||
private chosenLocation: AbstractStorageService & ObservableStorageService,
|
private chosenLocation: AbstractStorageService & ObservableStorageService,
|
||||||
) {
|
) {
|
||||||
this.storageKey = globalKeyBuilder(this.keyDefinition);
|
this.storageKey = globalKeyBuilder(this.keyDefinition);
|
||||||
|
|
||||||
const storageUpdates$ = this.chosenLocation.updates$.pipe(
|
|
||||||
filter((update) => update.key === this.storageKey),
|
|
||||||
switchMap(async (update) => {
|
|
||||||
if (update.updateType === "remove") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await getStoredValue(
|
|
||||||
this.storageKey,
|
|
||||||
this.chosenLocation,
|
|
||||||
this.keyDefinition.deserializer,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
shareReplay({ bufferSize: 1, refCount: false }),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.state$ = defer(() => {
|
|
||||||
const storageUpdateSubscription = storageUpdates$.subscribe((value) => {
|
|
||||||
this.stateSubject.next(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.getFromState().then((s) => {
|
|
||||||
this.stateSubject.next(s);
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.stateSubject.pipe(
|
|
||||||
tap({
|
|
||||||
complete: () => {
|
|
||||||
storageUpdateSubscription.unsubscribe();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}).pipe(
|
|
||||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
|
||||||
filter<T>((i) => i != FAKE_DEFAULT),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update<TCombine>(
|
async update<TCombine>(
|
||||||
@@ -78,7 +48,24 @@ export class DefaultGlobalState<T> implements GlobalState<T> {
|
|||||||
options: StateUpdateOptions<T, TCombine> = {},
|
options: StateUpdateOptions<T, TCombine> = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
options = populateOptionsWithDefault(options);
|
options = populateOptionsWithDefault(options);
|
||||||
const currentState = await this.getGuaranteedState();
|
if (this.updatePromise != null) {
|
||||||
|
await this.updatePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.updatePromise = this.internalUpdate(configureState, options);
|
||||||
|
const newState = await this.updatePromise;
|
||||||
|
return newState;
|
||||||
|
} finally {
|
||||||
|
this.updatePromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async internalUpdate<TCombine>(
|
||||||
|
configureState: (state: T, dependency: TCombine) => T,
|
||||||
|
options: StateUpdateOptions<T, TCombine>,
|
||||||
|
): Promise<T> {
|
||||||
|
const currentState = await this.getStateForUpdate();
|
||||||
const combinedDependencies =
|
const combinedDependencies =
|
||||||
options.combineLatestWith != null
|
options.combineLatestWith != null
|
||||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||||
@@ -93,16 +80,94 @@ export class DefaultGlobalState<T> implements GlobalState<T> {
|
|||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getGuaranteedState() {
|
private initializeObservable() {
|
||||||
|
this.storageUpdateSubscription = this.chosenLocation.updates$
|
||||||
|
.pipe(
|
||||||
|
filter((update) => update.key === this.storageKey),
|
||||||
|
switchMap(async (update) => {
|
||||||
|
if (update.updateType === "remove") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await this.getFromState();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe((v) => this.stateSubject.next(v));
|
||||||
|
|
||||||
|
this.subscriberCount.subscribe((count) => {
|
||||||
|
if (count === 0 && this.stateObservable != null) {
|
||||||
|
this.triggerCleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intentionally un-awaited promise, we don't want to delay return of observable, but we do want to
|
||||||
|
// trigger populating it immediately.
|
||||||
|
this.getFromState().then((s) => {
|
||||||
|
this.stateSubject.next(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Observable<T>((subscriber) => {
|
||||||
|
this.incrementSubscribers();
|
||||||
|
|
||||||
|
// reinitialize listeners after cleanup
|
||||||
|
if (this.reinitialize) {
|
||||||
|
this.reinitialize = false;
|
||||||
|
this.initializeObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevUnsubscribe = subscriber.unsubscribe.bind(subscriber);
|
||||||
|
subscriber.unsubscribe = () => {
|
||||||
|
this.decrementSubscribers();
|
||||||
|
prevUnsubscribe();
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.stateSubject
|
||||||
|
.pipe(
|
||||||
|
// Filter out fake default, which is used to indicate that state is not ready to be emitted yet.
|
||||||
|
filter<T>((i) => i != FAKE_DEFAULT),
|
||||||
|
)
|
||||||
|
.subscribe(subscriber);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For use in update methods, does not wait for update to complete before yielding state.
|
||||||
|
* The expectation is that that await is already done
|
||||||
|
*/
|
||||||
|
private async getStateForUpdate() {
|
||||||
const currentValue = this.stateSubject.getValue();
|
const currentValue = this.stateSubject.getValue();
|
||||||
return currentValue === FAKE_DEFAULT ? await this.getFromState() : currentValue;
|
return currentValue === FAKE_DEFAULT
|
||||||
|
? await getStoredValue(this.storageKey, this.chosenLocation, this.keyDefinition.deserializer)
|
||||||
|
: currentValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFromState(): Promise<T> {
|
async getFromState(): Promise<T> {
|
||||||
|
if (this.updatePromise != null) {
|
||||||
|
return await this.updatePromise;
|
||||||
|
}
|
||||||
return await getStoredValue(
|
return await getStoredValue(
|
||||||
this.storageKey,
|
this.storageKey,
|
||||||
this.chosenLocation,
|
this.chosenLocation,
|
||||||
this.keyDefinition.deserializer,
|
this.keyDefinition.deserializer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private incrementSubscribers() {
|
||||||
|
this.subscriberCount.next(this.subscriberCount.value + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decrementSubscribers() {
|
||||||
|
this.subscriberCount.next(this.subscriberCount.value - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private triggerCleanup() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.subscriberCount.value === 0) {
|
||||||
|
this.updatePromise = null;
|
||||||
|
this.storageUpdateSubscription.unsubscribe();
|
||||||
|
this.subscriberCount.complete();
|
||||||
|
this.subscriberCount = new BehaviorSubject<number>(0);
|
||||||
|
this.stateSubject.next(FAKE_DEFAULT);
|
||||||
|
this.reinitialize = true;
|
||||||
|
}
|
||||||
|
}, this.keyDefinition.cleanupDelayMs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* @jest-environment ../shared/test.environment.ts
|
* @jest-environment ../shared/test.environment.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { anySymbol } from "jest-mock-extended";
|
||||||
import { firstValueFrom, of } from "rxjs";
|
import { firstValueFrom, of } from "rxjs";
|
||||||
import { Jsonify } from "type-fest";
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
@@ -30,21 +31,22 @@ class TestState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const testStateDefinition = new StateDefinition("fake", "disk");
|
const testStateDefinition = new StateDefinition("fake", "disk");
|
||||||
|
const cleanupDelayMs = 10;
|
||||||
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
|
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
|
||||||
deserializer: TestState.fromJSON,
|
deserializer: TestState.fromJSON,
|
||||||
|
cleanupDelayMs,
|
||||||
});
|
});
|
||||||
const userId = Utils.newGuid() as UserId;
|
const userId = Utils.newGuid() as UserId;
|
||||||
const userKey = userKeyBuilder(userId, testKeyDefinition);
|
const userKey = userKeyBuilder(userId, testKeyDefinition);
|
||||||
|
|
||||||
describe("DefaultSingleUserState", () => {
|
describe("DefaultSingleUserState", () => {
|
||||||
let diskStorageService: FakeStorageService;
|
let diskStorageService: FakeStorageService;
|
||||||
let globalState: DefaultSingleUserState<TestState>;
|
let userState: DefaultSingleUserState<TestState>;
|
||||||
const newData = { date: new Date() };
|
const newData = { date: new Date() };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
diskStorageService = new FakeStorageService();
|
diskStorageService = new FakeStorageService();
|
||||||
globalState = new DefaultSingleUserState(
|
userState = new DefaultSingleUserState(
|
||||||
userId,
|
userId,
|
||||||
testKeyDefinition,
|
testKeyDefinition,
|
||||||
null, // Not testing anything with encrypt service
|
null, // Not testing anything with encrypt service
|
||||||
@@ -58,7 +60,7 @@ describe("DefaultSingleUserState", () => {
|
|||||||
|
|
||||||
describe("state$", () => {
|
describe("state$", () => {
|
||||||
it("should emit when storage updates", async () => {
|
it("should emit when storage updates", async () => {
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(userState.state$);
|
||||||
await diskStorageService.save(userKey, newData);
|
await diskStorageService.save(userKey, newData);
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ describe("DefaultSingleUserState", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not emit when update key does not match", async () => {
|
it("should not emit when update key does not match", async () => {
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(userState.state$);
|
||||||
await diskStorageService.save("wrong_key", newData);
|
await diskStorageService.save("wrong_key", newData);
|
||||||
|
|
||||||
expect(emissions).toHaveLength(0);
|
expect(emissions).toHaveLength(0);
|
||||||
@@ -82,7 +84,7 @@ describe("DefaultSingleUserState", () => {
|
|||||||
});
|
});
|
||||||
diskStorageService.internalUpdateStore(initialStorage);
|
diskStorageService.internalUpdateStore(initialStorage);
|
||||||
|
|
||||||
const state = await firstValueFrom(globalState.state$);
|
const state = await firstValueFrom(userState.state$);
|
||||||
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1);
|
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1);
|
||||||
expect(diskStorageService.mock.get).toHaveBeenCalledWith(
|
expect(diskStorageService.mock.get).toHaveBeenCalledWith(
|
||||||
`user_${userId}_fake_fake`,
|
`user_${userId}_fake_fake`,
|
||||||
@@ -94,7 +96,7 @@ describe("DefaultSingleUserState", () => {
|
|||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
it("should save on update", async () => {
|
it("should save on update", async () => {
|
||||||
const result = await globalState.update((state) => {
|
const result = await userState.update((state) => {
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,10 +105,10 @@ describe("DefaultSingleUserState", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should emit once per update", async () => {
|
it("should emit once per update", async () => {
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(userState.state$);
|
||||||
await awaitAsync(); // storage updates are behind a promise
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
await globalState.update((state) => {
|
await userState.update((state) => {
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,12 +121,12 @@ describe("DefaultSingleUserState", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should provided combined dependencies", async () => {
|
it("should provided combined dependencies", async () => {
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(userState.state$);
|
||||||
await awaitAsync(); // storage updates are behind a promise
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
const combinedDependencies = { date: new Date() };
|
const combinedDependencies = { date: new Date() };
|
||||||
|
|
||||||
await globalState.update(
|
await userState.update(
|
||||||
(state, dependencies) => {
|
(state, dependencies) => {
|
||||||
expect(dependencies).toEqual(combinedDependencies);
|
expect(dependencies).toEqual(combinedDependencies);
|
||||||
return newData;
|
return newData;
|
||||||
@@ -143,9 +145,10 @@ describe("DefaultSingleUserState", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not update if shouldUpdate returns false", async () => {
|
it("should not update if shouldUpdate returns false", async () => {
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
const result = await globalState.update(
|
const result = await userState.update(
|
||||||
(state) => {
|
(state) => {
|
||||||
return newData;
|
return newData;
|
||||||
},
|
},
|
||||||
@@ -160,18 +163,18 @@ describe("DefaultSingleUserState", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should provide the update callback with the current State", async () => {
|
it("should provide the update callback with the current State", async () => {
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(userState.state$);
|
||||||
await awaitAsync(); // storage updates are behind a promise
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
// Seed with interesting data
|
// Seed with interesting data
|
||||||
const initialData = { date: new Date(2020, 1, 1) };
|
const initialData = { date: new Date(2020, 1, 1) };
|
||||||
await globalState.update((state, dependencies) => {
|
await userState.update((state, dependencies) => {
|
||||||
return initialData;
|
return initialData;
|
||||||
});
|
});
|
||||||
|
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
|
|
||||||
await globalState.update((state) => {
|
await userState.update((state) => {
|
||||||
expect(state).toEqual(initialData);
|
expect(state).toEqual(initialData);
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
@@ -193,14 +196,14 @@ describe("DefaultSingleUserState", () => {
|
|||||||
initialStorage[userKey] = initialState;
|
initialStorage[userKey] = initialState;
|
||||||
diskStorageService.internalUpdateStore(initialStorage);
|
diskStorageService.internalUpdateStore(initialStorage);
|
||||||
|
|
||||||
const emissions = trackEmissions(globalState.state$);
|
const emissions = trackEmissions(userState.state$);
|
||||||
await awaitAsync(); // storage updates are behind a promise
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
const newState = {
|
const newState = {
|
||||||
...initialState,
|
...initialState,
|
||||||
date: new Date(initialState.date.getFullYear(), initialState.date.getMonth() + 1),
|
date: new Date(initialState.date.getFullYear(), initialState.date.getMonth() + 1),
|
||||||
};
|
};
|
||||||
const actual = await globalState.update((existingState) => newState);
|
const actual = await userState.update((existingState) => newState);
|
||||||
|
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
|
|
||||||
@@ -209,4 +212,212 @@ describe("DefaultSingleUserState", () => {
|
|||||||
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
|
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("update races", () => {
|
||||||
|
test("subscriptions during an update should receive the current and latest data", async () => {
|
||||||
|
const oldData = { date: new Date(2019, 1, 1) };
|
||||||
|
await userState.update(() => {
|
||||||
|
return oldData;
|
||||||
|
});
|
||||||
|
const initialData = { date: new Date(2020, 1, 1) };
|
||||||
|
await userState.update(() => {
|
||||||
|
return initialData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
expect(emissions).toEqual([initialData]);
|
||||||
|
|
||||||
|
let emissions2: TestState[];
|
||||||
|
const originalSave = diskStorageService.save.bind(diskStorageService);
|
||||||
|
diskStorageService.save = jest.fn().mockImplementation(async (key: string, obj: any) => {
|
||||||
|
emissions2 = trackEmissions(userState.state$);
|
||||||
|
await originalSave(key, obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
const val = await userState.update(() => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync(10);
|
||||||
|
|
||||||
|
expect(val).toEqual(newData);
|
||||||
|
expect(emissions).toEqual([initialData, newData]);
|
||||||
|
expect(emissions2).toEqual([initialData, newData]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscription during an aborted update should receive the last value", async () => {
|
||||||
|
// Seed with interesting data
|
||||||
|
const initialData = { date: new Date(2020, 1, 1) };
|
||||||
|
await userState.update(() => {
|
||||||
|
return initialData;
|
||||||
|
});
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
expect(emissions).toEqual([initialData]);
|
||||||
|
|
||||||
|
let emissions2: TestState[];
|
||||||
|
const val = await userState.update(
|
||||||
|
(state) => {
|
||||||
|
return newData;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shouldUpdate: () => {
|
||||||
|
emissions2 = trackEmissions(userState.state$);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(val).toEqual(initialData);
|
||||||
|
expect(emissions).toEqual([initialData]);
|
||||||
|
|
||||||
|
expect(emissions2).toEqual([initialData]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates should wait until previous update is complete", async () => {
|
||||||
|
trackEmissions(userState.state$);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
const originalSave = diskStorageService.save.bind(diskStorageService);
|
||||||
|
diskStorageService.save = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
let resolved = false;
|
||||||
|
await Promise.race([
|
||||||
|
userState.update(() => {
|
||||||
|
// deadlocks
|
||||||
|
resolved = true;
|
||||||
|
return newData;
|
||||||
|
}),
|
||||||
|
awaitAsync(100), // limit test to 100ms
|
||||||
|
]);
|
||||||
|
expect(resolved).toBe(false);
|
||||||
|
})
|
||||||
|
.mockImplementation(originalSave);
|
||||||
|
|
||||||
|
await userState.update((state) => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates with FAKE_DEFAULT initial value should resolve correctly", async () => {
|
||||||
|
expect(userState["stateSubject"].value).toEqual(anySymbol()); // FAKE_DEFAULT
|
||||||
|
const val = await userState.update((state) => {
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(val).toEqual(newData);
|
||||||
|
const call = diskStorageService.mock.save.mock.calls[0];
|
||||||
|
expect(call[0]).toEqual(`user_${userId}_fake_fake`);
|
||||||
|
expect(call[1]).toEqual(newData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanup", () => {
|
||||||
|
async function assertClean() {
|
||||||
|
const emissions = trackEmissions(userState["stateSubject"]);
|
||||||
|
const initial = structuredClone(emissions);
|
||||||
|
|
||||||
|
diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
expect(emissions).toEqual(initial); // no longer listening to storage updates
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should cleanup after last subscriber", async () => {
|
||||||
|
const subscription = userState.state$.subscribe();
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
expect(userState["subscriberCount"].getValue()).toBe(0);
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
await assertClean();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not cleanup if there are still subscribers", async () => {
|
||||||
|
const subscription1 = userState.state$.subscribe();
|
||||||
|
const sub2Emissions: TestState[] = [];
|
||||||
|
const subscription2 = userState.state$.subscribe((v) => sub2Emissions.push(v));
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
|
||||||
|
subscription1.unsubscribe();
|
||||||
|
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
expect(userState["subscriberCount"].getValue()).toBe(1);
|
||||||
|
|
||||||
|
// Still be listening to storage updates
|
||||||
|
diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync(); // storage updates are behind a promise
|
||||||
|
expect(sub2Emissions).toEqual([null, newData]);
|
||||||
|
|
||||||
|
subscription2.unsubscribe();
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
await assertClean();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can re-initialize after cleanup", async () => {
|
||||||
|
const subscription = userState.state$.subscribe();
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
const emissions = trackEmissions(userState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([null, newData]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not cleanup if a subscriber joins during the cleanup delay", async () => {
|
||||||
|
const subscription = userState.state$.subscribe();
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
await diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
expect(userState["subscriberCount"].getValue()).toBe(0);
|
||||||
|
// Do not wait long enough for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs / 2);
|
||||||
|
|
||||||
|
expect(userState["stateSubject"].value).toEqual(newData); // digging in to check that it hasn't been cleared
|
||||||
|
expect(userState["storageUpdateSubscription"]).not.toBeNull(); // still listening to storage updates
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state$ observables are durable to cleanup", async () => {
|
||||||
|
const observable = userState.state$;
|
||||||
|
let subscription = observable.subscribe();
|
||||||
|
|
||||||
|
await diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
// Wait for cleanup
|
||||||
|
await awaitAsync(cleanupDelayMs * 2);
|
||||||
|
|
||||||
|
subscription = observable.subscribe();
|
||||||
|
await diskStorageService.save(userKey, newData);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(await firstValueFrom(observable)).toEqual(newData);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
Observable,
|
Observable,
|
||||||
defer,
|
Subscription,
|
||||||
filter,
|
filter,
|
||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
shareReplay,
|
|
||||||
switchMap,
|
switchMap,
|
||||||
tap,
|
|
||||||
timeout,
|
timeout,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
@@ -23,16 +21,25 @@ import { Converter, SingleUserState } from "../user-state";
|
|||||||
|
|
||||||
import { DefaultDerivedUserState } from "./default-derived-state";
|
import { DefaultDerivedUserState } from "./default-derived-state";
|
||||||
import { getStoredValue } from "./util";
|
import { getStoredValue } from "./util";
|
||||||
|
|
||||||
const FAKE_DEFAULT = Symbol("fakeDefault");
|
const FAKE_DEFAULT = Symbol("fakeDefault");
|
||||||
|
|
||||||
export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
||||||
private storageKey: string;
|
private storageKey: string;
|
||||||
|
private updatePromise: Promise<T> | null = null;
|
||||||
|
private storageUpdateSubscription: Subscription;
|
||||||
|
private subscriberCount = new BehaviorSubject<number>(0);
|
||||||
|
private stateObservable: Observable<T>;
|
||||||
|
private reinitialize = false;
|
||||||
|
|
||||||
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
|
||||||
T | typeof FAKE_DEFAULT
|
T | typeof FAKE_DEFAULT
|
||||||
>(FAKE_DEFAULT);
|
>(FAKE_DEFAULT);
|
||||||
|
|
||||||
state$: Observable<T>;
|
get state$() {
|
||||||
|
this.stateObservable = this.stateObservable ?? this.initializeObservable();
|
||||||
|
return this.stateObservable;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly userId: UserId,
|
readonly userId: UserId,
|
||||||
@@ -41,42 +48,6 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
|||||||
private chosenLocation: AbstractStorageService & ObservableStorageService,
|
private chosenLocation: AbstractStorageService & ObservableStorageService,
|
||||||
) {
|
) {
|
||||||
this.storageKey = userKeyBuilder(this.userId, this.keyDefinition);
|
this.storageKey = userKeyBuilder(this.userId, this.keyDefinition);
|
||||||
|
|
||||||
const storageUpdates$ = this.chosenLocation.updates$.pipe(
|
|
||||||
filter((update) => update.key === this.storageKey),
|
|
||||||
switchMap(async (update) => {
|
|
||||||
if (update.updateType === "remove") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await getStoredValue(
|
|
||||||
this.storageKey,
|
|
||||||
this.chosenLocation,
|
|
||||||
this.keyDefinition.deserializer,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
shareReplay({ bufferSize: 1, refCount: false }),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.state$ = defer(() => {
|
|
||||||
const storageUpdateSubscription = storageUpdates$.subscribe((value) => {
|
|
||||||
this.stateSubject.next(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.getFromState().then((s) => {
|
|
||||||
this.stateSubject.next(s);
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.stateSubject.pipe(
|
|
||||||
tap({
|
|
||||||
complete: () => {
|
|
||||||
storageUpdateSubscription.unsubscribe();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}).pipe(
|
|
||||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
|
||||||
filter<T>((i) => i != FAKE_DEFAULT),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update<TCombine>(
|
async update<TCombine>(
|
||||||
@@ -84,7 +55,28 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
|||||||
options: StateUpdateOptions<T, TCombine> = {},
|
options: StateUpdateOptions<T, TCombine> = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
options = populateOptionsWithDefault(options);
|
options = populateOptionsWithDefault(options);
|
||||||
const currentState = await this.getGuaranteedState();
|
if (this.updatePromise != null) {
|
||||||
|
await this.updatePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.updatePromise = this.internalUpdate(configureState, options);
|
||||||
|
const newState = await this.updatePromise;
|
||||||
|
return newState;
|
||||||
|
} finally {
|
||||||
|
this.updatePromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
||||||
|
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async internalUpdate<TCombine>(
|
||||||
|
configureState: (state: T, dependency: TCombine) => T,
|
||||||
|
options: StateUpdateOptions<T, TCombine>,
|
||||||
|
): Promise<T> {
|
||||||
|
const currentState = await this.getStateForUpdate();
|
||||||
const combinedDependencies =
|
const combinedDependencies =
|
||||||
options.combineLatestWith != null
|
options.combineLatestWith != null
|
||||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||||
@@ -99,20 +91,94 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
|
|||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
|
private initializeObservable() {
|
||||||
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
|
this.storageUpdateSubscription = this.chosenLocation.updates$
|
||||||
|
.pipe(
|
||||||
|
filter((update) => update.key === this.storageKey),
|
||||||
|
switchMap(async (update) => {
|
||||||
|
if (update.updateType === "remove") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await this.getFromState();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe((v) => this.stateSubject.next(v));
|
||||||
|
|
||||||
|
this.subscriberCount.subscribe((count) => {
|
||||||
|
if (count === 0 && this.stateObservable != null) {
|
||||||
|
this.triggerCleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intentionally un-awaited promise, we don't want to delay return of observable, but we do want to
|
||||||
|
// trigger populating it immediately.
|
||||||
|
this.getFromState().then((s) => {
|
||||||
|
this.stateSubject.next(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Observable<T>((subscriber) => {
|
||||||
|
this.incrementSubscribers();
|
||||||
|
|
||||||
|
// reinitialize listeners after cleanup
|
||||||
|
if (this.reinitialize) {
|
||||||
|
this.reinitialize = false;
|
||||||
|
this.initializeObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevUnsubscribe = subscriber.unsubscribe.bind(subscriber);
|
||||||
|
subscriber.unsubscribe = () => {
|
||||||
|
this.decrementSubscribers();
|
||||||
|
prevUnsubscribe();
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.stateSubject
|
||||||
|
.pipe(
|
||||||
|
// Filter out fake default, which is used to indicate that state is not ready to be emitted yet.
|
||||||
|
filter<T>((i) => i != FAKE_DEFAULT),
|
||||||
|
)
|
||||||
|
.subscribe(subscriber);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getGuaranteedState() {
|
/** For use in update methods, does not wait for update to complete before yielding state.
|
||||||
|
* The expectation is that that await is already done
|
||||||
|
*/
|
||||||
|
private async getStateForUpdate() {
|
||||||
const currentValue = this.stateSubject.getValue();
|
const currentValue = this.stateSubject.getValue();
|
||||||
return currentValue === FAKE_DEFAULT ? await this.getFromState() : currentValue;
|
return currentValue === FAKE_DEFAULT
|
||||||
|
? await getStoredValue(this.storageKey, this.chosenLocation, this.keyDefinition.deserializer)
|
||||||
|
: currentValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFromState(): Promise<T> {
|
async getFromState(): Promise<T> {
|
||||||
|
if (this.updatePromise != null) {
|
||||||
|
return await this.updatePromise;
|
||||||
|
}
|
||||||
return await getStoredValue(
|
return await getStoredValue(
|
||||||
this.storageKey,
|
this.storageKey,
|
||||||
this.chosenLocation,
|
this.chosenLocation,
|
||||||
this.keyDefinition.deserializer,
|
this.keyDefinition.deserializer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private incrementSubscribers() {
|
||||||
|
this.subscriberCount.next(this.subscriberCount.value + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decrementSubscribers() {
|
||||||
|
this.subscriberCount.next(this.subscriberCount.value - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private triggerCleanup() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.subscriberCount.value === 0) {
|
||||||
|
this.updatePromise = null;
|
||||||
|
this.storageUpdateSubscription.unsubscribe();
|
||||||
|
this.subscriberCount.complete();
|
||||||
|
this.subscriberCount = new BehaviorSubject<number>(0);
|
||||||
|
this.stateSubject.next(FAKE_DEFAULT);
|
||||||
|
this.reinitialize = true;
|
||||||
|
}
|
||||||
|
}, this.keyDefinition.cleanupDelayMs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,37 @@ describe("KeyDefinition", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cleanupDelayMs", () => {
|
||||||
|
it("defaults to 1000ms", () => {
|
||||||
|
const keyDefinition = new KeyDefinition<boolean>(fakeStateDefinition, "fake", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(keyDefinition).toBeTruthy();
|
||||||
|
expect(keyDefinition.cleanupDelayMs).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be overridden", () => {
|
||||||
|
const keyDefinition = new KeyDefinition<boolean>(fakeStateDefinition, "fake", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
cleanupDelayMs: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(keyDefinition).toBeTruthy();
|
||||||
|
expect(keyDefinition.cleanupDelayMs).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([0, -1])("throws on 0 or negative (%s)", (testValue: number) => {
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new KeyDefinition<boolean>(fakeStateDefinition, "fake", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
cleanupDelayMs: testValue,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("record", () => {
|
describe("record", () => {
|
||||||
it("runs custom deserializer for each record value", () => {
|
it("runs custom deserializer for each record value", () => {
|
||||||
const recordDefinition = KeyDefinition.record<boolean>(fakeStateDefinition, "fake", {
|
const recordDefinition = KeyDefinition.record<boolean>(fakeStateDefinition, "fake", {
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type KeyDefinitionOptions<T> = {
|
|||||||
* @returns The fully typed version of your state.
|
* @returns The fully typed version of your state.
|
||||||
*/
|
*/
|
||||||
readonly deserializer: (jsonValue: Jsonify<T>) => T;
|
readonly deserializer: (jsonValue: Jsonify<T>) => T;
|
||||||
|
/**
|
||||||
|
* The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||||
|
* Defaults to 1000ms.
|
||||||
|
*/
|
||||||
|
readonly cleanupDelayMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,8 +47,12 @@ export class KeyDefinition<T> {
|
|||||||
private readonly options: KeyDefinitionOptions<T>,
|
private readonly options: KeyDefinitionOptions<T>,
|
||||||
) {
|
) {
|
||||||
if (options.deserializer == null) {
|
if (options.deserializer == null) {
|
||||||
|
throw new Error(`'deserializer' is a required property on key ${this.errorKeyName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.cleanupDelayMs <= 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`'deserializer' is a required property on key ${stateDefinition.name} > ${key}`,
|
`'cleanupDelayMs' must be greater than 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +64,13 @@ export class KeyDefinition<T> {
|
|||||||
return this.options.deserializer;
|
return this.options.deserializer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||||
|
*/
|
||||||
|
get cleanupDelayMs() {
|
||||||
|
return this.options.cleanupDelayMs < 0 ? 0 : this.options.cleanupDelayMs ?? 1000;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a {@link KeyDefinition} for state that is an array.
|
* Creates a {@link KeyDefinition} for state that is an array.
|
||||||
* @param stateDefinition The state definition to be added to the KeyDefinition
|
* @param stateDefinition The state definition to be added to the KeyDefinition
|
||||||
@@ -137,6 +153,10 @@ export class KeyDefinition<T> {
|
|||||||
? `${scope}_${userId}_${this.stateDefinition.name}_${this.key}`
|
? `${scope}_${userId}_${this.stateDefinition.name}_${this.key}`
|
||||||
: `${scope}_${this.stateDefinition.name}_${this.key}`;
|
: `${scope}_${this.stateDefinition.name}_${this.key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get errorKeyName() {
|
||||||
|
return `${this.stateDefinition.name} > ${this.key}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StorageKey = Opaque<string, "StorageKey">;
|
export type StorageKey = Opaque<string, "StorageKey">;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ImportResult } from "../models/import-result";
|
|||||||
import { BaseImporter } from "./base-importer";
|
import { BaseImporter } from "./base-importer";
|
||||||
import { Importer } from "./importer";
|
import { Importer } from "./importer";
|
||||||
|
|
||||||
|
/** This is the importer for the xml format from pwsafe.org */
|
||||||
export class PasswordSafeXmlImporter extends BaseImporter implements Importer {
|
export class PasswordSafeXmlImporter extends BaseImporter implements Importer {
|
||||||
parse(data: string): Promise<ImportResult> {
|
parse(data: string): Promise<ImportResult> {
|
||||||
const result = new ImportResult();
|
const result = new ImportResult();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const regularImportOptions = [
|
|||||||
{ id: "enpassjson", name: "Enpass (json)" },
|
{ id: "enpassjson", name: "Enpass (json)" },
|
||||||
{ id: "protonpass", name: "ProtonPass (zip/json)" },
|
{ id: "protonpass", name: "ProtonPass (zip/json)" },
|
||||||
{ id: "safeincloudxml", name: "SafeInCloud (xml)" },
|
{ id: "safeincloudxml", name: "SafeInCloud (xml)" },
|
||||||
{ id: "pwsafexml", name: "Password Safe (xml)" },
|
{ id: "pwsafexml", name: "Password Safe - pwsafe.org (xml)" },
|
||||||
{ id: "stickypasswordxml", name: "Sticky Password (xml)" },
|
{ id: "stickypasswordxml", name: "Sticky Password (xml)" },
|
||||||
{ id: "msecurecsv", name: "mSecure (csv)" },
|
{ id: "msecurecsv", name: "mSecure (csv)" },
|
||||||
{ id: "truekeycsv", name: "True Key (csv)" },
|
{ id: "truekeycsv", name: "True Key (csv)" },
|
||||||
|
|||||||
34
package-lock.json
generated
34
package-lock.json
generated
@@ -33,7 +33,7 @@
|
|||||||
"argon2-browser": "1.18.0",
|
"argon2-browser": "1.18.0",
|
||||||
"big-integer": "1.6.51",
|
"big-integer": "1.6.51",
|
||||||
"bootstrap": "4.6.0",
|
"bootstrap": "4.6.0",
|
||||||
"braintree-web-drop-in": "1.40.0",
|
"braintree-web-drop-in": "1.41.0",
|
||||||
"bufferutil": "4.0.8",
|
"bufferutil": "4.0.8",
|
||||||
"chalk": "4.1.2",
|
"chalk": "4.1.2",
|
||||||
"commander": "7.2.0",
|
"commander": "7.2.0",
|
||||||
@@ -4638,9 +4638,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@braintree/browser-detection": {
|
"node_modules/@braintree/browser-detection": {
|
||||||
"version": "1.14.0",
|
"version": "1.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@braintree/browser-detection/-/browser-detection-1.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@braintree/browser-detection/-/browser-detection-1.17.1.tgz",
|
||||||
"integrity": "sha512-OsqU+28RhNvSw8Y5JEiUHUrAyn4OpYazFkjSJe8ZVZfkAaRXQc6hsV38MMEpIlkPMig+A68buk/diY+0O8/dMQ=="
|
"integrity": "sha512-Mk7jauyp9pD14BTRS7otoy9dqIJGb3Oy0XtxKM/adGD9i9MAuCjH5uRZMyW2iVmJQTaA/PLlWdG7eSDyMWMc8Q=="
|
||||||
},
|
},
|
||||||
"node_modules/@braintree/event-emitter": {
|
"node_modules/@braintree/event-emitter": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
@@ -4658,9 +4658,9 @@
|
|||||||
"integrity": "sha512-tVpr7U6u6bqeQlHreEjYMNtnHX62vLnNWziY2kQLqkWhvusPuY5DfuGEIPpWqsd+V/a1slyTQaxK6HWTlH6A/Q=="
|
"integrity": "sha512-tVpr7U6u6bqeQlHreEjYMNtnHX62vLnNWziY2kQLqkWhvusPuY5DfuGEIPpWqsd+V/a1slyTQaxK6HWTlH6A/Q=="
|
||||||
},
|
},
|
||||||
"node_modules/@braintree/sanitize-url": {
|
"node_modules/@braintree/sanitize-url": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz",
|
||||||
"integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg=="
|
"integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A=="
|
||||||
},
|
},
|
||||||
"node_modules/@braintree/uuid": {
|
"node_modules/@braintree/uuid": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -17244,16 +17244,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braintree-web": {
|
"node_modules/braintree-web": {
|
||||||
"version": "3.96.1",
|
"version": "3.97.4",
|
||||||
"resolved": "https://registry.npmjs.org/braintree-web/-/braintree-web-3.96.1.tgz",
|
"resolved": "https://registry.npmjs.org/braintree-web/-/braintree-web-3.97.4.tgz",
|
||||||
"integrity": "sha512-e483TQkRmcO5zFH+pMGUokFWq5Q+Jyn9TmUIDofPpul1P+xOciwNqvekl/Ku6MsJBT1+kyH0cndBYMZVJpbrtg==",
|
"integrity": "sha512-w//M/ZI/MhjaxUwpICwZO50uTLF/L3WGLN4tFCPh/Xw20jDw8UBiM0Gzquq7gmwcQ1BgNnAAaYlR94HcSmt/Cg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/asset-loader": "0.4.4",
|
"@braintree/asset-loader": "0.4.4",
|
||||||
"@braintree/browser-detection": "1.14.0",
|
"@braintree/browser-detection": "1.17.1",
|
||||||
"@braintree/event-emitter": "0.4.1",
|
"@braintree/event-emitter": "0.4.1",
|
||||||
"@braintree/extended-promise": "0.4.1",
|
"@braintree/extended-promise": "0.4.1",
|
||||||
"@braintree/iframer": "1.1.0",
|
"@braintree/iframer": "1.1.0",
|
||||||
"@braintree/sanitize-url": "6.0.2",
|
"@braintree/sanitize-url": "6.0.4",
|
||||||
"@braintree/uuid": "0.1.0",
|
"@braintree/uuid": "0.1.0",
|
||||||
"@braintree/wrap-promise": "2.1.0",
|
"@braintree/wrap-promise": "2.1.0",
|
||||||
"card-validator": "8.1.1",
|
"card-validator": "8.1.1",
|
||||||
@@ -17265,16 +17265,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braintree-web-drop-in": {
|
"node_modules/braintree-web-drop-in": {
|
||||||
"version": "1.40.0",
|
"version": "1.41.0",
|
||||||
"resolved": "https://registry.npmjs.org/braintree-web-drop-in/-/braintree-web-drop-in-1.40.0.tgz",
|
"resolved": "https://registry.npmjs.org/braintree-web-drop-in/-/braintree-web-drop-in-1.41.0.tgz",
|
||||||
"integrity": "sha512-YrR+KitYu0X6+qkP2hvkjAolMJw3jKyd+Yppk+LZUFk1Bbfj7YayLjTQqLTyFT5MQmzpQKLYwbI75BBClPGc+Q==",
|
"integrity": "sha512-cpFY13iyoPNCTIOU7dipHmOvoblUtYFuA7ADAm0DUPk6oqxFz4EIr94R0Yg2rCabvjeauINDf01Y2d7/E1IaXg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/asset-loader": "0.4.4",
|
"@braintree/asset-loader": "0.4.4",
|
||||||
"@braintree/browser-detection": "1.14.0",
|
"@braintree/browser-detection": "1.17.1",
|
||||||
"@braintree/event-emitter": "0.4.1",
|
"@braintree/event-emitter": "0.4.1",
|
||||||
"@braintree/uuid": "0.1.0",
|
"@braintree/uuid": "0.1.0",
|
||||||
"@braintree/wrap-promise": "2.1.0",
|
"@braintree/wrap-promise": "2.1.0",
|
||||||
"braintree-web": "3.96.1"
|
"braintree-web": "3.97.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braintree-web/node_modules/promise-polyfill": {
|
"node_modules/braintree-web/node_modules/promise-polyfill": {
|
||||||
|
|||||||
@@ -165,7 +165,7 @@
|
|||||||
"argon2-browser": "1.18.0",
|
"argon2-browser": "1.18.0",
|
||||||
"big-integer": "1.6.51",
|
"big-integer": "1.6.51",
|
||||||
"bootstrap": "4.6.0",
|
"bootstrap": "4.6.0",
|
||||||
"braintree-web-drop-in": "1.40.0",
|
"braintree-web-drop-in": "1.41.0",
|
||||||
"bufferutil": "4.0.8",
|
"bufferutil": "4.0.8",
|
||||||
"chalk": "4.1.2",
|
"chalk": "4.1.2",
|
||||||
"commander": "7.2.0",
|
"commander": "7.2.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user