1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 08:43:33 +00:00

Add State Provider Framework (#6640)

* Add StateDefinition

Add a class for encapsulation information about state
this will often be for a domain but creations of this will
exist outside of a specific domain, hence just the name State.

* Add KeyDefinition

This adds a type that extends state definition into another sub-key
and forces creators to define the data that will be stored and how
to read the data that they expect to be stored.

* Add key-builders helper functions

Adds to function to help building keys for both keys scoped
to a specific user and for keys scoped to global storage.

Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>

* Add updates$ stream to existing storageServices

Original commit by Matt: 823d9546fe
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>

* Add fromChromeEvent helper

Create a helper that creats an Observable from a chrome event
and removes the listener when the subscription is completed.

* Implement `updates$` property for chrome storage

Use fromChromeEvent to create an observable from chrome
event and map that into our expected shape.

* Add GlobalState Abstractions

* Add UserState Abstractions

* Add Default Implementations of User/Global state

Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>

* Add Barrel File for state

Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>

* Fix ChromeStorageServices

* Rework fromChromeEvent

Rework fromChromeEvent so we have to lie to TS less and
remove unneeded generics. I did this by caring less about
the function and more about the parameters only.

Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>

* Fix UserStateProvider Test

* Add Inner Mock & Assert Calls

* Update Tests to use new keys

Use different key format

* Prefer returns over mutations in update

* Update Tests

* Address PR Feedback

* Be stricter with userId parameter

* Add Better Way To Determine if it was a remove

* Fix Web & Browser Storage Services

* Fix Desktop & CLI Storage Services

* Fix Test Storage Service

* Use createKey Helper

* Prefer implement to extending

* Determine storage location in providers

* Export default providers publicly

* Fix user state tests

* Name tests

* Fix CLI

* Prefer Implement In Chrome Storage

* Remove Secure Storage Option

Also throw an exception for subscribes to the secure storage observable.

* Update apps/browser/src/platform/browser/from-chrome-event.ts

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Enforce state module barrel file

* Fix Linting Error

* Allow state module import from other modules

* Globally Unregister fromChromeEvent Listeners

Changed fromChromeEvent to add its listeners through the BrowserApi, so that
they will be unregistered when safari closes.

* Test default global state

* Use Proper Casing in Parameter

* Address Feedback

* Update libs/common/src/platform/state/key-definition.ts

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Add `buildCacheKey` Method

* Fix lint errors

* Add Comment

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Use Generic in callback parameter

* Refactor Out DerivedStateDefinition

* Persist Listener Return Type

* Add Ticket Link

---------

Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
Justin Baur
2023-11-09 17:06:42 -05:00
committed by GitHub
parent 801141f90e
commit e1b5b83723
36 changed files with 1352 additions and 68 deletions

View File

@@ -0,0 +1,23 @@
import { Observable, switchMap } from "rxjs";
import { EncryptService } from "../../abstractions/encrypt.service";
import { DerivedUserState } from "../derived-user-state";
import { Converter, DeriveContext, UserState } from "../user-state";
export class DefaultDerivedUserState<TFrom, TTo> implements DerivedUserState<TTo> {
state$: Observable<TTo>;
constructor(
private converter: Converter<TFrom, TTo>,
private encryptService: EncryptService,
private userState: UserState<TFrom>
) {
this.state$ = userState.state$.pipe(
switchMap(async (from) => {
// TODO: How do I get the key?
const convertedData = await this.converter(from, new DeriveContext(null, encryptService));
return convertedData;
})
);
}
}

View File

@@ -0,0 +1,46 @@
import {
AbstractMemoryStorageService,
AbstractStorageService,
} from "../../abstractions/storage.service";
import { GlobalState } from "../global-state";
import { GlobalStateProvider } from "../global-state.provider";
import { KeyDefinition } from "../key-definition";
import { StorageLocation } from "../state-definition";
import { DefaultGlobalState } from "./default-global-state";
export class DefaultGlobalStateProvider implements GlobalStateProvider {
private globalStateCache: Record<string, GlobalState<unknown>> = {};
constructor(
private memoryStorage: AbstractMemoryStorageService,
private diskStorage: AbstractStorageService
) {}
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
const cacheKey = keyDefinition.buildCacheKey();
const existingGlobalState = this.globalStateCache[cacheKey];
if (existingGlobalState != null) {
// The cast into the actual generic is safe because of rules around key definitions
// being unique.
return existingGlobalState as DefaultGlobalState<T>;
}
const newGlobalState = new DefaultGlobalState<T>(
keyDefinition,
this.getLocation(keyDefinition.stateDefinition.storageLocation)
);
this.globalStateCache[cacheKey] = newGlobalState;
return newGlobalState;
}
private getLocation(location: StorageLocation) {
switch (location) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
}
}
}

