From 7fbfce953d28a40d8e7bdb8b32fa154ac8b40672 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 23 Nov 2022 17:26:57 -0500 Subject: [PATCH] [PS-1854] Split services between background and visualizations (#4075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Elevate Map <-> Record JSON helpers to Utils * Build Account from a StateService provided AccountDeserializer * Allow Manifest V2 usage of session sync Expands use of SessionSyncer to all Subject types. Correctly handles replay buffer for each type to ignore the flood of data upon subscription to each Subject type. * Create browser-synced Policy Service * Move BrowserFolderService * Libs account serialization improvements * Serialize Browser Accounts * Separate StateService in background/visualizations Visualizer state services share storages with background page, which nicely emulates mv3 synchronization through session/local storage. There should not be multithreading issues since all of these services are still running through a single thread, we just now have multiple places we are reading/writing data from. Smaller improvements * Rename browser's state service to BrowserStateService * Remove unused WithPrototype decorator :celebrate: * Removed conversion on withPrototypeForArrayMembers. It's reasonable to think that if the type is maintained, it doesn't need conversion. Eventually, we should be able to remove the withPrototypeForArrayMembers decorator as well, but that will require a bit more work on (de)serialization of the Accounts.data property. * Make Record <-> Map idempotent Should we get in a situation where we _think_ an object has been jsonified, but hasn't been, we need to correctly deal with the object received to create our target. * Check all requirements while duck typing * Name client services after the client * Use union type to limit initialize options * Fixup usages of `initializeAs` * Add OrganizationService to synced services Co-Authored-By: Daniel James Smith * Add Settings service to synced services Co-Authored-By: Daniel James Smith * Add missing BrowserStateService * Fix factories to use browser-specific service overides * Fix org-service registration in services.module * Revert "Add missing BrowserStateService" This reverts commit 81cf384e872718e86e84f9c54731e78a083e1ee3. * Fix session syncer tests * Fix synced item metadata tests * Early return null json objects * Prefer abstract service dependencies * Prefer minimal browser service overrides * [SG-632] - Change forwarded providers radio buttons list to dropdown (#4045) * SG-632 - Changed forwarded providers list of radio buttons to dropdown * SG-632 - Added role attributes to improve accessibility. * SG-632 - Added sorting to array and empty option * SG-632 - Fix styling to match standards. * rename cipehrs component to vault items component (#4081) * Update the version hash for the QA Web build artifact to follow SemVer syntax (#4102) * Remove extra call to toJSON() (#4101) Co-authored-by: Daniel James Smith Co-authored-by: Daniel James Smith Co-authored-by: Carlos Gonçalves Co-authored-by: Jake Fink Co-authored-by: Joseph Flinn <58369717+joseph-flinn@users.noreply.github.com> Co-authored-by: Robyn MacCallum --- .../browser/src/background/idle.background.ts | 4 +- .../browser/src/background/main.background.ts | 22 ++--- .../src/background/notification.background.ts | 4 +- .../folder-service.factory.ts | 4 +- .../organization-service.factory.ts | 5 +- .../policy-service.factory.ts | 5 +- .../settings-service.factory.ts | 5 +- .../state-service.factory.ts | 8 +- apps/browser/src/clipboard/clipboard-state.ts | 6 +- ...rate-password-to-clipboard-command.spec.ts | 6 +- .../generate-password-to-clipboard-command.ts | 4 +- .../browser-session.decorator.spec.ts | 12 +-- .../browser-session.decorator.ts | 12 ++- .../session-sync.decorator.ts | 13 +-- .../session-syncer.spec.ts | 89 +++++++++++++------ .../session-sync-observable/session-syncer.ts | 46 ++++++---- .../sync-item-metadata.ts | 14 ++- .../synced-item-metadata.spec.ts | 38 ++++++-- apps/browser/src/listeners/update-badge.ts | 4 +- apps/browser/src/models/account.ts | 24 +++++ .../src/models/browserComponentState.ts | 10 +++ .../models/browserGroupingsComponentState.ts | 26 ++++++ .../src/models/browserSendComponentState.ts | 19 ++++ apps/browser/src/popup/app.component.ts | 4 +- .../src/popup/send/send-add-edit.component.ts | 4 +- .../popup/send/send-groupings.component.ts | 8 +- .../src/popup/send/send-type.component.ts | 4 +- .../src/popup/services/init.service.ts | 2 +- .../src/popup/services/services.module.ts | 73 ++++++++++++--- .../src/popup/vault/vault-filter.component.ts | 8 +- .../src/popup/vault/vault-items.component.ts | 4 +- ...te.service.ts => browser-state.service.ts} | 3 +- apps/browser/src/services/autofill.service.ts | 4 +- ...r.service.ts => browser-folder.service.ts} | 8 +- .../services/browser-organization.service.ts | 12 +++ .../src/services/browser-policy.service.ts | 12 +++ .../src/services/browser-settings.service.ts | 11 +++ ....spec.ts => browser-state.service.spec.ts} | 47 +++++----- ...te.service.ts => browser-state.service.ts} | 32 ++++--- libs/angular/test-utils.ts | 3 + libs/common/spec/misc/utils.spec.ts | 68 ++++++++++++++ libs/common/src/misc/utils.ts | 59 ++++++++++++ libs/common/src/models/domain/account.ts | 8 +- libs/common/src/models/domain/organization.ts | 13 +++ libs/common/src/models/domain/state.spec.ts | 15 ++-- libs/common/src/models/domain/state.ts | 14 +-- libs/common/src/models/view/send-file.view.ts | 9 ++ libs/common/src/models/view/send-text.view.ts | 9 ++ libs/common/src/models/view/send.view.ts | 23 +++++ libs/common/src/services/cipher.service.ts | 2 +- .../organization/organization.service.ts | 2 +- .../src/services/policy/policy.service.ts | 2 +- libs/common/src/services/settings.service.ts | 2 +- libs/common/src/services/state.service.ts | 67 +++----------- 54 files changed, 671 insertions(+), 241 deletions(-) rename apps/browser/src/services/abstractions/{state.service.ts => browser-state.service.ts} (91%) rename apps/browser/src/services/{folders/folder.service.ts => browser-folder.service.ts} (57%) create mode 100644 apps/browser/src/services/browser-organization.service.ts create mode 100644 apps/browser/src/services/browser-policy.service.ts create mode 100644 apps/browser/src/services/browser-settings.service.ts rename apps/browser/src/services/{state.service.spec.ts => browser-state.service.spec.ts} (77%) rename apps/browser/src/services/{state.service.ts => browser-state.service.ts} (81%) create mode 100644 libs/angular/test-utils.ts diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts index 56e2e2dd7e1..1a8c5ae5c50 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -1,7 +1,7 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; const IdleInterval = 60 * 5; // 5 minutes @@ -12,7 +12,7 @@ export default class IdleBackground { constructor( private vaultTimeoutService: VaultTimeoutService, - private stateService: StateService, + private stateService: BrowserStateService, private notificationsService: NotificationsService ) { this.idle = chrome.idle || (browser != null ? browser.idle : null); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 581da587e45..2d370c91bb7 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -61,14 +61,11 @@ import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.s import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service"; import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service"; -import { OrganizationService } from "@bitwarden/common/services/organization/organization.service"; import { PasswordGenerationService } from "@bitwarden/common/services/passwordGeneration.service"; import { PolicyApiService } from "@bitwarden/common/services/policy/policy-api.service"; -import { PolicyService } from "@bitwarden/common/services/policy/policy.service"; import { ProviderService } from "@bitwarden/common/services/provider.service"; import { SearchService } from "@bitwarden/common/services/search.service"; import { SendService } from "@bitwarden/common/services/send.service"; -import { SettingsService } from "@bitwarden/common/services/settings.service"; import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; import { SyncService } from "@bitwarden/common/services/sync/sync.service"; import { SyncNotifierService } from "@bitwarden/common/services/sync/syncNotifier.service"; @@ -89,19 +86,22 @@ import { UpdateBadge } from "../listeners/update-badge"; import { Account } from "../models/account"; import { PopupUtilsService } from "../popup/services/popup-utils.service"; import { AutofillService as AutofillServiceAbstraction } from "../services/abstractions/autofill.service"; -import { StateService as StateServiceAbstraction } from "../services/abstractions/state.service"; +import { BrowserStateService as StateServiceAbstraction } from "../services/abstractions/browser-state.service"; import AutofillService from "../services/autofill.service"; import { BrowserEnvironmentService } from "../services/browser-environment.service"; +import { BrowserFolderService } from "../services/browser-folder.service"; +import { BrowserOrganizationService } from "../services/browser-organization.service"; +import { BrowserPolicyService } from "../services/browser-policy.service"; +import { BrowserSettingsService } from "../services/browser-settings.service"; +import { BrowserStateService } from "../services/browser-state.service"; import { BrowserCryptoService } from "../services/browserCrypto.service"; import BrowserLocalStorageService from "../services/browserLocalStorage.service"; import BrowserMessagingService from "../services/browserMessaging.service"; import BrowserMessagingPrivateModeBackgroundService from "../services/browserMessagingPrivateModeBackground.service"; import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; -import { FolderService } from "../services/folders/folder.service"; import I18nService from "../services/i18n.service"; import { KeyGenerationService } from "../services/keyGeneration.service"; import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service"; -import { StateService } from "../services/state.service"; import { VaultFilterService } from "../services/vaultFilter.service"; import VaultTimeoutService from "../services/vaultTimeout/vaultTimeout.service"; @@ -227,7 +227,7 @@ export default class MainBackground { this.secureStorageService, new StateFactory(GlobalState, Account) ); - this.stateService = new StateService( + this.stateService = new BrowserStateService( this.storageService, this.secureStorageService, this.memoryStorageService, @@ -282,7 +282,7 @@ export default class MainBackground { this.appIdService, (expired: boolean) => this.logout(expired) ); - this.settingsService = new SettingsService(this.stateService); + this.settingsService = new BrowserSettingsService(this.stateService); this.fileUploadService = new FileUploadService(this.logService, this.apiService); this.cipherService = new CipherService( this.cryptoService, @@ -295,7 +295,7 @@ export default class MainBackground { this.stateService, this.encryptService ); - this.folderService = new FolderService( + this.folderService = new BrowserFolderService( this.cryptoService, this.i18nService, this.cipherService, @@ -317,8 +317,8 @@ export default class MainBackground { this.stateService ); this.syncNotifierService = new SyncNotifierService(); - this.organizationService = new OrganizationService(this.stateService); - this.policyService = new PolicyService(this.stateService, this.organizationService); + this.organizationService = new BrowserOrganizationService(this.stateService); + this.policyService = new BrowserPolicyService(this.stateService, this.organizationService); this.policyApiService = new PolicyApiService( this.policyService, this.apiService, diff --git a/apps/browser/src/background/notification.background.ts b/apps/browser/src/background/notification.background.ts index 127fc5203d7..2da4791857d 100644 --- a/apps/browser/src/background/notification.background.ts +++ b/apps/browser/src/background/notification.background.ts @@ -15,7 +15,7 @@ import { LoginView } from "@bitwarden/common/models/view/login.view"; import { BrowserApi } from "../browser/browserApi"; import { AutofillService } from "../services/abstractions/autofill.service"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; import AddChangePasswordQueueMessage from "./models/addChangePasswordQueueMessage"; import AddLoginQueueMessage from "./models/addLoginQueueMessage"; @@ -33,7 +33,7 @@ export default class NotificationBackground { private authService: AuthService, private policyService: PolicyService, private folderService: FolderService, - private stateService: StateService + private stateService: BrowserStateService ) {} async init() { diff --git a/apps/browser/src/background/service_factories/folder-service.factory.ts b/apps/browser/src/background/service_factories/folder-service.factory.ts index a7c90d234b1..bb35970325f 100644 --- a/apps/browser/src/background/service_factories/folder-service.factory.ts +++ b/apps/browser/src/background/service_factories/folder-service.factory.ts @@ -1,6 +1,6 @@ import { FolderService as AbstractFolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; -import { FolderService } from "../../services/folders/folder.service"; +import { BrowserFolderService } from "../../services/browser-folder.service"; import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory"; import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory"; @@ -28,7 +28,7 @@ export function folderServiceFactory( "folderService", opts, async () => - new FolderService( + new BrowserFolderService( await cryptoServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await cipherServiceFactory(cache, opts), diff --git a/apps/browser/src/background/service_factories/organization-service.factory.ts b/apps/browser/src/background/service_factories/organization-service.factory.ts index ea11d32e26a..4f2eaee8058 100644 --- a/apps/browser/src/background/service_factories/organization-service.factory.ts +++ b/apps/browser/src/background/service_factories/organization-service.factory.ts @@ -1,5 +1,6 @@ import { OrganizationService as AbstractOrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; -import { OrganizationService } from "@bitwarden/common/services/organization/organization.service"; + +import { BrowserOrganizationService } from "../../services/browser-organization.service"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; @@ -17,6 +18,6 @@ export function organizationServiceFactory( cache, "organizationService", opts, - async () => new OrganizationService(await stateServiceFactory(cache, opts)) + async () => new BrowserOrganizationService(await stateServiceFactory(cache, opts)) ); } diff --git a/apps/browser/src/background/service_factories/policy-service.factory.ts b/apps/browser/src/background/service_factories/policy-service.factory.ts index d4940bef259..d20bca3c62e 100644 --- a/apps/browser/src/background/service_factories/policy-service.factory.ts +++ b/apps/browser/src/background/service_factories/policy-service.factory.ts @@ -1,5 +1,6 @@ import { PolicyService as AbstractPolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; -import { PolicyService } from "@bitwarden/common/services/policy/policy.service"; + +import { BrowserPolicyService } from "../../services/browser-policy.service"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { @@ -26,7 +27,7 @@ export function policyServiceFactory( "policyService", opts, async () => - new PolicyService( + new BrowserPolicyService( await stateServiceFactory(cache, opts), await organizationServiceFactory(cache, opts) ) diff --git a/apps/browser/src/background/service_factories/settings-service.factory.ts b/apps/browser/src/background/service_factories/settings-service.factory.ts index 745a6d08d60..73e0ae52032 100644 --- a/apps/browser/src/background/service_factories/settings-service.factory.ts +++ b/apps/browser/src/background/service_factories/settings-service.factory.ts @@ -1,5 +1,6 @@ import { SettingsService as AbstractSettingsService } from "@bitwarden/common/abstractions/settings.service"; -import { SettingsService } from "@bitwarden/common/services/settings.service"; + +import { BrowserSettingsService } from "../../services/browser-settings.service"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; @@ -16,6 +17,6 @@ export function settingsServiceFactory( cache, "settingsService", opts, - async () => new SettingsService(await stateServiceFactory(cache, opts)) + async () => new BrowserSettingsService(await stateServiceFactory(cache, opts)) ); } diff --git a/apps/browser/src/background/service_factories/state-service.factory.ts b/apps/browser/src/background/service_factories/state-service.factory.ts index 1b81567ac52..6d2c5cb4fa7 100644 --- a/apps/browser/src/background/service_factories/state-service.factory.ts +++ b/apps/browser/src/background/service_factories/state-service.factory.ts @@ -2,7 +2,7 @@ import { StateFactory } from "@bitwarden/common/factories/stateFactory"; import { GlobalState } from "@bitwarden/common/models/domain/global-state"; import { Account } from "../../models/account"; -import { StateService } from "../../services/state.service"; +import { BrowserStateService } from "../../services/browser-state.service"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; @@ -34,15 +34,15 @@ export type StateServiceInitOptions = StateServiceFactoryOptions & StateMigrationServiceInitOptions; export async function stateServiceFactory( - cache: { stateService?: StateService } & CachedServices, + cache: { stateService?: BrowserStateService } & CachedServices, opts: StateServiceInitOptions -): Promise { +): Promise { const service = await factory( cache, "stateService", opts, async () => - await new StateService( + await new BrowserStateService( await diskStorageServiceFactory(cache, opts), await secureStorageServiceFactory(cache, opts), await memoryStorageServiceFactory(cache, opts), diff --git a/apps/browser/src/clipboard/clipboard-state.ts b/apps/browser/src/clipboard/clipboard-state.ts index a1c15addc0a..cfa2f9459f8 100644 --- a/apps/browser/src/clipboard/clipboard-state.ts +++ b/apps/browser/src/clipboard/clipboard-state.ts @@ -1,10 +1,10 @@ -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; const clearClipboardStorageKey = "clearClipboardTime"; -export const getClearClipboardTime = async (stateService: StateService) => { +export const getClearClipboardTime = async (stateService: BrowserStateService) => { return await stateService.getFromSessionMemory(clearClipboardStorageKey); }; -export const setClearClipboardTime = async (stateService: StateService, time: number) => { +export const setClearClipboardTime = async (stateService: BrowserStateService, time: number) => { await stateService.setInSessionMemory(clearClipboardStorageKey, time); }; diff --git a/apps/browser/src/clipboard/generate-password-to-clipboard-command.spec.ts b/apps/browser/src/clipboard/generate-password-to-clipboard-command.spec.ts index e9c2141211f..5ab36b06fef 100644 --- a/apps/browser/src/clipboard/generate-password-to-clipboard-command.spec.ts +++ b/apps/browser/src/clipboard/generate-password-to-clipboard-command.spec.ts @@ -3,7 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; import { BrowserApi } from "../browser/browserApi"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; import { setClearClipboardTime } from "./clipboard-state"; import { GeneratePasswordToClipboardCommand } from "./generate-password-to-clipboard-command"; @@ -19,13 +19,13 @@ const setClearClipboardTimeMock = setClearClipboardTime as jest.Mock; describe("GeneratePasswordToClipboardCommand", () => { let passwordGenerationService: MockProxy; - let stateService: MockProxy; + let stateService: MockProxy; let sut: GeneratePasswordToClipboardCommand; beforeEach(() => { passwordGenerationService = mock(); - stateService = mock(); + stateService = mock(); passwordGenerationService.getOptions.mockResolvedValue([{ length: 8 }, {} as any]); diff --git a/apps/browser/src/clipboard/generate-password-to-clipboard-command.ts b/apps/browser/src/clipboard/generate-password-to-clipboard-command.ts index ca92d2c686f..e6d4d6b8b0c 100644 --- a/apps/browser/src/clipboard/generate-password-to-clipboard-command.ts +++ b/apps/browser/src/clipboard/generate-password-to-clipboard-command.ts @@ -1,6 +1,6 @@ import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; import { setClearClipboardTime } from "./clipboard-state"; import { copyToClipboard } from "./copy-to-clipboard-command"; @@ -8,7 +8,7 @@ import { copyToClipboard } from "./copy-to-clipboard-command"; export class GeneratePasswordToClipboardCommand { constructor( private passwordGenerationService: PasswordGenerationService, - private stateService: StateService + private stateService: BrowserStateService ) {} async generatePasswordToClipboard(tab: chrome.tabs.Tab) { diff --git a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts index cc8a5618760..92c5dfb0170 100644 --- a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts +++ b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts @@ -1,6 +1,6 @@ import { BehaviorSubject } from "rxjs"; -import { StateService } from "../../services/state.service"; +import { BrowserStateService } from "../../services/browser-state.service"; import { browserSession } from "./browser-session.decorator"; import { SessionStorable } from "./session-storable"; @@ -22,25 +22,25 @@ describe("browserSession decorator", () => { }); it("should create if StateService is a constructor argument", () => { - const stateService = Object.create(StateService.prototype, {}); + const stateService = Object.create(BrowserStateService.prototype, {}); @browserSession class TestClass { - constructor(private stateService: StateService) {} + constructor(private stateService: BrowserStateService) {} } expect(new TestClass(stateService)).toBeDefined(); }); describe("interaction with @sessionSync decorator", () => { - let stateService: StateService; + let stateService: BrowserStateService; @browserSession class TestClass { @sessionSync({ initializer: (s: string) => s }) private behaviorSubject = new BehaviorSubject(""); - constructor(private stateService: StateService) {} + constructor(private stateService: BrowserStateService) {} fromJSON(json: any) { this.behaviorSubject.next(json); @@ -48,7 +48,7 @@ describe("browserSession decorator", () => { } beforeEach(() => { - stateService = Object.create(StateService.prototype, {}) as StateService; + stateService = Object.create(BrowserStateService.prototype, {}) as BrowserStateService; }); it("should create a session syncer", () => { diff --git a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts index 73cdf767357..5d9d56c1d71 100644 --- a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts +++ b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts @@ -1,6 +1,6 @@ import { Constructor } from "type-fest"; -import { StateService } from "../../services/state.service"; +import { BrowserStateService } from "../../services/browser-state.service"; import { SessionStorable } from "./session-storable"; import { SessionSyncer } from "./session-syncer"; @@ -22,7 +22,13 @@ export function browserSession>(constructor: TCto super(...args); // Require state service to be injected - const stateService = args.find((arg) => arg instanceof StateService); + const stateService: BrowserStateService = [this as any] + .concat(args) + .find( + (arg) => + typeof arg.setInSessionMemory === "function" && + typeof arg.getFromSessionMemory === "function" + ); if (!stateService) { throw new Error( `Cannot decorate ${constructor.name} with browserSession, Browser's StateService must be injected` @@ -38,7 +44,7 @@ export function browserSession>(constructor: TCto ); } - buildSyncer(metadata: SyncedItemMetadata, stateService: StateService) { + buildSyncer(metadata: SyncedItemMetadata, stateService: BrowserStateService) { const syncer = new SessionSyncer((this as any)[metadata.propertyKey], stateService, metadata); syncer.init(); return syncer; diff --git a/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts b/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts index df0764528f7..071322900e1 100644 --- a/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts +++ b/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts @@ -1,11 +1,12 @@ import { Jsonify } from "type-fest"; import { SessionStorable } from "./session-storable"; +import { InitializeOptions } from "./sync-item-metadata"; -class BuildOptions { +class BuildOptions> { ctor?: new () => T; - initializer?: (keyValuePair: Jsonify) => T; - initializeAsArray? = false; + initializer?: (keyValuePair: TJson) => T; + initializeAs?: InitializeOptions; } /** @@ -20,10 +21,10 @@ class BuildOptions { * @param buildOptions * Builders for the value, requires either a constructor (ctor) for your BehaviorSubject type or an * initializer function that takes a key value pair representation of the BehaviorSubject data - * and returns your instantiated BehaviorSubject value. `initializeAsArray can optionally be used to indicate + * and returns your instantiated BehaviorSubject value. `initializeAs can optionally be used to indicate * the provided initializer function should be used to build an array of values. For example, * ```ts - * \@sessionSync({ initializer: Foo.fromJSON, initializeAsArray: true }) + * \@sessionSync({ initializer: Foo.fromJSON, initializeAs: 'array' }) * ``` * is equivalent to * ``` @@ -46,7 +47,7 @@ export function sessionSync(buildOptions: BuildOptions) { sessionKey: `${prototype.constructor.name}_${propertyKey}`, ctor: buildOptions.ctor, initializer: buildOptions.initializer, - initializeAsArray: buildOptions.initializeAsArray, + initializeAs: buildOptions.initializeAs ?? "object", }); }; } diff --git a/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts b/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts index 5286cece1bb..00a0da433a5 100644 --- a/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts +++ b/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts @@ -1,8 +1,9 @@ +import { awaitAsync as flushAsyncObservables } from "@bitwarden/angular/../test-utils"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, ReplaySubject } from "rxjs"; import { BrowserApi } from "../../browser/browserApi"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { SessionSyncer } from "./session-syncer"; import { SyncedItemMetadata } from "./sync-item-metadata"; @@ -10,8 +11,13 @@ import { SyncedItemMetadata } from "./sync-item-metadata"; describe("session syncer", () => { const propertyKey = "behaviorSubject"; const sessionKey = "Test__" + propertyKey; - const metaData = { propertyKey, sessionKey, initializer: (s: string) => s }; - let stateService: MockProxy; + const metaData: SyncedItemMetadata = { + propertyKey, + sessionKey, + initializer: (s: string) => s, + initializeAs: "object", + }; + let stateService: MockProxy; let sut: SessionSyncer; let behaviorSubject: BehaviorSubject; @@ -23,7 +29,7 @@ describe("session syncer", () => { manifest_version: 3, }); - stateService = mock(); + stateService = mock(); sut = new SessionSyncer(behaviorSubject, stateService, metaData); }); @@ -34,53 +40,85 @@ describe("session syncer", () => { }); describe("constructor", () => { - it("should throw if behaviorSubject is not an instance of BehaviorSubject", () => { + it("should throw if subject is not an instance of Subject", () => { expect(() => { new SessionSyncer({} as any, stateService, null); - }).toThrowError("behaviorSubject must be an instance of BehaviorSubject"); + }).toThrowError("subject must inherit from Subject"); }); it("should create if either ctor or initializer is provided", () => { expect( - new SessionSyncer(behaviorSubject, stateService, { propertyKey, sessionKey, ctor: String }) + new SessionSyncer(behaviorSubject, stateService, { + propertyKey, + sessionKey, + ctor: String, + initializeAs: "object", + }) ).toBeDefined(); expect( new SessionSyncer(behaviorSubject, stateService, { propertyKey, sessionKey, initializer: (s: any) => s, + initializeAs: "object", }) ).toBeDefined(); }); it("should throw if neither ctor or initializer is provided", () => { expect(() => { - new SessionSyncer(behaviorSubject, stateService, { propertyKey, sessionKey }); + new SessionSyncer(behaviorSubject, stateService, { + propertyKey, + sessionKey, + initializeAs: "object", + }); }).toThrowError("ctor or initializer must be provided"); }); }); - describe("manifest v2 init", () => { - let observeSpy: jest.SpyInstance; - let listenForUpdatesSpy: jest.SpyInstance; - - beforeEach(() => { - observeSpy = jest.spyOn(behaviorSubject, "subscribe").mockReturnThis(); - listenForUpdatesSpy = jest.spyOn(BrowserApi, "messageListener").mockReturnValue(); - jest.spyOn(chrome.runtime, "getManifest").mockReturnValue({ - name: "bitwarden-test", - version: "0.0.0", - manifest_version: 2, - }); + describe("init", () => { + it("should ignore all updates currently in a ReplaySubject's buffer", () => { + const replaySubject = new ReplaySubject(Infinity); + replaySubject.next("1"); + replaySubject.next("2"); + replaySubject.next("3"); + sut = new SessionSyncer(replaySubject, stateService, metaData); + // block observing the subject + jest.spyOn(sut as any, "observe").mockImplementation(); sut.init(); + + expect(sut["ignoreNUpdates"]).toBe(3); }); - it("should not start observing", () => { - expect(observeSpy).not.toHaveBeenCalled(); + it("should ignore BehaviorSubject's initial value", () => { + const behaviorSubject = new BehaviorSubject("initial"); + sut = new SessionSyncer(behaviorSubject, stateService, metaData); + // block observing the subject + jest.spyOn(sut as any, "observe").mockImplementation(); + + sut.init(); + + expect(sut["ignoreNUpdates"]).toBe(1); }); - it("should not start listening", () => { - expect(listenForUpdatesSpy).not.toHaveBeenCalled(); + it("should grab an initial value from storage if it exists", () => { + stateService.hasInSessionMemory.mockResolvedValue(true); + //Block a call to update + const updateSpy = jest.spyOn(sut as any, "update").mockImplementation(); + + sut.init(); + + expect(updateSpy).toHaveBeenCalledWith(); + }); + + it("should not grab an initial value from storage if it does not exist", () => { + stateService.hasInSessionMemory.mockResolvedValue(false); + //Block a call to update + const updateSpy = jest.spyOn(sut as any, "update").mockImplementation(); + + sut.init(); + + expect(updateSpy).toHaveBeenCalledWith(); }); }); @@ -146,6 +184,7 @@ describe("session syncer", () => { stateService.getFromSessionMemory.mockResolvedValue("test"); await sut.updateFromMessage({ command: `${sessionKey}_update`, id: "different_id" }); + await flushAsyncObservables(); expect(stateService.getFromSessionMemory).toHaveBeenCalledTimes(1); expect(stateService.getFromSessionMemory).toHaveBeenCalledWith(sessionKey, builder); diff --git a/apps/browser/src/decorators/session-sync-observable/session-syncer.ts b/apps/browser/src/decorators/session-sync-observable/session-syncer.ts index 2acfed2954f..68294b68c3d 100644 --- a/apps/browser/src/decorators/session-sync-observable/session-syncer.ts +++ b/apps/browser/src/decorators/session-sync-observable/session-syncer.ts @@ -1,9 +1,9 @@ -import { BehaviorSubject, concatMap, Subscription } from "rxjs"; +import { BehaviorSubject, concatMap, ReplaySubject, Subject, Subscription } from "rxjs"; import { Utils } from "@bitwarden/common/misc/utils"; import { BrowserApi } from "../../browser/browserApi"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { SyncedItemMetadata } from "./sync-item-metadata"; @@ -11,16 +11,16 @@ export class SessionSyncer { subscription: Subscription; id = Utils.newGuid(); - // everyone gets the same initial values - private ignoreNextUpdate = true; + // ignore initial values + private ignoreNUpdates = 0; constructor( - private behaviorSubject: BehaviorSubject, - private stateService: StateService, + private subject: Subject, + private stateService: BrowserStateService, private metaData: SyncedItemMetadata ) { - if (!(behaviorSubject instanceof BehaviorSubject)) { - throw new Error("behaviorSubject must be an instance of BehaviorSubject"); + if (!(subject instanceof Subject)) { + throw new Error("subject must inherit from Subject"); } if (metaData.ctor == null && metaData.initializer == null) { @@ -29,11 +29,23 @@ export class SessionSyncer { } init() { - if (BrowserApi.manifestVersion !== 3) { - return; + switch (this.subject.constructor) { + case ReplaySubject: + // ignore all updates currently in the buffer + this.ignoreNUpdates = (this.subject as any)._buffer.length; + break; + case BehaviorSubject: + this.ignoreNUpdates = 1; + break; + default: + break; } this.observe(); + if (this.stateService.hasInSessionMemory(this.metaData.sessionKey)) { + this.update(); + } + this.listenForUpdates(); } @@ -41,11 +53,11 @@ export class SessionSyncer { // This may be a memory leak. // There is no good time to unsubscribe from this observable. Hopefully Manifest V3 clears memory from temporary // contexts. If so, this is handled by destruction of the context. - this.subscription = this.behaviorSubject + this.subscription = this.subject .pipe( concatMap(async (next) => { - if (this.ignoreNextUpdate) { - this.ignoreNextUpdate = false; + if (this.ignoreNUpdates > 0) { + this.ignoreNUpdates -= 1; return; } await this.updateSession(next); @@ -66,10 +78,14 @@ export class SessionSyncer { if (message.command != this.updateMessageCommand || message.id === this.id) { return; } + this.update(); + } + + async update() { const builder = SyncedItemMetadata.builder(this.metaData); const value = await this.stateService.getFromSessionMemory(this.metaData.sessionKey, builder); - this.ignoreNextUpdate = true; - this.behaviorSubject.next(value); + this.ignoreNUpdates = 1; + this.subject.next(value); } private async updateSession(value: any) { diff --git a/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts b/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts index 2b3f4715d46..facfda32fd8 100644 --- a/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts +++ b/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts @@ -1,17 +1,27 @@ +export type InitializeOptions = "array" | "record" | "object"; + export class SyncedItemMetadata { propertyKey: string; sessionKey: string; ctor?: new () => any; initializer?: (keyValuePair: any) => any; - initializeAsArray?: boolean; + initializeAs: InitializeOptions; static builder(metadata: SyncedItemMetadata): (o: any) => any { const itemBuilder = metadata.initializer != null ? metadata.initializer : (o: any) => Object.assign(new metadata.ctor(), o); - if (metadata.initializeAsArray) { + if (metadata.initializeAs === "array") { return (keyValuePair: any) => keyValuePair.map((o: any) => itemBuilder(o)); + } else if (metadata.initializeAs === "record") { + return (keyValuePair: any) => { + const record: Record = {}; + for (const key in keyValuePair) { + record[key] = itemBuilder(keyValuePair[key]); + } + return record; + }; } else { return (keyValuePair: any) => itemBuilder(keyValuePair); } diff --git a/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts b/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts index 5cd869a5b67..12b0b57d523 100644 --- a/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts +++ b/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts @@ -8,32 +8,60 @@ describe("builder", () => { const ctor = TestClass; it("should use initializer if provided", () => { - const metadata = { propertyKey, sessionKey: key, initializer }; + const metadata: SyncedItemMetadata = { + propertyKey, + sessionKey: key, + initializer, + initializeAs: "object", + }; const builder = SyncedItemMetadata.builder(metadata); expect(builder({})).toBe("used initializer"); }); it("should use ctor if initializer is not provided", () => { - const metadata = { propertyKey, sessionKey: key, ctor }; + const metadata: SyncedItemMetadata = { + propertyKey, + sessionKey: key, + ctor, + initializeAs: "object", + }; const builder = SyncedItemMetadata.builder(metadata); expect(builder({})).toBeInstanceOf(TestClass); }); it("should prefer initializer over ctor", () => { - const metadata = { propertyKey, sessionKey: key, ctor, initializer }; + const metadata: SyncedItemMetadata = { + propertyKey, + sessionKey: key, + ctor, + initializer, + initializeAs: "object", + }; const builder = SyncedItemMetadata.builder(metadata); expect(builder({})).toBe("used initializer"); }); it("should honor initialize as array", () => { - const metadata = { + const metadata: SyncedItemMetadata = { propertyKey, sessionKey: key, initializer: initializer, - initializeAsArray: true, + initializeAs: "array", }; const builder = SyncedItemMetadata.builder(metadata); expect(builder([{}])).toBeInstanceOf(Array); expect(builder([{}])[0]).toBe("used initializer"); }); + + it("should honor initialize as record", () => { + const metadata: SyncedItemMetadata = { + propertyKey, + sessionKey: key, + initializer: initializer, + initializeAs: "record", + }; + const builder = SyncedItemMetadata.builder(metadata); + expect(builder({ key: "" })).toBeInstanceOf(Object); + expect(builder({ key: "" })).toStrictEqual({ key: "used initializer" }); + }); }); diff --git a/apps/browser/src/listeners/update-badge.ts b/apps/browser/src/listeners/update-badge.ts index 9c7c122a45a..8762a15ab2a 100644 --- a/apps/browser/src/listeners/update-badge.ts +++ b/apps/browser/src/listeners/update-badge.ts @@ -15,7 +15,7 @@ import { searchServiceFactory } from "../background/service_factories/search-ser import { stateServiceFactory } from "../background/service_factories/state-service.factory"; import { BrowserApi } from "../browser/browserApi"; import { Account } from "../models/account"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; export type BadgeOptions = { @@ -25,7 +25,7 @@ export type BadgeOptions = { export class UpdateBadge { private authService: AuthService; - private stateService: StateService; + private stateService: BrowserStateService; private cipherService: CipherService; private badgeAction: typeof chrome.action; private sidebarAction: OperaSidebarAction | FirefoxSidebarAction; diff --git a/apps/browser/src/models/account.ts b/apps/browser/src/models/account.ts index f49c55d2909..cfbcbecf979 100644 --- a/apps/browser/src/models/account.ts +++ b/apps/browser/src/models/account.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { Account as BaseAccount, AccountSettings as BaseAccountSettings, @@ -9,6 +11,14 @@ import { BrowserSendComponentState } from "./browserSendComponentState"; export class AccountSettings extends BaseAccountSettings { vaultTimeout = -1; // On Restart + + static fromJSON(json: Jsonify): AccountSettings { + if (json == null) { + return null; + } + + return Object.assign(new AccountSettings(), json, super.fromJSON(json)); + } } export class Account extends BaseAccount { @@ -29,4 +39,18 @@ export class Account extends BaseAccount { this.ciphers = init?.ciphers ?? new BrowserComponentState(); this.sendType = init?.sendType ?? new BrowserComponentState(); } + + static fromJSON(json: Jsonify): Account { + if (json == null) { + return null; + } + + return Object.assign(new Account({}), json, super.fromJSON(json), { + settings: AccountSettings.fromJSON(json.settings), + groupings: BrowserGroupingsComponentState.fromJSON(json.groupings), + send: BrowserSendComponentState.fromJSON(json.send), + ciphers: BrowserComponentState.fromJSON(json.ciphers), + sendType: BrowserComponentState.fromJSON(json.sendType), + }); + } } diff --git a/apps/browser/src/models/browserComponentState.ts b/apps/browser/src/models/browserComponentState.ts index d968726c413..c5540d088ff 100644 --- a/apps/browser/src/models/browserComponentState.ts +++ b/apps/browser/src/models/browserComponentState.ts @@ -1,4 +1,14 @@ +import { Jsonify } from "type-fest"; + export class BrowserComponentState { scrollY: number; searchText: string; + + static fromJSON(json: Jsonify) { + if (json == null) { + return null; + } + + return Object.assign(new BrowserComponentState(), json); + } } diff --git a/apps/browser/src/models/browserGroupingsComponentState.ts b/apps/browser/src/models/browserGroupingsComponentState.ts index 63eb4aaa88f..f406e3d8271 100644 --- a/apps/browser/src/models/browserGroupingsComponentState.ts +++ b/apps/browser/src/models/browserGroupingsComponentState.ts @@ -1,7 +1,9 @@ import { CipherType } from "@bitwarden/common/enums/cipherType"; +import { Utils } from "@bitwarden/common/misc/utils"; import { CipherView } from "@bitwarden/common/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/models/view/collection.view"; import { FolderView } from "@bitwarden/common/models/view/folder.view"; +import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify"; import { BrowserComponentState } from "./browserComponentState"; @@ -15,4 +17,28 @@ export class BrowserGroupingsComponentState extends BrowserComponentState { folders: FolderView[]; collections: CollectionView[]; deletedCount: number; + + toJSON() { + return Utils.merge(this, { + collectionCounts: Utils.mapToRecord(this.collectionCounts), + folderCounts: Utils.mapToRecord(this.folderCounts), + typeCounts: Utils.mapToRecord(this.typeCounts), + }); + } + + static fromJSON(json: DeepJsonify) { + if (json == null) { + return null; + } + + return Object.assign(new BrowserGroupingsComponentState(), json, { + favoriteCiphers: json.favoriteCiphers?.map((c) => CipherView.fromJSON(c)), + noFolderCiphers: json.noFolderCiphers?.map((c) => CipherView.fromJSON(c)), + ciphers: json.ciphers?.map((c) => CipherView.fromJSON(c)), + collectionCounts: Utils.recordToMap(json.collectionCounts), + folderCounts: Utils.recordToMap(json.folderCounts), + typeCounts: Utils.recordToMap(json.typeCounts), + folders: json.folders?.map((f) => FolderView.fromJSON(f)), + }); + } } diff --git a/apps/browser/src/models/browserSendComponentState.ts b/apps/browser/src/models/browserSendComponentState.ts index e2bf4eaa5d6..99508737ab1 100644 --- a/apps/browser/src/models/browserSendComponentState.ts +++ b/apps/browser/src/models/browserSendComponentState.ts @@ -1,9 +1,28 @@ import { SendType } from "@bitwarden/common/enums/sendType"; +import { Utils } from "@bitwarden/common/misc/utils"; import { SendView } from "@bitwarden/common/models/view/send.view"; +import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify"; import { BrowserComponentState } from "./browserComponentState"; export class BrowserSendComponentState extends BrowserComponentState { sends: SendView[]; typeCounts: Map; + + toJSON() { + return Utils.merge(this, { + typeCounts: Utils.mapToRecord(this.typeCounts), + }); + } + + static fromJSON(json: DeepJsonify) { + if (json == null) { + return null; + } + + return Object.assign(new BrowserSendComponentState(), json, { + sends: json.sends?.map((s) => SendView.fromJSON(s)), + typeCounts: Utils.recordToMap(json.typeCounts), + }); + } } diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index ec094cbe945..da7b0062b8b 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -19,7 +19,7 @@ import { MessagingService } from "@bitwarden/common/abstractions/messaging.servi import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { BrowserApi } from "../browser/browserApi"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; import { routerTransition } from "./app-routing.animations"; @@ -43,7 +43,7 @@ export class AppComponent implements OnInit, OnDestroy { private authService: AuthService, private i18nService: I18nService, private router: Router, - private stateService: StateService, + private stateService: BrowserStateService, private messagingService: MessagingService, private changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, diff --git a/apps/browser/src/popup/send/send-add-edit.component.ts b/apps/browser/src/popup/send/send-add-edit.component.ts index 2fb45996ad1..0355b764c30 100644 --- a/apps/browser/src/popup/send/send-add-edit.component.ts +++ b/apps/browser/src/popup/send/send-add-edit.component.ts @@ -12,7 +12,7 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; import { SendService } from "@bitwarden/common/abstractions/send.service"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { PopupUtilsService } from "../services/popup-utils.service"; @Component({ @@ -33,7 +33,7 @@ export class SendAddEditComponent extends BaseAddEditComponent { constructor( i18nService: I18nService, platformUtilsService: PlatformUtilsService, - stateService: StateService, + stateService: BrowserStateService, messagingService: MessagingService, policyService: PolicyService, environmentService: EnvironmentService, diff --git a/apps/browser/src/popup/send/send-groupings.component.ts b/apps/browser/src/popup/send/send-groupings.component.ts index 1af715ada0f..a5d63eb9d58 100644 --- a/apps/browser/src/popup/send/send-groupings.component.ts +++ b/apps/browser/src/popup/send/send-groupings.component.ts @@ -15,7 +15,7 @@ import { SendType } from "@bitwarden/common/enums/sendType"; import { SendView } from "@bitwarden/common/models/view/send.view"; import { BrowserSendComponentState } from "../../models/browserSendComponentState"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { PopupUtilsService } from "../services/popup-utils.service"; const ComponentId = "SendComponent"; @@ -42,7 +42,7 @@ export class SendGroupingsComponent extends BaseSendComponent { policyService: PolicyService, searchService: SearchService, private popupUtils: PopupUtilsService, - private stateService: StateService, + private stateService: BrowserStateService, private router: Router, private syncService: SyncService, private changeDetectorRef: ChangeDetectorRef, @@ -165,12 +165,12 @@ export class SendGroupingsComponent extends BaseSendComponent { } private async saveState() { - this.state = { + this.state = Object.assign(new BrowserSendComponentState(), { scrollY: this.popupUtils.getContentScrollY(window), searchText: this.searchText, sends: this.sends, typeCounts: this.typeCounts, - }; + }); await this.stateService.setBrowserSendComponentState(this.state); } diff --git a/apps/browser/src/popup/send/send-type.component.ts b/apps/browser/src/popup/send/send-type.component.ts index afd3daeeda5..e899ab9f00f 100644 --- a/apps/browser/src/popup/send/send-type.component.ts +++ b/apps/browser/src/popup/send/send-type.component.ts @@ -16,7 +16,7 @@ import { SendType } from "@bitwarden/common/enums/sendType"; import { SendView } from "@bitwarden/common/models/view/send.view"; import { BrowserComponentState } from "../../models/browserComponentState"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { PopupUtilsService } from "../services/popup-utils.service"; const ComponentId = "SendTypeComponent"; @@ -41,7 +41,7 @@ export class SendTypeComponent extends BaseSendComponent { policyService: PolicyService, searchService: SearchService, private popupUtils: PopupUtilsService, - private stateService: StateService, + private stateService: BrowserStateService, private route: ActivatedRoute, private location: Location, private changeDetectorRef: ChangeDetectorRef, diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index a73792cc10f..8008f6c88ce 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -5,7 +5,7 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService as StateServiceAbstraction } from "../../services/abstractions/state.service"; +import { BrowserStateService as StateServiceAbstraction } from "../../services/abstractions/browser-state.service"; import { PopupUtilsService } from "./popup-utils.service"; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 236ff4e5b18..0acd8c785d1 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -38,6 +38,7 @@ import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abs import { SendService } from "@bitwarden/common/abstractions/send.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; +import { StateMigrationService } from "@bitwarden/common/abstractions/stateMigration.service"; import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; import { TokenService } from "@bitwarden/common/abstractions/token.service"; @@ -47,6 +48,8 @@ import { UserVerificationService } from "@bitwarden/common/abstractions/userVeri import { UsernameGenerationService } from "@bitwarden/common/abstractions/usernameGeneration.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; +import { StateFactory } from "@bitwarden/common/factories/stateFactory"; +import { GlobalState } from "@bitwarden/common/models/domain/global-state"; import { AuthService } from "@bitwarden/common/services/auth.service"; import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service"; import { LoginService } from "@bitwarden/common/services/login.service"; @@ -54,9 +57,14 @@ import { SearchService } from "@bitwarden/common/services/search.service"; import MainBackground from "../../background/main.background"; import { BrowserApi } from "../../browser/browserApi"; +import { Account } from "../../models/account"; import { AutofillService } from "../../services/abstractions/autofill.service"; -import { StateService as StateServiceAbstraction } from "../../services/abstractions/state.service"; +import { BrowserStateService as StateServiceAbstraction } from "../../services/abstractions/browser-state.service"; import { BrowserEnvironmentService } from "../../services/browser-environment.service"; +import { BrowserOrganizationService } from "../../services/browser-organization.service"; +import { BrowserPolicyService } from "../../services/browser-policy.service"; +import { BrowserSettingsService } from "../../services/browser-settings.service"; +import { BrowserStateService } from "../../services/browser-state.service"; import { BrowserFileDownloadService } from "../../services/browserFileDownloadService"; import BrowserMessagingService from "../../services/browserMessaging.service"; import BrowserMessagingPrivateModePopupService from "../../services/browserMessagingPrivateModePopup.service"; @@ -190,8 +198,13 @@ function getBgService(service: keyof MainBackground) { { provide: EventService, useFactory: getBgService("eventService"), deps: [] }, { provide: PolicyService, - useFactory: getBgService("policyService"), - deps: [], + useFactory: ( + stateService: StateServiceAbstraction, + organizationService: OrganizationService + ) => { + return new BrowserPolicyService(stateService, organizationService); + }, + deps: [StateServiceAbstraction, OrganizationService], }, { provide: PolicyApiServiceAbstraction, @@ -212,8 +225,10 @@ function getBgService(service: keyof MainBackground) { { provide: SyncService, useFactory: getBgService("syncService"), deps: [] }, { provide: SettingsService, - useFactory: getBgService("settingsService"), - deps: [], + useFactory: (stateService: StateServiceAbstraction) => { + return new BrowserSettingsService(stateService); + }, + deps: [StateServiceAbstraction], }, { provide: AbstractStorageService, @@ -261,8 +276,10 @@ function getBgService(service: keyof MainBackground) { { provide: PasswordRepromptServiceAbstraction, useClass: PasswordRepromptService }, { provide: OrganizationService, - useFactory: getBgService("organizationService"), - deps: [], + useFactory: (stateService: StateServiceAbstraction) => { + return new BrowserOrganizationService(stateService); + }, + deps: [StateServiceAbstraction], }, { provide: VaultFilterService, @@ -293,10 +310,36 @@ function getBgService(service: keyof MainBackground) { useFactory: getBgService("memoryStorageService"), }, { - provide: StateServiceAbstraction, - useFactory: getBgService("stateService"), + provide: StateMigrationService, + useFactory: getBgService("stateMigrationService"), deps: [], }, + { + provide: StateServiceAbstraction, + useFactory: ( + storageService: AbstractStorageService, + secureStorageService: AbstractStorageService, + memoryStorageService: AbstractStorageService, + logService: LogServiceAbstraction, + stateMigrationService: StateMigrationService + ) => { + return new BrowserStateService( + storageService, + secureStorageService, + memoryStorageService, + logService, + stateMigrationService, + new StateFactory(GlobalState, Account) + ); + }, + deps: [ + AbstractStorageService, + SECURE_STORAGE, + MEMORY_STORAGE, + LogServiceAbstraction, + StateMigrationService, + ], + }, { provide: UsernameGenerationService, useFactory: getBgService("usernameGenerationService"), @@ -317,17 +360,19 @@ function getBgService(service: keyof MainBackground) { }, { provide: AbstractThemingService, - useFactory: () => { + useFactory: ( + stateService: StateServiceAbstraction, + platformUtilsService: PlatformUtilsService + ) => { return new ThemingService( - getBgService("stateService")(), + stateService, // Safari doesn't properly handle the (prefers-color-scheme) media query in the popup window, it always returns light. // In Safari we have to use the background page instead, which comes with limitations like not dynamically changing the extension theme when the system theme is changed. - getBgService("platformUtilsService")().isSafari() - ? getBgService("backgroundWindow")() - : window, + platformUtilsService.isSafari() ? getBgService("backgroundWindow")() : window, document ); }, + deps: [StateServiceAbstraction, PlatformUtilsService], }, ], }) diff --git a/apps/browser/src/popup/vault/vault-filter.component.ts b/apps/browser/src/popup/vault/vault-filter.component.ts index f8a6f14081d..e67fe69f601 100644 --- a/apps/browser/src/popup/vault/vault-filter.component.ts +++ b/apps/browser/src/popup/vault/vault-filter.component.ts @@ -18,7 +18,7 @@ import { FolderView } from "@bitwarden/common/models/view/folder.view"; import { BrowserApi } from "../../browser/browserApi"; import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { VaultFilterService } from "../../services/vaultFilter.service"; import { PopupUtilsService } from "../services/popup-utils.service"; @@ -83,7 +83,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private searchService: SearchService, private location: Location, - private browserStateService: StateService, + private browserStateService: BrowserStateService, private vaultFilterService: VaultFilterService ) { this.noFolderListSize = 100; @@ -373,7 +373,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } private async saveState() { - this.state = { + this.state = Object.assign(new BrowserGroupingsComponentState(), { scrollY: this.popupUtils.getContentScrollY(window), searchText: this.searchText, favoriteCiphers: this.favoriteCiphers, @@ -385,7 +385,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { folders: this.folders, collections: this.collections, deletedCount: this.deletedCount, - }; + }); await this.browserStateService.setBrowserGroupingComponentState(this.state); } diff --git a/apps/browser/src/popup/vault/vault-items.component.ts b/apps/browser/src/popup/vault/vault-items.component.ts index 232a29ea415..b7f63d6d71d 100644 --- a/apps/browser/src/popup/vault/vault-items.component.ts +++ b/apps/browser/src/popup/vault/vault-items.component.ts @@ -21,7 +21,7 @@ import { FolderView } from "@bitwarden/common/models/view/folder.view"; import { BrowserApi } from "../../browser/browserApi"; import { BrowserComponentState } from "../../models/browserComponentState"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { VaultFilterService } from "../../services/vaultFilter.service"; import { PopupUtilsService } from "../services/popup-utils.service"; @@ -60,7 +60,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn private ngZone: NgZone, private broadcasterService: BroadcasterService, private changeDetectorRef: ChangeDetectorRef, - private stateService: StateService, + private stateService: BrowserStateService, private popupUtils: PopupUtilsService, private i18nService: I18nService, private folderService: FolderService, diff --git a/apps/browser/src/services/abstractions/state.service.ts b/apps/browser/src/services/abstractions/browser-state.service.ts similarity index 91% rename from apps/browser/src/services/abstractions/state.service.ts rename to apps/browser/src/services/abstractions/browser-state.service.ts index 53a1b883646..afe5e2ce694 100644 --- a/apps/browser/src/services/abstractions/state.service.ts +++ b/apps/browser/src/services/abstractions/browser-state.service.ts @@ -8,7 +8,8 @@ import { BrowserComponentState } from "../../models/browserComponentState"; import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../models/browserSendComponentState"; -export abstract class StateService extends BaseStateServiceAbstraction { +export abstract class BrowserStateService extends BaseStateServiceAbstraction { + abstract hasInSessionMemory(key: string): Promise; abstract getFromSessionMemory(key: string, deserializer?: (obj: Jsonify) => T): Promise; abstract setInSessionMemory(key: string, value: any): Promise; getBrowserGroupingComponentState: ( diff --git a/apps/browser/src/services/autofill.service.ts b/apps/browser/src/services/autofill.service.ts index 470bd584cf1..f458279de4a 100644 --- a/apps/browser/src/services/autofill.service.ts +++ b/apps/browser/src/services/autofill.service.ts @@ -14,7 +14,6 @@ import { BrowserApi } from "../browser/browserApi"; import AutofillField from "../models/autofillField"; import AutofillPageDetails from "../models/autofillPageDetails"; import AutofillScript from "../models/autofillScript"; -import { StateService } from "../services/abstractions/state.service"; import { AutoFillOptions, @@ -22,6 +21,7 @@ import { PageDetail, FormData, } from "./abstractions/autofill.service"; +import { BrowserStateService } from "./abstractions/browser-state.service"; import { AutoFillConstants, CreditCardAutoFillConstants, @@ -39,7 +39,7 @@ export interface GenerateFillScriptOptions { export default class AutofillService implements AutofillServiceInterface { constructor( private cipherService: CipherService, - private stateService: StateService, + private stateService: BrowserStateService, private totpService: TotpService, private eventService: EventService, private logService: LogService diff --git a/apps/browser/src/services/folders/folder.service.ts b/apps/browser/src/services/browser-folder.service.ts similarity index 57% rename from apps/browser/src/services/folders/folder.service.ts rename to apps/browser/src/services/browser-folder.service.ts index e4fc19644da..a9573ab0f78 100644 --- a/apps/browser/src/services/folders/folder.service.ts +++ b/apps/browser/src/services/browser-folder.service.ts @@ -4,12 +4,12 @@ import { Folder } from "@bitwarden/common/models/domain/folder"; import { FolderView } from "@bitwarden/common/models/view/folder.view"; import { FolderService as BaseFolderService } from "@bitwarden/common/services/folder/folder.service"; -import { browserSession, sessionSync } from "../../decorators/session-sync-observable"; +import { browserSession, sessionSync } from "../decorators/session-sync-observable"; @browserSession -export class FolderService extends BaseFolderService { - @sessionSync({ initializer: Folder.fromJSON, initializeAsArray: true }) +export class BrowserFolderService extends BaseFolderService { + @sessionSync({ initializer: Folder.fromJSON, initializeAs: "array" }) protected _folders: BehaviorSubject; - @sessionSync({ initializer: FolderView.fromJSON, initializeAsArray: true }) + @sessionSync({ initializer: FolderView.fromJSON, initializeAs: "array" }) protected _folderViews: BehaviorSubject; } diff --git a/apps/browser/src/services/browser-organization.service.ts b/apps/browser/src/services/browser-organization.service.ts new file mode 100644 index 00000000000..63f2848e2e8 --- /dev/null +++ b/apps/browser/src/services/browser-organization.service.ts @@ -0,0 +1,12 @@ +import { BehaviorSubject } from "rxjs"; + +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { OrganizationService } from "@bitwarden/common/services/organization/organization.service"; + +import { browserSession, sessionSync } from "../decorators/session-sync-observable"; + +@browserSession +export class BrowserOrganizationService extends OrganizationService { + @sessionSync({ initializer: Organization.fromJSON, initializeAs: "array" }) + protected _organizations: BehaviorSubject; +} diff --git a/apps/browser/src/services/browser-policy.service.ts b/apps/browser/src/services/browser-policy.service.ts new file mode 100644 index 00000000000..613e5c39cf4 --- /dev/null +++ b/apps/browser/src/services/browser-policy.service.ts @@ -0,0 +1,12 @@ +import { BehaviorSubject } from "rxjs"; + +import { Policy } from "@bitwarden/common/models/domain/policy"; +import { PolicyService } from "@bitwarden/common/services/policy/policy.service"; + +import { browserSession, sessionSync } from "../decorators/session-sync-observable"; + +@browserSession +export class BrowserPolicyService extends PolicyService { + @sessionSync({ ctor: Policy, initializeAs: "array" }) + protected _policies: BehaviorSubject; +} diff --git a/apps/browser/src/services/browser-settings.service.ts b/apps/browser/src/services/browser-settings.service.ts new file mode 100644 index 00000000000..78b4f76ea6a --- /dev/null +++ b/apps/browser/src/services/browser-settings.service.ts @@ -0,0 +1,11 @@ +import { BehaviorSubject } from "rxjs"; + +import { AccountSettingsSettings } from "@bitwarden/common/models/domain/account"; +import { SettingsService } from "@bitwarden/common/services/settings.service"; + +import { sessionSync } from "../decorators/session-sync-observable"; + +export class BrowserSettingsService extends SettingsService { + @sessionSync({ initializer: (obj: string[][]) => obj }) + protected _settings: BehaviorSubject; +} diff --git a/apps/browser/src/services/state.service.spec.ts b/apps/browser/src/services/browser-state.service.spec.ts similarity index 77% rename from apps/browser/src/services/state.service.spec.ts rename to apps/browser/src/services/browser-state.service.spec.ts index 5bc4e6ad99b..7d6f8456314 100644 --- a/apps/browser/src/services/state.service.spec.ts +++ b/apps/browser/src/services/browser-state.service.spec.ts @@ -1,6 +1,6 @@ -// eslint-disable-next-line no-restricted-imports -import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; +import { mock, MockProxy } from "jest-mock-extended"; +import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; import { MemoryStorageServiceInterface, @@ -18,28 +18,29 @@ import { BrowserComponentState } from "../models/browserComponentState"; import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../models/browserSendComponentState"; +import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service"; +import { BrowserStateService } from "./browser-state.service"; import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service"; -import { StateService } from "./state.service"; describe("Browser State Service", () => { - let secureStorageService: SubstituteOf; - let diskStorageService: SubstituteOf; - let logService: SubstituteOf; - let stateMigrationService: SubstituteOf; - let stateFactory: SubstituteOf>; + let secureStorageService: MockProxy; + let diskStorageService: MockProxy; + let logService: MockProxy; + let stateMigrationService: MockProxy; + let stateFactory: MockProxy>; let useAccountCache: boolean; let state: State; const userId = "userId"; - let sut: StateService; + let sut: BrowserStateService; beforeEach(() => { - secureStorageService = Substitute.for(); - diskStorageService = Substitute.for(); - logService = Substitute.for(); - stateMigrationService = Substitute.for(); - stateFactory = Substitute.for(); + secureStorageService = mock(); + diskStorageService = mock(); + logService = mock(); + stateMigrationService = mock(); + stateFactory = mock(); useAccountCache = true; state = new State(new GlobalState()); @@ -54,9 +55,12 @@ describe("Browser State Service", () => { beforeEach(() => { // We need `AbstractCachedStorageService` in the prototype chain to correctly test cache bypass. - memoryStorageService = Object.create(LocalBackedSessionStorageService.prototype); + memoryStorageService = new LocalBackedSessionStorageService( + mock(), + mock() + ); - sut = new StateService( + sut = new BrowserStateService( diskStorageService, secureStorageService, memoryStorageService, @@ -80,14 +84,14 @@ describe("Browser State Service", () => { }); describe("state methods", () => { - let memoryStorageService: SubstituteOf; + let memoryStorageService: MockProxy; beforeEach(() => { - memoryStorageService = Substitute.for(); - const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state))); - memoryStorageService.get("state", Arg.any()).mimicks(stateGetter); + memoryStorageService = mock(); + const stateGetter = (key: string) => Promise.resolve(state); + memoryStorageService.get.mockImplementation(stateGetter); - sut = new StateService( + sut = new BrowserStateService( diskStorageService, secureStorageService, memoryStorageService, @@ -128,6 +132,7 @@ describe("Browser State Service", () => { [SendType.Text, 5], ]); state.accounts[userId].send = sendState; + (global as any)["watch"] = state; const actual = await sut.getBrowserSendComponentState(); expect(actual).toBeInstanceOf(BrowserSendComponentState); diff --git a/apps/browser/src/services/state.service.ts b/apps/browser/src/services/browser-state.service.ts similarity index 81% rename from apps/browser/src/services/state.service.ts rename to apps/browser/src/services/browser-state.service.ts index 3f4161f5a6b..24630dcbaef 100644 --- a/apps/browser/src/services/state.service.ts +++ b/apps/browser/src/services/browser-state.service.ts @@ -1,24 +1,40 @@ +import { BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service"; import { GlobalState } from "@bitwarden/common/models/domain/global-state"; import { StorageOptions } from "@bitwarden/common/models/domain/storage-options"; -import { - StateService as BaseStateService, - withPrototype, -} from "@bitwarden/common/services/state.service"; +import { StateService as BaseStateService } from "@bitwarden/common/services/state.service"; +import { browserSession, sessionSync } from "../decorators/session-sync-observable"; import { Account } from "../models/account"; import { BrowserComponentState } from "../models/browserComponentState"; import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../models/browserSendComponentState"; -import { StateService as StateServiceAbstraction } from "./abstractions/state.service"; +import { BrowserStateService as StateServiceAbstraction } from "./abstractions/browser-state.service"; -export class StateService +@browserSession +export class BrowserStateService extends BaseStateService implements StateServiceAbstraction { + @sessionSync({ + initializer: Account.fromJSON as any, // TODO: Remove this any when all any types are removed from Account + initializeAs: "record", + }) + protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>; + @sessionSync({ ctor: String }) + protected activeAccountSubject: BehaviorSubject; + @sessionSync({ ctor: Boolean }) + protected activeAccountUnlockedSubject: BehaviorSubject; + + protected accountDeserializer = Account.fromJSON; + + async hasInSessionMemory(key: string): Promise { + return await this.memoryStorageService.has(key); + } + async getFromSessionMemory(key: string, deserializer?: (obj: Jsonify) => T): Promise { return this.memoryStorageService instanceof AbstractCachedStorageService ? await this.memoryStorageService.getBypassCache(key, { deserializer: deserializer }) @@ -44,7 +60,6 @@ export class StateService ); } - @withPrototype(BrowserGroupingsComponentState) async getBrowserGroupingComponentState( options?: StorageOptions ): Promise { @@ -67,7 +82,6 @@ export class StateService ); } - @withPrototype(BrowserComponentState) async getBrowserVaultItemsComponentState( options?: StorageOptions ): Promise { @@ -90,7 +104,6 @@ export class StateService ); } - @withPrototype(BrowserSendComponentState) async getBrowserSendComponentState(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) @@ -111,7 +124,6 @@ export class StateService ); } - @withPrototype(BrowserComponentState) async getBrowserSendTypeComponentState(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) diff --git a/libs/angular/test-utils.ts b/libs/angular/test-utils.ts new file mode 100644 index 00000000000..a2422e698fd --- /dev/null +++ b/libs/angular/test-utils.ts @@ -0,0 +1,3 @@ +export async function awaitAsync(ms = 0) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/libs/common/spec/misc/utils.spec.ts b/libs/common/spec/misc/utils.spec.ts index e1de2655322..fb57c33fff1 100644 --- a/libs/common/spec/misc/utils.spec.ts +++ b/libs/common/spec/misc/utils.spec.ts @@ -241,4 +241,72 @@ describe("Utils Service", () => { expect(Utils.fromByteStringToArray(null)).toEqual(null); }); }); + + describe("mapToRecord", () => { + it("should handle null", () => { + expect(Utils.mapToRecord(null)).toEqual(null); + }); + + it("should handle empty map", () => { + expect(Utils.mapToRecord(new Map())).toEqual({}); + }); + + it("should handle convert a Map to a Record", () => { + const map = new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]); + expect(Utils.mapToRecord(map)).toEqual({ key1: "value1", key2: "value2" }); + }); + + it("should handle convert a Map to a Record with non-string keys", () => { + const map = new Map([ + [1, "value1"], + [2, "value2"], + ]); + const result = Utils.mapToRecord(map); + expect(result).toEqual({ 1: "value1", 2: "value2" }); + expect(Utils.recordToMap(result)).toEqual(map); + }); + + it("should not convert an object if it's not a map", () => { + const obj = { key1: "value1", key2: "value2" }; + expect(Utils.mapToRecord(obj as any)).toEqual(obj); + }); + }); + + describe("recordToMap", () => { + it("should handle null", () => { + expect(Utils.recordToMap(null)).toEqual(null); + }); + + it("should handle empty record", () => { + expect(Utils.recordToMap({})).toEqual(new Map()); + }); + + it("should handle convert a Record to a Map", () => { + const record = { key1: "value1", key2: "value2" }; + expect(Utils.recordToMap(record)).toEqual(new Map(Object.entries(record))); + }); + + it("should handle convert a Record to a Map with non-string keys", () => { + const record = { 1: "value1", 2: "value2" }; + const result = Utils.recordToMap(record); + expect(result).toEqual( + new Map([ + [1, "value1"], + [2, "value2"], + ]) + ); + expect(Utils.mapToRecord(result)).toEqual(record); + }); + + it("should not convert an object if already a map", () => { + const map = new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]); + expect(Utils.recordToMap(map as any)).toEqual(map); + }); + }); }); diff --git a/libs/common/src/misc/utils.ts b/libs/common/src/misc/utils.ts index ff21a919f1f..ead692d6663 100644 --- a/libs/common/src/misc/utils.ts +++ b/libs/common/src/misc/utils.ts @@ -1,5 +1,6 @@ /* eslint-disable no-useless-escape */ import { getHostname, parse } from "tldts"; +import { Merge } from "type-fest"; import { CryptoService } from "../abstractions/crypto.service"; import { EncryptService } from "../abstractions/encrypt.service"; @@ -55,6 +56,10 @@ export class Utils { } static fromB64ToArray(str: string): Uint8Array { + if (str == null) { + return null; + } + if (Utils.isNode) { return new Uint8Array(Buffer.from(str, "base64")); } else { @@ -108,6 +113,9 @@ export class Utils { } static fromBufferToB64(buffer: ArrayBuffer): string { + if (buffer == null) { + return null; + } if (Utils.isNode) { return Buffer.from(buffer).toString("base64"); } else { @@ -423,6 +431,57 @@ export class Utils { return this.global.bitwardenContainerService; } + /** + * Converts map to a Record with the same data. Inverse of recordToMap + * Useful in toJSON methods, since Maps are not serializable + * @param map + * @returns + */ + static mapToRecord(map: Map): Record { + if (map == null) { + return null; + } + if (!(map instanceof Map)) { + return map; + } + return Object.fromEntries(map); + } + + /** + * Converts record to a Map with the same data. Inverse of mapToRecord + * Useful in fromJSON methods, since Maps are not serializable + * + * Warning: If the record has string keys that are numbers, they will be converted to numbers in the map + * @param record + * @returns + */ + static recordToMap(record: Record): Map { + if (record == null) { + return null; + } else if (record instanceof Map) { + return record; + } + + const entries = Object.entries(record); + if (entries.length === 0) { + return new Map(); + } + + if (isNaN(Number(entries[0][0]))) { + return new Map(entries) as Map; + } else { + return new Map(entries.map((e) => [Number(e[0]), e[1]])) as Map; + } + } + + /** Applies Object.assign, but converts the type nicely using Type-Fest Merge */ + static merge( + destination: Destination, + source: Source + ): Merge { + return Object.assign(destination, source) as unknown as Merge; + } + private static isMobile(win: Window) { let mobile = false; ((a) => { diff --git a/libs/common/src/models/domain/account.ts b/libs/common/src/models/domain/account.ts index 75a1eebfebb..0715b0c49b6 100644 --- a/libs/common/src/models/domain/account.ts +++ b/libs/common/src/models/domain/account.ts @@ -1,4 +1,4 @@ -import { Except, Jsonify } from "type-fest"; +import { Jsonify } from "type-fest"; import { AuthenticationStatus } from "../../enums/authenticationStatus"; import { KdfType } from "../../enums/kdfType"; @@ -40,7 +40,7 @@ export class EncryptionPair { } static fromJSON( - obj: Jsonify, Jsonify>>, + obj: { encrypted?: Jsonify; decrypted?: string | Jsonify }, decryptedFromJson?: (decObj: Jsonify | string) => TDecrypted, encryptedFromJson?: (encObj: Jsonify) => TEncrypted ) { @@ -123,7 +123,7 @@ export class AccountKeys { apiKeyClientSecret?: string; toJSON() { - return Object.assign(this as Except, { + return Utils.merge(this, { publicKey: Utils.fromBufferToByteString(this.publicKey), }); } @@ -251,7 +251,7 @@ export class AccountSettings { } export type AccountSettingsSettings = { - equivalentDomains?: { [id: string]: any }; + equivalentDomains?: string[][]; }; export class AccountTokens { diff --git a/libs/common/src/models/domain/organization.ts b/libs/common/src/models/domain/organization.ts index faf1ee1f978..9e1f63ccb0e 100644 --- a/libs/common/src/models/domain/organization.ts +++ b/libs/common/src/models/domain/organization.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { OrganizationUserStatusType } from "../../enums/organizationUserStatusType"; import { OrganizationUserType } from "../../enums/organizationUserType"; import { ProductType } from "../../enums/productType"; @@ -201,4 +203,15 @@ export class Organization { get hasProvider() { return this.providerId != null || this.providerName != null; } + + static fromJSON(json: Jsonify) { + if (json == null) { + return null; + } + + return Object.assign(new Organization(), json, { + familySponsorshipLastSyncDate: new Date(json.familySponsorshipLastSyncDate), + familySponsorshipValidUntil: new Date(json.familySponsorshipValidUntil), + }); + } } diff --git a/libs/common/src/models/domain/state.spec.ts b/libs/common/src/models/domain/state.spec.ts index 64e71d7cb2c..aa4f36549c2 100644 --- a/libs/common/src/models/domain/state.spec.ts +++ b/libs/common/src/models/domain/state.spec.ts @@ -4,22 +4,25 @@ import { State } from "./state"; describe("state", () => { describe("fromJSON", () => { it("should deserialize to an instance of itself", () => { - expect(State.fromJSON({})).toBeInstanceOf(State); + expect(State.fromJSON({}, () => new Account({}))).toBeInstanceOf(State); }); it("should always assign an object to accounts", () => { - const state = State.fromJSON({}); + const state = State.fromJSON({}, () => new Account({})); expect(state.accounts).not.toBeNull(); expect(state.accounts).toEqual({}); }); it("should build an account map", () => { const accountsSpy = jest.spyOn(Account, "fromJSON"); - const state = State.fromJSON({ - accounts: { - userId: {}, + const state = State.fromJSON( + { + accounts: { + userId: {}, + }, }, - }); + Account.fromJSON + ); expect(state.accounts["userId"]).toBeInstanceOf(Account); expect(accountsSpy).toHaveBeenCalled(); diff --git a/libs/common/src/models/domain/state.ts b/libs/common/src/models/domain/state.ts index be99d9bdabe..c08e6fe70f6 100644 --- a/libs/common/src/models/domain/state.ts +++ b/libs/common/src/models/domain/state.ts @@ -19,26 +19,28 @@ export class State< // TODO, make Jsonify work. It currently doesn't because Globals doesn't implement Jsonify. static fromJSON( - obj: any + obj: any, + accountDeserializer: (json: Jsonify) => TAccount ): State { if (obj == null) { return null; } return Object.assign(new State(null), obj, { - accounts: State.buildAccountMapFromJSON(obj?.accounts), + accounts: State.buildAccountMapFromJSON(obj?.accounts, accountDeserializer), }); } - private static buildAccountMapFromJSON( - jsonAccounts: Jsonify<{ [userId: string]: Jsonify }> + private static buildAccountMapFromJSON( + jsonAccounts: { [userId: string]: Jsonify }, + accountDeserializer: (json: Jsonify) => TAccount ) { if (!jsonAccounts) { return {}; } - const accounts: { [userId: string]: Account } = {}; + const accounts: { [userId: string]: TAccount } = {}; for (const userId in jsonAccounts) { - accounts[userId] = Account.fromJSON(jsonAccounts[userId]); + accounts[userId] = accountDeserializer(jsonAccounts[userId]); } return accounts; } diff --git a/libs/common/src/models/view/send-file.view.ts b/libs/common/src/models/view/send-file.view.ts index 7ed291a2b73..3ac12b8203d 100644 --- a/libs/common/src/models/view/send-file.view.ts +++ b/libs/common/src/models/view/send-file.view.ts @@ -1,3 +1,4 @@ +import { DeepJsonify } from "../../types/deep-jsonify"; import { SendFile } from "../domain/send-file"; import { View } from "./view"; @@ -28,4 +29,12 @@ export class SendFileView implements View { } return 0; } + + static fromJSON(json: DeepJsonify) { + if (json == null) { + return null; + } + + return Object.assign(new SendFileView(), json); + } } diff --git a/libs/common/src/models/view/send-text.view.ts b/libs/common/src/models/view/send-text.view.ts index cd10c799b64..638f66ad661 100644 --- a/libs/common/src/models/view/send-text.view.ts +++ b/libs/common/src/models/view/send-text.view.ts @@ -1,3 +1,4 @@ +import { DeepJsonify } from "../../types/deep-jsonify"; import { SendText } from "../domain/send-text"; import { View } from "./view"; @@ -17,4 +18,12 @@ export class SendTextView implements View { get maskedText(): string { return this.text != null ? "••••••••" : null; } + + static fromJSON(json: DeepJsonify) { + if (json == null) { + return null; + } + + return Object.assign(new SendTextView(), json); + } } diff --git a/libs/common/src/models/view/send.view.ts b/libs/common/src/models/view/send.view.ts index 3ef6bf9f0e3..c5da1d2f683 100644 --- a/libs/common/src/models/view/send.view.ts +++ b/libs/common/src/models/view/send.view.ts @@ -1,5 +1,6 @@ import { SendType } from "../../enums/sendType"; import { Utils } from "../../misc/utils"; +import { DeepJsonify } from "../../types/deep-jsonify"; import { Send } from "../domain/send"; import { SymmetricCryptoKey } from "../domain/symmetric-crypto-key"; @@ -65,4 +66,26 @@ export class SendView implements View { get pendingDelete(): boolean { return this.deletionDate <= new Date(); } + + toJSON() { + return Utils.merge(this, { + key: Utils.fromBufferToB64(this.key), + }); + } + + static fromJSON(json: DeepJsonify) { + if (json == null) { + return null; + } + + return Object.assign(new SendView(), json, { + key: Utils.fromB64ToArray(json.key)?.buffer, + cryptoKey: SymmetricCryptoKey.fromJSON(json.cryptoKey), + text: SendTextView.fromJSON(json.text), + file: SendFileView.fromJSON(json.file), + revisionDate: json.revisionDate == null ? null : new Date(json.revisionDate), + deletionDate: json.deletionDate == null ? null : new Date(json.deletionDate), + expirationDate: json.expirationDate == null ? null : new Date(json.expirationDate), + }); + } } diff --git a/libs/common/src/services/cipher.service.ts b/libs/common/src/services/cipher.service.ts index a7b54942e2c..b93c60d6228 100644 --- a/libs/common/src/services/cipher.service.ts +++ b/libs/common/src/services/cipher.service.ts @@ -412,7 +412,7 @@ export class CipherService implements CipherServiceAbstraction { : firstValueFrom(this.settingsService.settings$).then( (settings: AccountSettingsSettings) => { let matches: any[] = []; - settings.equivalentDomains?.forEach((eqDomain: any) => { + settings?.equivalentDomains?.forEach((eqDomain: any) => { if (eqDomain.length && eqDomain.indexOf(domain) >= 0) { matches = matches.concat(eqDomain); } diff --git a/libs/common/src/services/organization/organization.service.ts b/libs/common/src/services/organization/organization.service.ts index 5d936c53ae7..b0d7791ec26 100644 --- a/libs/common/src/services/organization/organization.service.ts +++ b/libs/common/src/services/organization/organization.service.ts @@ -6,7 +6,7 @@ import { OrganizationData } from "../../models/data/organization.data"; import { Organization } from "../../models/domain/organization"; export class OrganizationService implements InternalOrganizationServiceAbstraction { - private _organizations = new BehaviorSubject([]); + protected _organizations = new BehaviorSubject([]); organizations$ = this._organizations.asObservable(); diff --git a/libs/common/src/services/policy/policy.service.ts b/libs/common/src/services/policy/policy.service.ts index 757c3b3e05f..c20682184bc 100644 --- a/libs/common/src/services/policy/policy.service.ts +++ b/libs/common/src/services/policy/policy.service.ts @@ -16,7 +16,7 @@ import { ListResponse } from "../../models/response/list.response"; import { PolicyResponse } from "../../models/response/policy.response"; export class PolicyService implements InternalPolicyServiceAbstraction { - private _policies: BehaviorSubject = new BehaviorSubject([]); + protected _policies: BehaviorSubject = new BehaviorSubject([]); policies$ = this._policies.asObservable(); diff --git a/libs/common/src/services/settings.service.ts b/libs/common/src/services/settings.service.ts index 923bd8970dd..4a6986e37f1 100644 --- a/libs/common/src/services/settings.service.ts +++ b/libs/common/src/services/settings.service.ts @@ -6,7 +6,7 @@ import { Utils } from "../misc/utils"; import { AccountSettingsSettings } from "../models/domain/account"; export class SettingsService implements SettingsServiceAbstraction { - private _settings: BehaviorSubject = new BehaviorSubject({}); + protected _settings: BehaviorSubject = new BehaviorSubject({}); settings$ = this._settings.asObservable(); diff --git a/libs/common/src/services/state.service.ts b/libs/common/src/services/state.service.ts index 0c1c3b725ab..fe787f21c0e 100644 --- a/libs/common/src/services/state.service.ts +++ b/libs/common/src/services/state.service.ts @@ -1,4 +1,5 @@ import { BehaviorSubject, concatMap } from "rxjs"; +import { Jsonify } from "type-fest"; import { LogService } from "../abstractions/log.service"; import { StateService as StateServiceAbstraction } from "../abstractions/state.service"; @@ -13,6 +14,7 @@ import { StorageLocation } from "../enums/storageLocation"; import { ThemeType } from "../enums/themeType"; import { UriMatchType } from "../enums/uriMatchType"; import { StateFactory } from "../factories/stateFactory"; +import { Utils } from "../misc/utils"; import { CipherData } from "../models/data/cipher.data"; import { CollectionData } from "../models/data/collection.data"; import { EncryptedOrganizationKeyData } from "../models/data/encrypted-organization-key.data"; @@ -65,13 +67,13 @@ export class StateService< TAccount extends Account = Account > implements StateServiceAbstraction { - private accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({}); + protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({}); accounts$ = this.accountsSubject.asObservable(); - private activeAccountSubject = new BehaviorSubject(null); + protected activeAccountSubject = new BehaviorSubject(null); activeAccount$ = this.activeAccountSubject.asObservable(); - private activeAccountUnlockedSubject = new BehaviorSubject(false); + protected activeAccountUnlockedSubject = new BehaviorSubject(false); activeAccountUnlocked$ = this.activeAccountUnlockedSubject.asObservable(); private hasBeenInited = false; @@ -79,6 +81,9 @@ export class StateService< private accountDiskCache = new Map(); + // default account serializer, must be overridden by child class + protected accountDeserializer = Account.fromJSON as (json: Jsonify) => TAccount; + constructor( protected storageService: AbstractStorageService, protected secureStorageService: AbstractStorageService, @@ -676,7 +681,7 @@ export class StateService< const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); - return this.recordToMap(account?.keys?.organizationKeys?.decrypted); + return Utils.recordToMap(account?.keys?.organizationKeys?.decrypted); } async setDecryptedOrganizationKeys( @@ -686,7 +691,7 @@ export class StateService< const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); - account.keys.organizationKeys.decrypted = this.mapToRecord(value); + account.keys.organizationKeys.decrypted = Utils.mapToRecord(value); await this.saveAccount( account, this.reconcileOptions(options, await this.defaultInMemoryOptions()) @@ -774,7 +779,7 @@ export class StateService< const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); - return this.recordToMap(account?.keys?.providerKeys?.decrypted); + return Utils.recordToMap(account?.keys?.providerKeys?.decrypted); } async setDecryptedProviderKeys( @@ -784,7 +789,7 @@ export class StateService< const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); - account.keys.providerKeys.decrypted = this.mapToRecord(value); + account.keys.providerKeys.decrypted = Utils.mapToRecord(value); await this.saveAccount( account, this.reconcileOptions(options, await this.defaultInMemoryOptions()) @@ -2744,7 +2749,7 @@ export class StateService< protected async state(): Promise> { const state = await this.memoryStorageService.get>(keys.state, { - deserializer: (s) => State.fromJSON(s), + deserializer: (s) => State.fromJSON(s, this.accountDeserializer), }); return state; } @@ -2765,50 +2770,6 @@ export class StateService< await this.setState(updatedState); }); } - - private mapToRecord(map: Map): Record { - return map == null ? null : Object.fromEntries(map); - } - - private recordToMap(record: Record): Map { - return record == null ? null : new Map(Object.entries(record)); - } -} - -export function withPrototype( - constructor: new (...args: any[]) => T, - converter: (input: any) => T = (i) => i -): ( - target: any, - propertyKey: string | symbol, - descriptor: PropertyDescriptor -) => { value: (...args: any[]) => Promise } { - return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - const originalMethod = descriptor.value; - - return { - value: function (...args: any[]) { - const originalResult: Promise = originalMethod.apply(this, args); - - if (!(originalResult instanceof Promise)) { - throw new Error( - `Error applying prototype to stored value -- result is not a promise for method ${String( - propertyKey - )}` - ); - } - - return originalResult.then((result) => { - return result == null || - result.constructor.name === constructor.prototype.constructor.name - ? converter(result as T) - : converter( - Object.create(constructor.prototype, Object.getOwnPropertyDescriptors(result)) as T - ); - }); - }, - }; - }; } function withPrototypeForArrayMembers( @@ -2847,7 +2808,7 @@ function withPrototypeForArrayMembers( return result.map((r) => { return r == null || r.constructor.name === memberConstructor.prototype.constructor.name - ? memberConverter(r) + ? r : memberConverter( Object.create(memberConstructor.prototype, Object.getOwnPropertyDescriptors(r)) );