1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 11:13:46 +00:00

Ps/pm 2910/add browser storage services (#6849)

* Allow for update logic in state update callbacks

* Prefer reading updates to sending in stream

* Inform state providers when they must deserialize

* Update DefaultGlobalState to act more like DefaultUserState

* Fully Implement AbstractStorageService

* Add KeyDefinitionOptions

* Address PR feedback

* Prefer testing interactions for ports

* Synced memory storage for browser

* Fix port handling

* Do not stringify port message data

* Use messaging storage

* Initialize new foreground memory storage services

This will need to be rethought for short-lived background pages, but for
now the background is the source of truth for memory storage

* Use global state for account service

* Use BrowserApi listener to avoid safari memory leaks

* Fix build errors: debugging and missed impls

* Prefer bound arrow functions

* JSON Stringify Messages

* Prefer `useClass`

* Use noop services

* extract storage observable to new interface

This also reverts changes for the existing services to use
foreground/background services. Those are now used only in state
providers

* Fix web DI

* Prefer initializing observable in constructor

* Do not use jsonify as equality operator

* Remove port listener to avoid memory leaks

* Fix logic and type issues

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
Matt Gibson
2023-11-21 16:35:37 -05:00
committed by GitHub
parent 1ecf019397
commit 24c240d0d4
36 changed files with 744 additions and 160 deletions

View File

@@ -1,9 +1,15 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
GlobalState,
GlobalStateProvider,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { AccountInfo } from "../abstractions/account.service";
import { AuthenticationStatus } from "../enums/authentication-status";
@@ -13,6 +19,11 @@ import { AccountServiceImplementation } from "./account.service";
describe("accountService", () => {
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
let globalStateProvider: MockProxy<GlobalStateProvider>;
let accountsState: MockProxy<GlobalState<Record<UserId, AccountInfo>>>;
let accountsSubject: BehaviorSubject<Record<UserId, AccountInfo>>;
let activeAccountIdState: MockProxy<GlobalState<UserId>>;
let activeAccountIdSubject: BehaviorSubject<UserId>;
let sut: AccountServiceImplementation;
const userId = "userId" as UserId;
function userInfo(status: AuthenticationStatus): AccountInfo {
@@ -20,10 +31,29 @@ describe("accountService", () => {
}
beforeEach(() => {
messagingService = mock<MessagingService>();
logService = mock<LogService>();
messagingService = mock();
logService = mock();
globalStateProvider = mock();
accountsState = mock();
activeAccountIdState = mock();
sut = new AccountServiceImplementation(messagingService, logService);
accountsSubject = new BehaviorSubject<Record<UserId, AccountInfo>>(null);
accountsState.state$ = accountsSubject.asObservable();
activeAccountIdSubject = new BehaviorSubject<UserId>(null);
activeAccountIdState.state$ = activeAccountIdSubject.asObservable();
globalStateProvider.get.mockImplementation((keyDefinition) => {
switch (keyDefinition) {
case ACCOUNT_ACCOUNTS:
return accountsState;
case ACCOUNT_ACTIVE_ACCOUNT_ID:
return activeAccountIdState;
default:
throw new Error("Unknown key definition");
}
});
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider);
});
afterEach(() => {
@@ -39,8 +69,8 @@ describe("accountService", () => {
it("should emit the active account and status", async () => {
const emissions = trackEmissions(sut.activeAccount$);
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
sut.switchAccount(userId);
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
activeAccountIdSubject.next(userId);
expect(emissions).toEqual([
undefined, // initial value
@@ -48,9 +78,21 @@ describe("accountService", () => {
]);
});
it("should update the status if the account status changes", async () => {
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
activeAccountIdSubject.next(userId);
const emissions = trackEmissions(sut.activeAccount$);
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Locked) });
expect(emissions).toEqual([
{ id: userId, ...userInfo(AuthenticationStatus.Unlocked) },
{ id: userId, ...userInfo(AuthenticationStatus.Locked) },
]);
});
it("should remember the last emitted value", async () => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
sut.switchAccount(userId);
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
activeAccountIdSubject.next(userId);
expect(await firstValueFrom(sut.activeAccount$)).toEqual({
id: userId,
@@ -59,77 +101,98 @@ describe("accountService", () => {
});
});
describe("accounts$", () => {
it("should maintain an accounts cache", async () => {
expect(await firstValueFrom(sut.accounts$)).toEqual({});
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
expect(await firstValueFrom(sut.accounts$)).toEqual({
[userId]: userInfo(AuthenticationStatus.Unlocked),
});
});
});
describe("addAccount", () => {
it("should emit the new account", () => {
const emissions = trackEmissions(sut.accounts$);
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
expect(emissions).toEqual([
{}, // initial value
{ [userId]: userInfo(AuthenticationStatus.Unlocked) },
]);
expect(accountsState.update).toHaveBeenCalledTimes(1);
const callback = accountsState.update.mock.calls[0][0];
expect(callback({}, null)).toEqual({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
});
describe("setAccountName", () => {
beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should emit the updated account", () => {
const emissions = trackEmissions(sut.accounts$);
it("should update the account", async () => {
sut.setAccountName(userId, "new name");
expect(emissions).toEqual([
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "name" } },
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" } },
]);
const callback = accountsState.update.mock.calls[0][0];
expect(callback(accountsSubject.value, null)).toEqual({
[userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" },
});
});
it("should not update if the name is the same", async () => {
sut.setAccountName(userId, "name");
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
});
});
describe("setAccountEmail", () => {
beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should emit the updated account", () => {
const emissions = trackEmissions(sut.accounts$);
it("should update the account", () => {
sut.setAccountEmail(userId, "new email");
expect(emissions).toEqual([
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "email" } },
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" } },
]);
const callback = accountsState.update.mock.calls[0][0];
expect(callback(accountsSubject.value, null)).toEqual({
[userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" },
});
});
it("should not update if the email is the same", () => {
sut.setAccountEmail(userId, "email");
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
});
});
describe("setAccountStatus", () => {
beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should not emit if the status is the same", async () => {
const emissions = trackEmissions(sut.accounts$);
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
it("should update the account", () => {
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
expect(emissions).toEqual([{ userId: userInfo(AuthenticationStatus.Unlocked) }]);
});
const callback = accountsState.update.mock.calls[0][0];
it("should maintain an accounts cache", async () => {
expect(await firstValueFrom(sut.accounts$)).toEqual({
[userId]: userInfo(AuthenticationStatus.Unlocked),
expect(callback(accountsSubject.value, null)).toEqual({
[userId]: {
...userInfo(AuthenticationStatus.Unlocked),
status: AuthenticationStatus.Locked,
},
});
});
it("should emit if the status is different", () => {
const emissions = trackEmissions(sut.accounts$);
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
it("should not update if the status is the same", () => {
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
expect(emissions).toEqual([
{ userId: userInfo(AuthenticationStatus.Unlocked) }, // initial value from beforeEach
{ userId: userInfo(AuthenticationStatus.Locked) },
]);
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
});
it("should emit logout if the status is logged out", () => {
@@ -148,34 +211,20 @@ describe("accountService", () => {
});
describe("switchAccount", () => {
let emissions: { id: string; status: AuthenticationStatus }[];
beforeEach(() => {
emissions = [];
sut.activeAccount$.subscribe((value) => emissions.push(value));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should emit undefined if no account is provided", () => {
sut.switchAccount(undefined);
expect(emissions).toEqual([undefined]);
sut.switchAccount(null);
const callback = activeAccountIdState.update.mock.calls[0][0];
expect(callback(userId, accountsSubject.value)).toBeUndefined();
});
it("should emit the active account and status", () => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
sut.switchAccount(userId);
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
sut.switchAccount(undefined);
sut.switchAccount(undefined);
expect(emissions).toEqual([
undefined, // initial value
{ id: userId, ...userInfo(AuthenticationStatus.Unlocked) },
{ id: userId, ...userInfo(AuthenticationStatus.Locked) },
]);
});
it("should throw if switched to an unknown account", () => {
expect(() => sut.switchAccount(userId)).toThrowError("Account does not exist");
it("should throw if the account does not exist", () => {
sut.switchAccount("unknown" as UserId);
const callback = activeAccountIdState.update.mock.calls[0][0];
expect(() => callback(userId, accountsSubject.value)).toThrowError("Account does not exist");
});
});
});