View File

@@ -0,0 +1,98 @@
/**
* need to update test environment so trackEmissions works appropriately
* @jest-environment ../shared/test.environment.ts
*/
import { Jsonify } from "type-fest";
import { trackEmissions } from "../../../../spec";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { DefaultGlobalState } from "./default-global-state";
class TestState {
date: Date;
static fromJSON(jsonState: Jsonify<TestState>) {
if (jsonState == null) {
return null;
}
return Object.assign(new TestState(), jsonState, {
date: new Date(jsonState.date),
});
}
}
const testStateDefinition = new StateDefinition("fake", "disk");
const testKeyDefinition = new KeyDefinition<TestState>(
testStateDefinition,
"fake",
TestState.fromJSON
);
const globalKey = globalKeyBuilder(testKeyDefinition);
describe("DefaultGlobalState", () => {
let diskStorageService: FakeStorageService;
let globalState: DefaultGlobalState<TestState>;
beforeEach(() => {
diskStorageService = new FakeStorageService();
globalState = new DefaultGlobalState(testKeyDefinition, diskStorageService);
});
afterEach(() => {
jest.resetAllMocks();
});
it("should emit when storage updates", async () => {
const emissions = trackEmissions(globalState.state$);
const newData = { date: new Date() };
await diskStorageService.save(globalKey, newData);
expect(emissions).toEqual([
null, // Initial value
newData,
// JSON.parse(JSON.stringify(newData)), // This is due to the way `trackEmissions` clones
]);
});
it("should not emit when update key does not match", async () => {
const emissions = trackEmissions(globalState.state$);
const newData = { date: new Date() };
await diskStorageService.save("wrong_key", newData);
expect(emissions).toEqual(
expect.arrayContaining([
null, // Initial value
])
);
});
it("should save on update", async () => {
const newData = { date: new Date() };
const result = await globalState.update((state) => {
return newData;
});
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
expect(result).toEqual(newData);
});
it("should emit once per update", async () => {
const emissions = trackEmissions(globalState.state$);
const newData = { date: new Date() };
await globalState.update((state) => {
return newData;
});
expect(emissions).toEqual([
null, // Initial value
newData,
]);
});
});

View File

@@ -0,0 +1,60 @@
import { BehaviorSubject, Observable, defer, filter, map, shareReplay, tap } from "rxjs";
import { Jsonify } from "type-fest";
import { AbstractStorageService } from "../../abstractions/storage.service";
import { GlobalState } from "../global-state";
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
export class DefaultGlobalState<T> implements GlobalState<T> {
private storageKey: string;
private seededPromise: Promise<void>;
protected stateSubject: BehaviorSubject<T | null> = new BehaviorSubject<T | null>(null);
state$: Observable<T>;
constructor(
private keyDefinition: KeyDefinition<T>,
private chosenLocation: AbstractStorageService
) {
this.storageKey = globalKeyBuilder(this.keyDefinition);
this.seededPromise = this.chosenLocation.get<Jsonify<T>>(this.storageKey).then((data) => {
const serializedData = this.keyDefinition.deserializer(data);
this.stateSubject.next(serializedData);
});
const storageUpdates$ = this.chosenLocation.updates$.pipe(
filter((update) => update.key === this.storageKey),
map((update) => {
return this.keyDefinition.deserializer(update.value as Jsonify<T>);
}),
shareReplay({ bufferSize: 1, refCount: false })
);
this.state$ = defer(() => {
const storageUpdateSubscription = storageUpdates$.subscribe((value) => {
this.stateSubject.next(value);
});
return this.stateSubject.pipe(
tap({
complete: () => storageUpdateSubscription.unsubscribe(),
})
);
});
}
async update(configureState: (state: T) => T): Promise<T> {
await this.seededPromise;
const currentState = this.stateSubject.getValue();
const newState = configureState(currentState);
await this.chosenLocation.save(this.storageKey, newState);
return newState;
}
async getFromState(): Promise<T> {
const data = await this.chosenLocation.get<Jsonify<T>>(this.storageKey);
return this.keyDefinition.deserializer(data);
}
}

View File

@@ -0,0 +1,55 @@
import { AccountService } from "../../../auth/abstractions/account.service";
import { EncryptService } from "../../abstractions/encrypt.service";
import {
AbstractMemoryStorageService,
AbstractStorageService,
} from "../../abstractions/storage.service";
import { KeyDefinition } from "../key-definition";
import { StorageLocation } from "../state-definition";
import { UserState } from "../user-state";
import { UserStateProvider } from "../user-state.provider";
import { DefaultUserState } from "./default-user-state";
export class DefaultUserStateProvider implements UserStateProvider {
private userStateCache: Record<string, UserState<unknown>> = {};
constructor(
protected accountService: AccountService,
protected encryptService: EncryptService,
protected memoryStorage: AbstractMemoryStorageService,
protected diskStorage: AbstractStorageService
) {}
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
const cacheKey = keyDefinition.buildCacheKey();
const existingUserState = this.userStateCache[cacheKey];
if (existingUserState != null) {
// I have to cast out of the unknown generic but this should be safe if rules
// around domain token are made
return existingUserState as DefaultUserState<T>;
}
const newUserState = this.buildUserState(keyDefinition);
this.userStateCache[cacheKey] = newUserState;
return newUserState;
}
protected buildUserState<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
return new DefaultUserState<T>(
keyDefinition,
this.accountService,
this.encryptService,
this.getLocation(keyDefinition.stateDefinition.storageLocation)
);
}
private getLocation(location: StorageLocation) {
switch (location) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
}
}
}

View File

@@ -0,0 +1,236 @@
import { any, mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, timeout } from "rxjs";
import { Jsonify } from "type-fest";
import { trackEmissions } from "../../../../spec";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { DefaultUserState } from "./default-user-state";
class TestState {
date: Date;
array: string[];
static fromJSON(jsonState: Jsonify<TestState>) {
if (jsonState == null) {
return null;
}
return Object.assign(new TestState(), jsonState, {
date: new Date(jsonState.date),
});
}
}
const testStateDefinition = new StateDefinition("fake", "disk");
const testKeyDefinition = new KeyDefinition<TestState>(
testStateDefinition,
"fake",
TestState.fromJSON
);
describe("DefaultUserState", () => {
const accountService = mock<AccountService>();
let diskStorageService: FakeStorageService;
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
let userState: DefaultUserState<TestState>;
beforeEach(() => {
activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined);
accountService.activeAccount$ = activeAccountSubject;
diskStorageService = new FakeStorageService();
userState = new DefaultUserState(
testKeyDefinition,
accountService,
null, // Not testing anything with encrypt service
diskStorageService
);
});
const changeActiveUser = async (id: string) => {
const userId = id != null ? `00000000-0000-1000-a000-00000000000${id}` : undefined;
activeAccountSubject.next({
id: userId as UserId,
email: `test${id}@example.com`,
name: `Test User ${id}`,
status: AuthenticationStatus.Unlocked,
});
await new Promise((resolve) => setTimeout(resolve, 1));
};
afterEach(() => {
jest.resetAllMocks();
});
it("emits updates for each user switch and update", async () => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: "2022-09-21T13:14:17.648Z",
array: ["value1", "value2"],
} as Jsonify<TestState>,
"user_00000000-0000-1000-a000-000000000002_fake_fake": {
date: "2021-09-21T13:14:17.648Z",
array: ["user2_value"],
},
});
const emissions = trackEmissions(userState.state$);
// User signs in
changeActiveUser("1");
await new Promise<void>((resolve) => setTimeout(resolve, 1));
// Service does an update
await userState.update((state) => {
state.array.push("value3");
state.date = new Date(2023, 0);
return state;
});
await new Promise<void>((resolve) => setTimeout(resolve, 1));
// Emulate an account switch
await changeActiveUser("2");
expect(emissions).toHaveLength(3);
// Gotten starter user data
expect(emissions[0]).toBeTruthy();
expect(emissions[0].array).toHaveLength(2);
// Gotten emission for the update call
expect(emissions[1]).toBeTruthy();
expect(emissions[1].array).toHaveLength(3);
expect(new Date(emissions[1].date).getUTCFullYear()).toBe(2023);
// The second users data
expect(emissions[2]).toBeTruthy();
expect(emissions[2].array).toHaveLength(1);
expect(new Date(emissions[2].date).getUTCFullYear()).toBe(2021);
// Should only be called twice to get state, once for each user
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(2);
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
1,
"user_00000000-0000-1000-a000-000000000001_fake_fake",
any()
);
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
2,
"user_00000000-0000-1000-a000-000000000002_fake_fake",
any()
);
// Should only have saved data for the first user
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
expect(diskStorageService.mock.save).toHaveBeenNthCalledWith(
1,
"user_00000000-0000-1000-a000-000000000001_fake_fake",
any()
);
});
it("will not emit any value if there isn't an active user", async () => {
let resolvedValue: TestState | undefined = undefined;
let rejectedError: Error | undefined = undefined;
const promise = firstValueFrom(userState.state$.pipe(timeout(20)))
.then((value) => {
resolvedValue = value;
})
.catch((err) => {
rejectedError = err;
});
await promise;
expect(diskStorageService.mock.get).not.toHaveBeenCalled();
expect(resolvedValue).toBe(undefined);
expect(rejectedError).toBeTruthy();
expect(rejectedError.message).toBe("Timeout has occurred");
});
it("will emit value for a new active user after subscription started", async () => {
let resolvedValue: TestState | undefined = undefined;
let rejectedError: Error | undefined = undefined;
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: "2020-09-21T13:14:17.648Z",
array: ["testValue"],
} as Jsonify<TestState>,
});
const promise = firstValueFrom(userState.state$.pipe(timeout(20)))
.then((value) => {
resolvedValue = value;
})
.catch((err) => {
rejectedError = err;
});
await changeActiveUser("1");
await promise;
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1);
expect(resolvedValue).toBeTruthy();
expect(resolvedValue.array).toHaveLength(1);
expect(resolvedValue.date.getUTCFullYear()).toBe(2020);
expect(rejectedError).toBeFalsy();
});
it("should not emit a previous users value if that user is no longer active", async () => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: "2020-09-21T13:14:17.648Z",
array: ["value"],
} as Jsonify<TestState>,
"user_00000000-0000-1000-a000-000000000002_fake_fake": {
date: "2020-09-21T13:14:17.648Z",
array: [],
} as Jsonify<TestState>,
});
// This starts one subscription on the observable for tracking emissions throughout
// the whole test.
const emissions = trackEmissions(userState.state$);
// Change to a user with data
await changeActiveUser("1");
// This should always return a value right await
const value = await firstValueFrom(userState.state$);
expect(value).toBeTruthy();
// Make it such that there is no active user
await changeActiveUser(undefined);
let resolvedValue: TestState | undefined = undefined;
let rejectedError: Error | undefined = undefined;
// Even if the observable has previously emitted a value it shouldn't have
// a value for the user subscribing to it because there isn't an active user
// to get data for.
await firstValueFrom(userState.state$.pipe(timeout(20)))
.then((value) => {
resolvedValue = value;
})
.catch((err) => {
rejectedError = err;
});
expect(resolvedValue).toBeFalsy();
expect(rejectedError).toBeTruthy();
expect(rejectedError.message).toBe("Timeout has occurred");
// We need to figure out if something should be emitted
// when there becomes no active user, if we don't want that to emit
// this value is correct.
expect(emissions).toHaveLength(2);
});
});

View File

@@ -0,0 +1,152 @@
import {
Observable,
BehaviorSubject,
map,
shareReplay,
switchMap,
tap,
defer,
firstValueFrom,
combineLatestWith,
filter,
} from "rxjs";
import { Jsonify } from "type-fest";
import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { EncryptService } from "../../abstractions/encrypt.service";
import { AbstractStorageService } from "../../abstractions/storage.service";
import { DerivedUserState } from "../derived-user-state";
import { KeyDefinition, userKeyBuilder } from "../key-definition";
import { Converter, UserState } from "../user-state";
import { DefaultDerivedUserState } from "./default-derived-state";
const FAKE_DEFAULT = Symbol("fakeDefault");
export class DefaultUserState<T> implements UserState<T> {
private formattedKey$: Observable<string>;
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
T | typeof FAKE_DEFAULT
>(FAKE_DEFAULT);
private stateSubject$ = this.stateSubject.asObservable();
state$: Observable<T>;
constructor(
protected keyDefinition: KeyDefinition<T>,
private accountService: AccountService,
private encryptService: EncryptService,
private chosenStorageLocation: AbstractStorageService
) {
this.formattedKey$ = this.accountService.activeAccount$.pipe(
map((account) =>
account != null && account.id != null
? userKeyBuilder(account.id, this.keyDefinition)
: null
),
shareReplay({ bufferSize: 1, refCount: false })
);
const activeAccountData$ = this.formattedKey$.pipe(
switchMap(async (key) => {
if (key == null) {
return FAKE_DEFAULT;
}
const jsonData = await this.chosenStorageLocation.get<Jsonify<T>>(key);
const data = keyDefinition.deserializer(jsonData);
return data;
}),
// Share the execution
shareReplay({ refCount: false, bufferSize: 1 })
);
const storageUpdates$ = this.chosenStorageLocation.updates$.pipe(
combineLatestWith(this.formattedKey$),
filter(([update, key]) => key !== null && update.key === key),
map(([update]) => {
return keyDefinition.deserializer(update.value as Jsonify<T>);
})
);
// Whomever subscribes to this data, should be notified of updated data
// if someone calls my update() method, or the active user changes.
this.state$ = defer(() => {
const accountChangeSubscription = activeAccountData$.subscribe((data) => {
this.stateSubject.next(data);
});
const storageUpdateSubscription = storageUpdates$.subscribe((data) => {
this.stateSubject.next(data);
});
return this.stateSubject$.pipe(
tap({
complete: () => {
accountChangeSubscription.unsubscribe();
storageUpdateSubscription.unsubscribe();
},
})
);
})
// I fake the generic here because I am filtering out the other union type
// and this makes it so that typescript understands the true type
.pipe(filter<T>((value) => value != FAKE_DEFAULT));
}
async update(configureState: (state: T) => T): Promise<T> {
const key = await this.createKey();
const currentState = await this.getGuaranteedState(key);
const newState = configureState(currentState);
await this.saveToStorage(key, newState);
return newState;
}
async updateFor(userId: UserId, configureState: (state: T) => T): Promise<T> {
if (userId == null) {
throw new Error("Attempting to update user state, but no userId has been supplied.");
}
const key = userKeyBuilder(userId, this.keyDefinition);
const currentStore = await this.chosenStorageLocation.get<Jsonify<T>>(key);
const currentState = this.keyDefinition.deserializer(currentStore);
const newState = configureState(currentState);
await this.saveToStorage(key, newState);
return newState;
}
async getFromState(): Promise<T> {
const key = await this.createKey();
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
return this.keyDefinition.deserializer(data);
}
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
}
protected async createKey(): Promise<string> {
const formattedKey = await firstValueFrom(this.formattedKey$);
if (formattedKey == null) {
throw new Error("Cannot create a key while there is no active user.");
}
return formattedKey;
}
protected async getGuaranteedState(key: string) {
const currentValue = this.stateSubject.getValue();
return currentValue === FAKE_DEFAULT ? await this.seedInitial(key) : currentValue;
}
private async seedInitial(key: string): Promise<T> {
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
const serializedData = this.keyDefinition.deserializer(data);
this.stateSubject.next(serializedData);
return serializedData;
}
protected saveToStorage(key: string, data: T): Promise<void> {
return this.chosenStorageLocation.save(key, data);
}
}