1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

Resolve state <-> state-test-utils circular dependency (#16093)

* Resolve state <-> state-test-utils circular dependency

* Fix type errors
This commit is contained in:
Justin Baur
2025-08-25 12:38:28 -04:00
committed by GitHub
parent 777b92660a
commit 5f7f1d1924
90 changed files with 543 additions and 500 deletions

View File

@@ -1,11 +0,0 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/user-core";
export abstract class ActiveUserAccessor {
/**
* Returns a stream of the current active user for the application. The stream either emits the user id for that account
* or returns null if there is no current active user.
*/
abstract activeUserId$: Observable<UserId | null>;
}

View File

@@ -19,7 +19,7 @@ export interface GlobalState<T> {
*/
update: <TCombine>(
configureState: (state: T | null, dependency: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>,
options?: Partial<StateUpdateOptions<T, TCombine>>,
) => Promise<T | null>;
/**

View File

@@ -1,253 +0,0 @@
/**
* need to update test environment so structuredClone works appropriately
* @jest-environment ../shared/test.environment.ts
*/
import { Observable, of } from "rxjs";
import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils";
import {
FakeActiveUserAccessor,
FakeActiveUserStateProvider,
FakeDerivedStateProvider,
FakeGlobalStateProvider,
FakeSingleUserStateProvider,
} from "@bitwarden/state-test-utils";
import { UserId } from "@bitwarden/user-core";
import { DeriveDefinition } from "../derive-definition";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { UserKeyDefinition } from "../user-key-definition";
import { DefaultStateProvider } from "./default-state.provider";
describe("DefaultStateProvider", () => {
let sut: DefaultStateProvider;
let activeUserStateProvider: FakeActiveUserStateProvider;
let singleUserStateProvider: FakeSingleUserStateProvider;
let globalStateProvider: FakeGlobalStateProvider;
let derivedStateProvider: FakeDerivedStateProvider;
let activeAccountAccessor: FakeActiveUserAccessor;
const userId = "fakeUserId" as UserId;
beforeEach(() => {
activeAccountAccessor = new FakeActiveUserAccessor(userId);
activeUserStateProvider = new FakeActiveUserStateProvider(activeAccountAccessor);
singleUserStateProvider = new FakeSingleUserStateProvider();
globalStateProvider = new FakeGlobalStateProvider();
derivedStateProvider = new FakeDerivedStateProvider();
sut = new DefaultStateProvider(
activeUserStateProvider,
singleUserStateProvider,
globalStateProvider,
derivedStateProvider,
);
});
describe("activeUserId$", () => {
it("should track the active User id from active user state provider", () => {
expect(sut.activeUserId$).toBe(activeUserStateProvider.activeUserId$);
});
});
describe.each([
[
"getUserState$",
(keyDefinition: UserKeyDefinition<string>, userId?: UserId) =>
sut.getUserState$(keyDefinition, userId),
],
[
"getUserStateOrDefault$",
(keyDefinition: UserKeyDefinition<string>, userId?: UserId) =>
sut.getUserStateOrDefault$(keyDefinition, { userId: userId }),
],
])(
"Shared behavior for %s",
(
_testName: string,
methodUnderTest: (
keyDefinition: UserKeyDefinition<string>,
userId?: UserId,
) => Observable<string>,
) => {
const keyDefinition = new UserKeyDefinition<string>(
new StateDefinition("test", "disk"),
"test",
{
deserializer: (s) => s,
clearOn: [],
},
);
it("should follow the specified user if userId is provided", async () => {
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(methodUnderTest(keyDefinition, userId));
state.nextState("value2");
state.nextState("value3");
expect(emissions).toEqual(["value", "value2", "value3"]);
});
it("should follow the current active user if no userId is provided", async () => {
activeAccountAccessor.switch(userId);
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(methodUnderTest(keyDefinition));
state.nextState("value2");
state.nextState("value3");
expect(emissions).toEqual(["value", "value2", "value3"]);
});
it("should continue to follow the state of the user that was active when called, even if active user changes", async () => {
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(methodUnderTest(keyDefinition));
activeAccountAccessor.switch("newUserId" as UserId);
const newUserEmissions = trackEmissions(sut.getUserState$(keyDefinition));
state.nextState("value2");
state.nextState("value3");
expect(emissions).toEqual(["value", "value2", "value3"]);
expect(newUserEmissions).toEqual([null]);
});
},
);
describe("getUserState$", () => {
const keyDefinition = new UserKeyDefinition<string>(
new StateDefinition("test", "disk"),
"test",
{
deserializer: (s) => s,
clearOn: [],
},
);
it("should not emit any values until a truthy user id is supplied", async () => {
activeAccountAccessor.switch(null);
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(sut.getUserState$(keyDefinition));
await awaitAsync();
expect(emissions).toHaveLength(0);
activeAccountAccessor.switch(userId);
await awaitAsync();
expect(emissions).toEqual(["value"]);
});
});
describe("getUserStateOrDefault$", () => {
const keyDefinition = new UserKeyDefinition<string>(
new StateDefinition("test", "disk"),
"test",
{
deserializer: (s) => s,
clearOn: [],
},
);
it("should emit default value if no userId supplied and first active user id emission in falsy", async () => {
activeAccountAccessor.switch(null);
const emissions = trackEmissions(
sut.getUserStateOrDefault$(keyDefinition, {
userId: undefined,
defaultValue: "I'm default!",
}),
);
expect(emissions).toEqual(["I'm default!"]);
});
});
describe("setUserState", () => {
const keyDefinition = new UserKeyDefinition<string>(
new StateDefinition("test", "disk"),
"test",
{
deserializer: (s) => s,
clearOn: [],
},
);
it("should set the state for the active user if no userId is provided", async () => {
const value = "value";
await sut.setUserState(keyDefinition, value);
const state = activeUserStateProvider.getFake(keyDefinition);
expect(state.nextMock).toHaveBeenCalledWith([expect.any(String), value]);
});
it("should not set state for a single user if no userId is provided", async () => {
const value = "value";
await sut.setUserState(keyDefinition, value);
const state = singleUserStateProvider.getFake(userId, keyDefinition);
expect(state.nextMock).not.toHaveBeenCalled();
});
it("should set the state for the provided userId", async () => {
const value = "value";
await sut.setUserState(keyDefinition, value, userId);
const state = singleUserStateProvider.getFake(userId, keyDefinition);
expect(state.nextMock).toHaveBeenCalledWith(value);
});
it("should not set the active user state if userId is provided", async () => {
const value = "value";
await sut.setUserState(keyDefinition, value, userId);
const state = activeUserStateProvider.getFake(keyDefinition);
expect(state.nextMock).not.toHaveBeenCalled();
});
});
it("should bind the activeUserStateProvider", () => {
const keyDefinition = new UserKeyDefinition(new StateDefinition("test", "disk"), "test", {
deserializer: () => null,
clearOn: [],
});
const existing = activeUserStateProvider.get(keyDefinition);
const actual = sut.getActive(keyDefinition);
expect(actual).toBe(existing);
});
it("should bind the singleUserStateProvider", () => {
const userId = "user" as UserId;
const keyDefinition = new UserKeyDefinition(new StateDefinition("test", "disk"), "test", {
deserializer: () => null,
clearOn: [],
});
const existing = singleUserStateProvider.get(userId, keyDefinition);
const actual = sut.getUser(userId, keyDefinition);
expect(actual).toBe(existing);
});
it("should bind the globalStateProvider", () => {
const keyDefinition = new KeyDefinition(new StateDefinition("test", "disk"), "test", {
deserializer: () => null,
});
const existing = globalStateProvider.get(keyDefinition);
const actual = sut.getGlobal(keyDefinition);
expect(actual).toBe(existing);
});
it("should bind the derivedStateProvider", () => {
const derivedDefinition = new DeriveDefinition(new StateDefinition("test", "disk"), "test", {
derive: () => null,
deserializer: () => null,
});
const parentState$ = of(null);
const existing = derivedStateProvider.get(parentState$, derivedDefinition, {});
const actual = sut.getDerived(parentState$, derivedDefinition, {});
expect(actual).toBe(existing);
});
});

View File

@@ -1,37 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, distinctUntilChanged } from "rxjs";
import { UserId } from "@bitwarden/user-core";
import { ActiveUserAccessor } from "../active-user.accessor";
import { UserKeyDefinition } from "../user-key-definition";
import { ActiveUserState } from "../user-state";
import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider";
import { DefaultActiveUserState } from "./default-active-user-state";
export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
activeUserId$: Observable<UserId | undefined>;
constructor(
private readonly activeAccountAccessor: ActiveUserAccessor,
private readonly singleUserStateProvider: SingleUserStateProvider,
) {
this.activeUserId$ = this.activeAccountAccessor.activeUserId$.pipe(
// To avoid going to storage when we don't need to, only get updates when there is a true change.
distinctUntilChanged((a, b) => (a == null || b == null ? a == b : a === b)), // Treat null and undefined as equal
);
}
get<T>(keyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
// All other providers cache the creation of their corresponding `State` objects, this instance
// doesn't need to do that since it calls `SingleUserStateProvider` it will go through their caching
// layer, because of that, the creation of this instance is quite simple and not worth caching.
return new DefaultActiveUserState(
keyDefinition,
this.activeUserId$,
this.singleUserStateProvider,
);
}
}

View File

@@ -1,766 +0,0 @@
/**
* need to update test environment so trackEmissions works appropriately
* @jest-environment ../shared/test.environment.ts
*/
import { any, mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs";
import { Jsonify } from "type-fest";
import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils";
import { LogService } from "@bitwarden/logging";
import { StorageServiceProvider } from "@bitwarden/storage-core";
import { FakeStorageService } from "@bitwarden/storage-test-utils";
import { UserId } from "@bitwarden/user-core";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { DefaultActiveUserState } from "./default-active-user-state";
import { DefaultSingleUserStateProvider } from "./default-single-user-state.provider";
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 cleanupDelayMs = 15;
const testKeyDefinition = new UserKeyDefinition<TestState>(testStateDefinition, "fake", {
deserializer: TestState.fromJSON,
cleanupDelayMs,
clearOn: [],
});
describe("DefaultActiveUserState", () => {
let diskStorageService: FakeStorageService;
const storageServiceProvider = mock<StorageServiceProvider>();
const stateEventRegistrarService = mock<StateEventRegistrarService>();
const logService = mock<LogService>();
let activeAccountSubject: BehaviorSubject<UserId | null>;
let singleUserStateProvider: DefaultSingleUserStateProvider;
let userState: DefaultActiveUserState<TestState>;
beforeEach(() => {
diskStorageService = new FakeStorageService();
storageServiceProvider.get.mockReturnValue(["disk", diskStorageService]);
singleUserStateProvider = new DefaultSingleUserStateProvider(
storageServiceProvider,
stateEventRegistrarService,
logService,
);
activeAccountSubject = new BehaviorSubject<UserId | null>(null);
userState = new DefaultActiveUserState(
testKeyDefinition,
activeAccountSubject.asObservable(),
singleUserStateProvider,
);
});
afterEach(() => {
jest.resetAllMocks();
});
const makeUserId = (id: string) => {
return id != null ? (`00000000-0000-1000-a000-00000000000${id}` as UserId) : undefined;
};
const changeActiveUser = async (id: string) => {
const userId = makeUserId(id);
activeAccountSubject.next(userId);
await awaitAsync();
};
afterEach(() => {
jest.resetAllMocks();
});
it("emits updates for each user switch and update", async () => {
const user1 = "user_00000000-0000-1000-a000-000000000001_fake_fake";
const user2 = "user_00000000-0000-1000-a000-000000000002_fake_fake";
const state1 = {
date: new Date(2021, 0),
array: ["user1"],
};
const state2 = {
date: new Date(2022, 0),
array: ["user2"],
};
const initialState: Record<string, TestState> = {};
initialState[user1] = state1;
initialState[user2] = state2;
diskStorageService.internalUpdateStore(initialState);
const emissions = trackEmissions(userState.state$);
// User signs in
await changeActiveUser("1");
// Service does an update
const updatedState = {
date: new Date(2023, 0),
array: ["user1-update"],
};
await userState.update(() => updatedState);
await awaitAsync();
// Emulate an account switch
await changeActiveUser("2");
// #1 initial state from user1
// #2 updated state for user1
// #3 switched state to initial state for user2
expect(emissions).toEqual([state1, updatedState, state2]);
// Should be called 4 time to get state, update state for user, emitting update, and switching users
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(4);
// Initial subscribe to state$
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
1,
"user_00000000-0000-1000-a000-000000000001_fake_fake",
any(), // options
);
// The updating of state for user1
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
2,
"user_00000000-0000-1000-a000-000000000001_fake_fake",
any(), // options
);
// The emission from being actively subscribed to user1
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
3,
"user_00000000-0000-1000-a000-000000000001_fake_fake",
any(), // options
);
// Switch to user2
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
4,
"user_00000000-0000-1000-a000-000000000002_fake_fake",
any(), // options
);
// 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",
updatedState,
any(), // options
);
});
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: new Date(2020, 0),
array: ["testValue"],
} as 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.getFullYear()).toBe(2020);
expect(rejectedError).toBeFalsy();
});
it("should not emit a previous users value if that user is no longer active", async () => {
const user1Data: Jsonify<TestState> = {
date: "2020-09-21T13:14:17.648Z",
// NOTE: `as any` is here until we migrate to Nx: https://bitwarden.atlassian.net/browse/PM-6493
array: ["value"] as any,
};
const user2Data: Jsonify<TestState> = {
date: "2020-09-21T13:14:17.648Z",
array: [],
};
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": user1Data,
"user_00000000-0000-1000-a000-000000000002_fake_fake": user2Data,
});
// 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$.pipe(
timeout({
first: 20,
with: () => {
throw new Error("Did not emit data from newly active user.");
},
}),
),
);
expect(value).toEqual(user1Data);
// 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).toBeUndefined();
expect(rejectedError).not.toBeUndefined();
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).toEqual([user1Data]);
});
it("should not emit twice if there are two listeners", async () => {
await changeActiveUser("1");
const emissions = trackEmissions(userState.state$);
const emissions2 = trackEmissions(userState.state$);
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
]);
expect(emissions2).toEqual([
null, // Initial value
]);
});
describe("update", () => {
const newData = { date: new Date(), array: ["test"] };
beforeEach(async () => {
await changeActiveUser("1");
});
it("should save on update", async () => {
const [setUserId, result] = await userState.update((state, dependencies) => {
return newData;
});
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
expect(result).toEqual(newData);
expect(setUserId).toEqual("00000000-0000-1000-a000-000000000001");
});
it("should emit once per update", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // Need to await for the initial value to be emitted
await userState.update((state, dependencies) => {
return newData;
});
await awaitAsync();
expect(emissions).toEqual([
null, // initial value
newData,
]);
});
it("should provide combined dependencies", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // Need to await for the initial value to be emitted
const combinedDependencies = { date: new Date() };
await userState.update(
(state, dependencies) => {
expect(dependencies).toEqual(combinedDependencies);
return newData;
},
{
combineLatestWith: of(combinedDependencies),
},
);
await awaitAsync();
expect(emissions).toEqual([
null, // initial value
newData,
]);
});
it("should not update if shouldUpdate returns false", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // Need to await for the initial value to be emitted
const [userIdResult, result] = await userState.update(
(state, dependencies) => {
return newData;
},
{
shouldUpdate: () => false,
},
);
await awaitAsync();
expect(diskStorageService.mock.save).not.toHaveBeenCalled();
expect(userIdResult).toEqual("00000000-0000-1000-a000-000000000001");
expect(result).toBeNull();
expect(emissions).toEqual([null]);
});
it("should provide the current state to the update callback", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // Need to await for the initial value to be emitted
// Seed with interesting data
const initialData = { date: new Date(2020, 0), array: ["value1", "value2"] };
await userState.update((state, dependencies) => {
return initialData;
});
await awaitAsync();
await userState.update((state, dependencies) => {
expect(state).toEqual(initialData);
return newData;
});
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
initialData,
newData,
]);
});
it("should throw on an attempted update when there is no active user", async () => {
await changeActiveUser(undefined);
await expect(async () => await userState.update(() => null)).rejects.toThrow(
"No active user at this time.",
);
});
it("should throw on an attempted update where there is no active user even if there used to be one", async () => {
// Arrange
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: new Date(2019, 1),
array: [],
},
});
const [userId, state] = await firstValueFrom(userState.combinedState$);
expect(userId).toBe("00000000-0000-1000-a000-000000000001");
expect(state.date.getUTCFullYear()).toBe(2019);
await changeActiveUser(undefined);
// Act
await expect(async () => await userState.update(() => null)).rejects.toThrow(
"No active user at this time.",
);
});
it.each([null, undefined])(
"should register user key definition when state transitions from null-ish (%s) to non-null",
async (startingValue: TestState | null) => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": startingValue,
});
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).toHaveBeenCalledWith(testKeyDefinition);
},
);
it("should not register user key definition when state has preexisting value", async () => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: new Date(2019, 1),
array: [],
},
});
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
});
it.each([null, undefined])(
"should not register user key definition when setting value to null-ish (%s) value",
async (updatedValue: TestState | null) => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: new Date(2019, 1),
array: [],
},
});
await userState.update(() => updatedValue);
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
},
);
});
describe("update races", () => {
const newData = { date: new Date(), array: ["test"] };
const userId = makeUserId("1");
beforeEach(async () => {
await changeActiveUser("1");
await awaitAsync();
});
test("subscriptions during an update should receive the current and latest", async () => {
const oldData = { date: new Date(2019, 1, 1), array: ["oldValue1"] };
await userState.update(() => {
return oldData;
});
const initialData = { date: new Date(2020, 1, 1), array: ["value1", "value2"] };
await userState.update(() => {
return initialData;
});
await awaitAsync();
const emissions = trackEmissions(userState.state$);
await awaitAsync();
expect(emissions).toEqual([initialData]);
let emissions2: TestState[];
const originalSave = diskStorageService.save.bind(diskStorageService);
diskStorageService.save = jest.fn().mockImplementation(async (key: string, obj: any) => {
emissions2 = trackEmissions(userState.state$);
await originalSave(key, obj);
});
const [userIdResult, val] = await userState.update(() => {
return newData;
});
await awaitAsync(10);
expect(userIdResult).toEqual(userId);
expect(val).toEqual(newData);
expect(emissions).toEqual([initialData, newData]);
expect(emissions2).toEqual([initialData, newData]);
});
test("subscription during an aborted update should receive the last value", async () => {
// Seed with interesting data
const initialData = { date: new Date(2020, 1, 1), array: ["value1", "value2"] };
await userState.update(() => {
return initialData;
});
await awaitAsync();
const emissions = trackEmissions(userState.state$);
await awaitAsync();
expect(emissions).toEqual([initialData]);
let emissions2: TestState[];
const [userIdResult, val] = await userState.update(
(state) => {
return newData;
},
{
shouldUpdate: () => {
emissions2 = trackEmissions(userState.state$);
return false;
},
},
);
await awaitAsync();
expect(userIdResult).toEqual(userId);
expect(val).toEqual(initialData);
expect(emissions).toEqual([initialData]);
expect(emissions2).toEqual([initialData]);
});
test("updates should wait until previous update is complete", async () => {
trackEmissions(userState.state$);
await awaitAsync(); // storage updates are behind a promise
const originalSave = diskStorageService.save.bind(diskStorageService);
diskStorageService.save = jest
.fn()
.mockImplementationOnce(async (key: string, obj: any) => {
let resolved = false;
await Promise.race([
userState.update(() => {
// deadlocks
resolved = true;
return newData;
}),
awaitAsync(100), // limit test to 100ms
]);
expect(resolved).toBe(false);
})
.mockImplementation((...args) => {
return originalSave(...args);
});
await userState.update(() => {
return newData;
});
});
test("updates with FAKE_DEFAULT initial value should resolve correctly", async () => {
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(0);
const [userIdResult, val] = await userState.update((state) => {
return newData;
});
expect(userIdResult).toEqual(userId);
expect(val).toEqual(newData);
const call = diskStorageService.mock.save.mock.calls[0];
expect(call[0]).toEqual(`user_${userId}_fake_fake`);
expect(call[1]).toEqual(newData);
});
it("does not await updates if the active user changes", async () => {
const initialUserId = activeAccountSubject.value;
expect(initialUserId).toBe(userId);
trackEmissions(userState.state$);
await awaitAsync(); // storage updates are behind a promise
const originalSave = diskStorageService.save.bind(diskStorageService);
diskStorageService.save = jest
.fn()
.mockImplementationOnce(async (key: string, obj: any) => {
let resolved = false;
await changeActiveUser("2");
await Promise.race([
userState.update(() => {
// should not deadlock because we updated the user
resolved = true;
return newData;
}),
awaitAsync(100), // limit test to 100ms
]);
expect(resolved).toBe(true);
})
.mockImplementation((...args) => {
return originalSave(...args);
});
await userState.update(() => {
return newData;
});
});
it("stores updates for users in the correct place when active user changes mid-update", async () => {
trackEmissions(userState.state$);
await awaitAsync(); // storage updates are behind a promise
const user2Data = { date: new Date(), array: ["user 2 data"] };
const originalSave = diskStorageService.save.bind(diskStorageService);
diskStorageService.save = jest
.fn()
.mockImplementationOnce(async (key: string, obj: any) => {
let resolved = false;
await changeActiveUser("2");
await Promise.race([
userState.update(() => {
// should not deadlock because we updated the user
resolved = true;
return user2Data;
}),
awaitAsync(100), // limit test to 100ms
]);
expect(resolved).toBe(true);
await originalSave(key, obj);
})
.mockImplementation((...args) => {
return originalSave(...args);
});
await userState.update(() => {
return newData;
});
await awaitAsync();
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(2);
const innerCall = diskStorageService.mock.save.mock.calls[0];
expect(innerCall[0]).toEqual(`user_${makeUserId("2")}_fake_fake`);
expect(innerCall[1]).toEqual(user2Data);
const outerCall = diskStorageService.mock.save.mock.calls[1];
expect(outerCall[0]).toEqual(`user_${makeUserId("1")}_fake_fake`);
expect(outerCall[1]).toEqual(newData);
});
});
describe("cleanup", () => {
const newData = { date: new Date(), array: ["test"] };
const userId = makeUserId("1");
let userKey: string;
beforeEach(async () => {
await changeActiveUser("1");
userKey = testKeyDefinition.buildKey(userId);
});
function assertClean() {
expect(activeAccountSubject["observers"]).toHaveLength(0);
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(0);
}
it("should cleanup after last subscriber", async () => {
const subscription = userState.state$.subscribe();
await awaitAsync(); // storage updates are behind a promise
subscription.unsubscribe();
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(1);
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
assertClean();
});
it("should not cleanup if there are still subscribers", async () => {
const subscription1 = userState.state$.subscribe();
const sub2Emissions: TestState[] = [];
const subscription2 = userState.state$.subscribe((v) => sub2Emissions.push(v));
await awaitAsync(); // storage updates are behind a promise
subscription1.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(1);
// Still be listening to storage updates
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
diskStorageService.save(userKey, newData);
await awaitAsync(); // storage updates are behind a promise
expect(sub2Emissions).toEqual([null, newData]);
subscription2.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
assertClean();
});
it("can re-initialize after cleanup", async () => {
const subscription = userState.state$.subscribe();
await awaitAsync();
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
const emissions = trackEmissions(userState.state$);
await awaitAsync();
await diskStorageService.save(userKey, newData);
await awaitAsync();
expect(emissions).toEqual([null, newData]);
});
it("should not cleanup if a subscriber joins during the cleanup delay", async () => {
const subscription = userState.state$.subscribe();
await awaitAsync();
await diskStorageService.save(userKey, newData);
await awaitAsync();
subscription.unsubscribe();
// Do not wait long enough for cleanup
await awaitAsync(cleanupDelayMs / 2);
const state = await firstValueFrom(userState.state$);
expect(state).toEqual(newData); // digging in to check that it hasn't been cleared
// Should be called once for the initial subscription and once from the save
// but should NOT be called for the second subscription from the `firstValueFrom`
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(2);
});
it("state$ observables are durable to cleanup", async () => {
const observable = userState.state$;
let subscription = observable.subscribe();
await diskStorageService.save(userKey, newData);
await awaitAsync();
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
subscription = observable.subscribe();
await diskStorageService.save(userKey, newData);
await awaitAsync();
expect(await firstValueFrom(observable)).toEqual(newData);
});
});
});

View File

@@ -1,65 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, map, switchMap, firstValueFrom, timeout, throwError, NEVER } from "rxjs";
import { UserId } from "@bitwarden/user-core";
import { StateUpdateOptions } from "../state-update-options";
import { UserKeyDefinition } from "../user-key-definition";
import { ActiveUserState, CombinedState, activeMarker } from "../user-state";
import { SingleUserStateProvider } from "../user-state.provider";
export class DefaultActiveUserState<T> implements ActiveUserState<T> {
[activeMarker]: true;
combinedState$: Observable<CombinedState<T>>;
state$: Observable<T>;
constructor(
protected keyDefinition: UserKeyDefinition<T>,
private activeUserId$: Observable<UserId | null>,
private singleUserStateProvider: SingleUserStateProvider,
) {
this.combinedState$ = this.activeUserId$.pipe(
switchMap((userId) =>
userId != null
? this.singleUserStateProvider.get(userId, this.keyDefinition).combinedState$
: NEVER,
),
);
// State should just be combined state without the user id
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
}
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine> = {},
): Promise<[UserId, T]> {
const userId = await firstValueFrom(
this.activeUserId$.pipe(
timeout({
first: 1000,
with: () =>
throwError(
() =>
new Error(
`Timeout while retrieving active user for key ${this.keyDefinition.fullName}.`,
),
),
}),
),
);
if (userId == null) {
throw new Error(
`Error storing ${this.keyDefinition.fullName} for the active user: No active user at this time.`,
);
}
return [
userId,
await this.singleUserStateProvider
.get(userId, this.keyDefinition)
.update(configureState, options),
];
}
}

View File

@@ -1,53 +0,0 @@
import { Observable } from "rxjs";
import { DerivedStateDependencies } from "../../types/state";
import { DeriveDefinition } from "../derive-definition";
import { DerivedState } from "../derived-state";
import { DerivedStateProvider } from "../derived-state.provider";
import { DefaultDerivedState } from "./default-derived-state";
export class DefaultDerivedStateProvider implements DerivedStateProvider {
/**
* The cache uses a WeakMap to maintain separate derived states per user.
* Each user's state Observable acts as a unique key, without needing to
* pass around `userId`. Also, when a user's state Observable is cleaned up
* (like during an account swap) their cache is automatically garbage
* collected.
*/
private cache = new WeakMap<Observable<unknown>, Record<string, DerivedState<unknown>>>();
constructor() {}
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
): DerivedState<TTo> {
let stateCache = this.cache.get(parentState$);
if (!stateCache) {
stateCache = {};
this.cache.set(parentState$, stateCache);
}
const cacheKey = deriveDefinition.buildCacheKey();
const existingDerivedState = stateCache[cacheKey];
if (existingDerivedState != null) {
// I have to cast out of the unknown generic but this should be safe if rules
// around domain token are made
return existingDerivedState as DefaultDerivedState<TFrom, TTo, TDeps>;
}
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies);
stateCache[cacheKey] = newDerivedState;
return newDerivedState;
}
protected buildDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>(
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
): DerivedState<TTo> {
return new DefaultDerivedState<TFrom, TTo, TDeps>(parentState$, deriveDefinition, dependencies);
}
}

View File

@@ -1,212 +0,0 @@
/**
* need to update test environment so trackEmissions works appropriately
* @jest-environment ../shared/test.environment.ts
*/
import { Subject, firstValueFrom } from "rxjs";
import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils";
import { DeriveDefinition } from "../derive-definition";
import { StateDefinition } from "../state-definition";
import { DefaultDerivedState } from "./default-derived-state";
import { DefaultDerivedStateProvider } from "./default-derived-state.provider";
let callCount = 0;
const cleanupDelayMs = 10;
const stateDefinition = new StateDefinition("test", "memory");
const deriveDefinition = new DeriveDefinition<string, Date, { date: Date }>(
stateDefinition,
"test",
{
derive: (dateString: string) => {
callCount++;
return new Date(dateString);
},
deserializer: (dateString: string) => new Date(dateString),
cleanupDelayMs,
},
);
describe("DefaultDerivedState", () => {
let parentState$: Subject<string>;
let sut: DefaultDerivedState<string, Date, { date: Date }>;
const deps = {
date: new Date(),
};
beforeEach(() => {
callCount = 0;
parentState$ = new Subject();
sut = new DefaultDerivedState(parentState$, deriveDefinition, deps);
});
afterEach(() => {
parentState$.complete();
jest.resetAllMocks();
});
it("should derive the state", async () => {
const dateString = "2020-01-01";
const emissions = trackEmissions(sut.state$);
parentState$.next(dateString);
await awaitAsync();
expect(emissions).toEqual([new Date(dateString)]);
});
it("should derive the state once", async () => {
const dateString = "2020-01-01";
trackEmissions(sut.state$);
parentState$.next(dateString);
expect(callCount).toBe(1);
});
describe("forceValue", () => {
const initialParentValue = "2020-01-01";
const forced = new Date("2020-02-02");
let emissions: Date[];
beforeEach(async () => {
emissions = trackEmissions(sut.state$);
parentState$.next(initialParentValue);
await awaitAsync();
});
it("should force the value", async () => {
await sut.forceValue(forced);
expect(emissions).toEqual([new Date(initialParentValue), forced]);
});
it("should only force the value once", async () => {
await sut.forceValue(forced);
parentState$.next(initialParentValue);
await awaitAsync();
expect(emissions).toEqual([
new Date(initialParentValue),
forced,
new Date(initialParentValue),
]);
});
});
describe("cleanup", () => {
const newDate = "2020-02-02";
it("should cleanup after last subscriber", async () => {
const subscription = sut.state$.subscribe();
await awaitAsync();
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(parentState$.observed).toBe(false);
});
it("should not cleanup if there are still subscribers", async () => {
const subscription1 = sut.state$.subscribe();
const sub2Emissions: Date[] = [];
const subscription2 = sut.state$.subscribe((v) => sub2Emissions.push(v));
await awaitAsync();
subscription1.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
// Still be listening to parent updates
parentState$.next(newDate);
await awaitAsync();
expect(sub2Emissions).toEqual([new Date(newDate)]);
subscription2.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(parentState$.observed).toBe(false);
});
it("can re-initialize after cleanup", async () => {
const subscription = sut.state$.subscribe();
await awaitAsync();
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
const emissions = trackEmissions(sut.state$);
await awaitAsync();
parentState$.next(newDate);
await awaitAsync();
expect(emissions).toEqual([new Date(newDate)]);
});
it("should not cleanup if a subscriber joins during the cleanup delay", async () => {
const subscription = sut.state$.subscribe();
await awaitAsync();
await parentState$.next(newDate);
await awaitAsync();
subscription.unsubscribe();
// Do not wait long enough for cleanup
await awaitAsync(cleanupDelayMs / 2);
expect(parentState$.observed).toBe(true); // still listening to parent
const emissions = trackEmissions(sut.state$);
expect(emissions).toEqual([new Date(newDate)]); // we didn't lose our buffered value
});
it("state$ observables are durable to cleanup", async () => {
const observable = sut.state$;
let subscription = observable.subscribe();
await parentState$.next(newDate);
await awaitAsync();
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
subscription = observable.subscribe();
await parentState$.next(newDate);
await awaitAsync();
expect(await firstValueFrom(observable)).toEqual(new Date(newDate));
});
});
describe("account switching", () => {
let provider: DefaultDerivedStateProvider;
beforeEach(() => {
provider = new DefaultDerivedStateProvider();
});
it("should provide a dedicated cache for each account", async () => {
const user1State$ = new Subject<string>();
const user1Derived = provider.get(user1State$, deriveDefinition, deps);
const user1Emissions = trackEmissions(user1Derived.state$);
const user2State$ = new Subject<string>();
const user2Derived = provider.get(user2State$, deriveDefinition, deps);
const user2Emissions = trackEmissions(user2Derived.state$);
user1State$.next("2015-12-30");
user2State$.next("2020-12-29");
await awaitAsync();
expect(user1Emissions).toEqual([new Date("2015-12-30")]);
expect(user2Emissions).toEqual([new Date("2020-12-29")]);
});
});
});

View File

@@ -1,50 +0,0 @@
import { Observable, ReplaySubject, Subject, concatMap, merge, share, timer } from "rxjs";
import { DerivedStateDependencies } from "../../types/state";
import { DeriveDefinition } from "../derive-definition";
import { DerivedState } from "../derived-state";
/**
* Default derived state
*/
export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>
implements DerivedState<TTo>
{
private readonly storageKey: string;
private forcedValueSubject = new Subject<TTo>();
state$: Observable<TTo>;
constructor(
private parentState$: Observable<TFrom>,
protected deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
private dependencies: TDeps,
) {
this.storageKey = deriveDefinition.storageKey;
const derivedState$ = this.parentState$.pipe(
concatMap(async (state) => {
let derivedStateOrPromise = this.deriveDefinition.derive(state, this.dependencies);
if (derivedStateOrPromise instanceof Promise) {
derivedStateOrPromise = await derivedStateOrPromise;
}
const derivedState = derivedStateOrPromise;
return derivedState;
}),
);
this.state$ = merge(this.forcedValueSubject, derivedState$).pipe(
share({
connector: () => {
return new ReplaySubject<TTo>(1);
},
resetOnRefCountZero: () => timer(this.deriveDefinition.cleanupDelayMs),
}),
);
}
async forceValue(value: TTo) {
this.forcedValueSubject.next(value);
return value;
}
}

View File

@@ -1,46 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { LogService } from "@bitwarden/logging";
import { StorageServiceProvider } from "@bitwarden/storage-core";
import { GlobalState } from "../global-state";
import { GlobalStateProvider } from "../global-state.provider";
import { KeyDefinition } from "../key-definition";
import { DefaultGlobalState } from "./default-global-state";
export class DefaultGlobalStateProvider implements GlobalStateProvider {
private globalStateCache: Record<string, GlobalState<unknown>> = {};
constructor(
private storageServiceProvider: StorageServiceProvider,
private readonly logService: LogService,
) {}
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
const [location, storageService] = this.storageServiceProvider.get(
keyDefinition.stateDefinition.defaultStorageLocation,
keyDefinition.stateDefinition.storageLocationOverrides,
);
const cacheKey = this.buildCacheKey(location, keyDefinition);
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,
storageService,
this.logService,
);
this.globalStateCache[cacheKey] = newGlobalState;
return newGlobalState;
}
private buildCacheKey(location: string, keyDefinition: KeyDefinition<unknown>) {
return `${location}_${keyDefinition.fullName}`;
}
}

View File

@@ -1,408 +0,0 @@
/**
* need to update test environment so trackEmissions works appropriately
* @jest-environment ../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { Jsonify } from "type-fest";
import { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils";
import { LogService } from "@bitwarden/logging";
import { FakeStorageService } from "@bitwarden/storage-test-utils";
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 cleanupDelayMs = 10;
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
deserializer: TestState.fromJSON,
cleanupDelayMs,
});
const globalKey = globalKeyBuilder(testKeyDefinition);
describe("DefaultGlobalState", () => {
let diskStorageService: FakeStorageService;
let globalState: DefaultGlobalState<TestState>;
const logService = mock<LogService>();
const newData = { date: new Date() };
beforeEach(() => {
diskStorageService = new FakeStorageService();
globalState = new DefaultGlobalState(testKeyDefinition, diskStorageService, logService);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("state$", () => {
it("should emit when storage updates", async () => {
const emissions = trackEmissions(globalState.state$);
await diskStorageService.save(globalKey, newData);
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
newData,
]);
});
it("should not emit when update key does not match", async () => {
const emissions = trackEmissions(globalState.state$);
await diskStorageService.save("wrong_key", newData);
expect(emissions).toHaveLength(0);
});
it("should emit initial storage value on first subscribe", async () => {
const initialStorage: Record<string, TestState> = {};
initialStorage[globalKey] = TestState.fromJSON({
date: "2022-09-21T13:14:17.648Z",
});
diskStorageService.internalUpdateStore(initialStorage);
const state = await firstValueFrom(globalState.state$);
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1);
expect(diskStorageService.mock.get).toHaveBeenCalledWith("global_fake_fake", undefined);
expect(state).toBeTruthy();
});
it("should not emit twice if there are two listeners", async () => {
const emissions = trackEmissions(globalState.state$);
const emissions2 = trackEmissions(globalState.state$);
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
]);
expect(emissions2).toEqual([
null, // Initial value
]);
});
});
describe("update", () => {
it("should save on update", async () => {
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$);
await awaitAsync(); // storage updates are behind a promise
await globalState.update((state) => {
return newData;
});
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
newData,
]);
});
it("should provided combined dependencies", async () => {
const emissions = trackEmissions(globalState.state$);
await awaitAsync(); // storage updates are behind a promise
const combinedDependencies = { date: new Date() };
await globalState.update(
(state, dependencies) => {
expect(dependencies).toEqual(combinedDependencies);
return newData;
},
{
combineLatestWith: of(combinedDependencies),
},
);
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
newData,
]);
});
it("should not update if shouldUpdate returns false", async () => {
const emissions = trackEmissions(globalState.state$);
await awaitAsync(); // storage updates are behind a promise
const result = await globalState.update(
(state) => {
return newData;
},
{
shouldUpdate: () => false,
},
);
expect(diskStorageService.mock.save).not.toHaveBeenCalled();
expect(emissions).toEqual([null]); // Initial value
expect(result).toBeNull();
});
it("should provide the update callback with the current State", async () => {
const emissions = trackEmissions(globalState.state$);
await awaitAsync(); // storage updates are behind a promise
// Seed with interesting data
const initialData = { date: new Date(2020, 1, 1) };
await globalState.update((state, dependencies) => {
return initialData;
});
await awaitAsync();
await globalState.update((state) => {
expect(state).toEqual(initialData);
return newData;
});
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
initialData,
newData,
]);
});
it("should give initial state for update call", async () => {
const initialStorage: Record<string, TestState> = {};
const initialState = TestState.fromJSON({
date: "2022-09-21T13:14:17.648Z",
});
initialStorage[globalKey] = initialState;
diskStorageService.internalUpdateStore(initialStorage);
const emissions = trackEmissions(globalState.state$);
await awaitAsync(); // storage updates are behind a promise
const newState = {
...initialState,
date: new Date(initialState.date.getFullYear(), initialState.date.getMonth() + 1),
};
const actual = await globalState.update((existingState) => newState);
await awaitAsync();
expect(actual).toEqual(newState);
expect(emissions).toHaveLength(2);
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
});
});
describe("update races", () => {
test("subscriptions during an update should receive the current and latest data", async () => {
const oldData = { date: new Date(2019, 1, 1) };
await globalState.update(() => {
return oldData;
});
const initialData = { date: new Date(2020, 1, 1) };
await globalState.update(() => {
return initialData;
});
await awaitAsync();
const emissions = trackEmissions(globalState.state$);
await awaitAsync();
expect(emissions).toEqual([initialData]);
let emissions2: TestState[];
const originalSave = diskStorageService.save.bind(diskStorageService);
diskStorageService.save = jest.fn().mockImplementation(async (key: string, obj: any) => {
emissions2 = trackEmissions(globalState.state$);
await originalSave(key, obj);
});
const val = await globalState.update(() => {
return newData;
});
await awaitAsync(10);
expect(val).toEqual(newData);
expect(emissions).toEqual([initialData, newData]);
expect(emissions2).toEqual([initialData, newData]);
});
test("subscription during an aborted update should receive the last value", async () => {
// Seed with interesting data
const initialData = { date: new Date(2020, 1, 1) };
await globalState.update(() => {
return initialData;
});
await awaitAsync();
const emissions = trackEmissions(globalState.state$);
await awaitAsync();
expect(emissions).toEqual([initialData]);
let emissions2: TestState[];
const val = await globalState.update(
() => {
return newData;
},
{
shouldUpdate: () => {
emissions2 = trackEmissions(globalState.state$);
return false;
},
},
);
await awaitAsync();
expect(val).toEqual(initialData);
expect(emissions).toEqual([initialData]);
expect(emissions2).toEqual([initialData]);
});
test("updates should wait until previous update is complete", async () => {
trackEmissions(globalState.state$);
await awaitAsync(); // storage updates are behind a promise
const originalSave = diskStorageService.save.bind(diskStorageService);
diskStorageService.save = jest
.fn()
.mockImplementationOnce(async () => {
let resolved = false;
await Promise.race([
globalState.update(() => {
// deadlocks
resolved = true;
return newData;
}),
awaitAsync(100), // limit test to 100ms
]);
expect(resolved).toBe(false);
})
.mockImplementation(originalSave);
await globalState.update((state) => {
return newData;
});
});
});
describe("cleanup", () => {
function assertClean() {
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(0);
}
it("should cleanup after last subscriber", async () => {
const subscription = globalState.state$.subscribe();
await awaitAsync(); // storage updates are behind a promise
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
assertClean();
});
it("should not cleanup if there are still subscribers", async () => {
const subscription1 = globalState.state$.subscribe();
const sub2Emissions: TestState[] = [];
const subscription2 = globalState.state$.subscribe((v) => sub2Emissions.push(v));
await awaitAsync(); // storage updates are behind a promise
subscription1.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(1);
// Still be listening to storage updates
await diskStorageService.save(globalKey, newData);
await awaitAsync(); // storage updates are behind a promise
expect(sub2Emissions).toEqual([null, newData]);
subscription2.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
assertClean();
});
it("can re-initialize after cleanup", async () => {
const subscription = globalState.state$.subscribe();
await awaitAsync();
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
const emissions = trackEmissions(globalState.state$);
await awaitAsync();
await diskStorageService.save(globalKey, newData);
await awaitAsync();
expect(emissions).toEqual([null, newData]);
});
it("should not cleanup if a subscriber joins during the cleanup delay", async () => {
const subscription = globalState.state$.subscribe();
await awaitAsync();
await diskStorageService.save(globalKey, newData);
await awaitAsync();
subscription.unsubscribe();
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(1);
// Do not wait long enough for cleanup
await awaitAsync(cleanupDelayMs / 2);
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(1);
});
it("state$ observables are durable to cleanup", async () => {
const observable = globalState.state$;
let subscription = observable.subscribe();
await diskStorageService.save(globalKey, newData);
await awaitAsync();
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
subscription = observable.subscribe();
await diskStorageService.save(globalKey, newData);
await awaitAsync();
expect(await firstValueFrom(observable)).toEqual(newData);
});
});
});

View File

@@ -1,20 +0,0 @@
import { LogService } from "@bitwarden/logging";
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
import { GlobalState } from "../global-state";
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
import { StateBase } from "./state-base";
export class DefaultGlobalState<T>
extends StateBase<T, KeyDefinition<T>>
implements GlobalState<T>
{
constructor(
keyDefinition: KeyDefinition<T>,
chosenLocation: AbstractStorageService & ObservableStorageService,
logService: LogService,
) {
super(globalKeyBuilder(keyDefinition), chosenLocation, keyDefinition, logService);
}
}

View File

@@ -1,54 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { LogService } from "@bitwarden/logging";
import { StorageServiceProvider } from "@bitwarden/storage-core";
import { UserId } from "@bitwarden/user-core";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { SingleUserState } from "../user-state";
import { SingleUserStateProvider } from "../user-state.provider";
import { DefaultSingleUserState } from "./default-single-user-state";
export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
private cache: Record<string, SingleUserState<unknown>> = {};
constructor(
private readonly storageServiceProvider: StorageServiceProvider,
private readonly stateEventRegistrarService: StateEventRegistrarService,
private readonly logService: LogService,
) {}
get<T>(userId: UserId, keyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
const [location, storageService] = this.storageServiceProvider.get(
keyDefinition.stateDefinition.defaultStorageLocation,
keyDefinition.stateDefinition.storageLocationOverrides,
);
const cacheKey = this.buildCacheKey(location, userId, keyDefinition);
const existingUserState = this.cache[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 SingleUserState<T>;
}
const newUserState = new DefaultSingleUserState<T>(
userId,
keyDefinition,
storageService,
this.stateEventRegistrarService,
this.logService,
);
this.cache[cacheKey] = newUserState;
return newUserState;
}
private buildCacheKey(
location: string,
userId: UserId,
keyDefinition: UserKeyDefinition<unknown>,
) {
return `${location}_${keyDefinition.fullName}_${userId}`;
}
}

View File

@@ -1,593 +0,0 @@
/**
* need to update test environment so trackEmissions works appropriately
* @jest-environment ../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { Jsonify } from "type-fest";
import { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils";
import { newGuid } from "@bitwarden/guid";
import { LogService } from "@bitwarden/logging";
import { FakeStorageService } from "@bitwarden/storage-test-utils";
import { UserId } from "@bitwarden/user-core";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { DefaultSingleUserState } from "./default-single-user-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 cleanupDelayMs = 10;
const testKeyDefinition = new UserKeyDefinition<TestState>(testStateDefinition, "fake", {
deserializer: TestState.fromJSON,
cleanupDelayMs,
clearOn: [],
});
const userId = newGuid() as UserId;
const userKey = testKeyDefinition.buildKey(userId);
describe("DefaultSingleUserState", () => {
let diskStorageService: FakeStorageService;
let userState: DefaultSingleUserState<TestState>;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
const logService = mock<LogService>();
const newData = { date: new Date() };
beforeEach(() => {
diskStorageService = new FakeStorageService();
userState = new DefaultSingleUserState(
userId,
testKeyDefinition,
diskStorageService,
stateEventRegistrarService,
logService,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("state$", () => {
it("should emit when storage updates", async () => {
const emissions = trackEmissions(userState.state$);
await diskStorageService.save(userKey, newData);
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
newData,
]);
});
it("should not emit when update key does not match", async () => {
const emissions = trackEmissions(userState.state$);
await diskStorageService.save("wrong_key", newData);
// Give userState a chance to emit it's initial value
// as well as wrongly emit the different key.
await awaitAsync();
// Just the initial value
expect(emissions).toEqual([null]);
});
it("should emit initial storage value on first subscribe", async () => {
const initialStorage: Record<string, TestState> = {};
initialStorage[userKey] = TestState.fromJSON({
date: "2022-09-21T13:14:17.648Z",
});
diskStorageService.internalUpdateStore(initialStorage);
const state = await firstValueFrom(userState.state$);
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1);
expect(diskStorageService.mock.get).toHaveBeenCalledWith(
`user_${userId}_fake_fake`,
undefined,
);
expect(state).toBeTruthy();
});
it("should go to disk each subscription if a cleanupDelayMs of 0 is given", async () => {
const state = new DefaultSingleUserState(
userId,
new UserKeyDefinition(testStateDefinition, "test", {
cleanupDelayMs: 0,
deserializer: TestState.fromJSON,
clearOn: [],
debug: {
enableRetrievalLogging: true,
},
}),
diskStorageService,
stateEventRegistrarService,
logService,
);
await firstValueFrom(state.state$);
await firstValueFrom(state.state$);
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(2);
expect(logService.info).toHaveBeenCalledTimes(2);
expect(logService.info).toHaveBeenCalledWith(
`Retrieving 'user_${userId}_fake_test' from storage, value is null`,
);
});
});
describe("combinedState$", () => {
it("should emit when storage updates", async () => {
const emissions = trackEmissions(userState.combinedState$);
await diskStorageService.save(userKey, newData);
await awaitAsync();
expect(emissions).toEqual([
[userId, null], // Initial value
[userId, newData],
]);
});
it("should not emit when update key does not match", async () => {
const emissions = trackEmissions(userState.combinedState$);
await diskStorageService.save("wrong_key", newData);
// Give userState a chance to emit it's initial value
// as well as wrongly emit the different key.
await awaitAsync();
// Just the initial value
expect(emissions).toHaveLength(1);
});
it("should emit initial storage value on first subscribe", async () => {
const initialStorage: Record<string, TestState> = {};
initialStorage[userKey] = TestState.fromJSON({
date: "2022-09-21T13:14:17.648Z",
});
diskStorageService.internalUpdateStore(initialStorage);
const combinedState = await firstValueFrom(userState.combinedState$);
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1);
expect(diskStorageService.mock.get).toHaveBeenCalledWith(
`user_${userId}_fake_fake`,
undefined,
);
expect(combinedState).toBeTruthy();
const [stateUserId, state] = combinedState;
expect(stateUserId).toBe(userId);
expect(state).toBe(initialStorage[userKey]);
});
});
describe("update", () => {
it("should save on update", async () => {
const result = await userState.update((state) => {
return newData;
});
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
expect(result).toEqual(newData);
});
it("should emit once per update", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // storage updates are behind a promise
await userState.update((state) => {
return newData;
});
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
newData,
]);
});
it("should provided combined dependencies", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // storage updates are behind a promise
const combinedDependencies = { date: new Date() };
await userState.update(
(state, dependencies) => {
expect(dependencies).toEqual(combinedDependencies);
return newData;
},
{
combineLatestWith: of(combinedDependencies),
},
);
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
newData,
]);
});
it("should not update if shouldUpdate returns false", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // storage updates are behind a promise
const result = await userState.update(
(state) => {
return newData;
},
{
shouldUpdate: () => false,
},
);
expect(diskStorageService.mock.save).not.toHaveBeenCalled();
expect(emissions).toEqual([null]); // Initial value
expect(result).toBeNull();
});
it("should provide the update callback with the current State", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // storage updates are behind a promise
// Seed with interesting data
const initialData = { date: new Date(2020, 1, 1) };
await userState.update((state, dependencies) => {
return initialData;
});
await awaitAsync();
await userState.update((state) => {
expect(state).toEqual(initialData);
return newData;
});
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
initialData,
newData,
]);
});
it("should give initial state for update call", async () => {
const initialStorage: Record<string, TestState> = {};
const initialState = TestState.fromJSON({
date: "2022-09-21T13:14:17.648Z",
});
initialStorage[userKey] = initialState;
diskStorageService.internalUpdateStore(initialStorage);
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // storage updates are behind a promise
const newState = {
...initialState,
date: new Date(initialState.date.getFullYear(), initialState.date.getMonth() + 1),
};
const actual = await userState.update((existingState) => newState);
await awaitAsync();
expect(actual).toEqual(newState);
expect(emissions).toHaveLength(2);
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
});
it.each([null, undefined])(
"should register user key definition when state transitions from null-ish (%s) to non-null",
async (startingValue: TestState | null) => {
const initialState: Record<string, TestState> = {};
initialState[userKey] = startingValue;
diskStorageService.internalUpdateStore(initialState);
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).toHaveBeenCalledWith(testKeyDefinition);
},
);
it("should not register user key definition when state has preexisting value", async () => {
const initialState: Record<string, TestState> = {};
initialState[userKey] = {
date: new Date(2019, 1),
};
diskStorageService.internalUpdateStore(initialState);
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
});
it.each([null, undefined])(
"should not register user key definition when setting value to null-ish (%s) value",
async (updatedValue: TestState | null) => {
const initialState: Record<string, TestState> = {};
initialState[userKey] = {
date: new Date(2019, 1),
};
diskStorageService.internalUpdateStore(initialState);
await userState.update(() => updatedValue);
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
},
);
const logCases: { startingValue: TestState; updateValue: TestState; phrase: string }[] = [
{
startingValue: null,
updateValue: null,
phrase: "null to null",
},
{
startingValue: null,
updateValue: new TestState(),
phrase: "null to non-null",
},
{
startingValue: new TestState(),
updateValue: null,
phrase: "non-null to null",
},
{
startingValue: new TestState(),
updateValue: new TestState(),
phrase: "non-null to non-null",
},
];
it.each(logCases)(
"should log meta info about the update",
async ({ startingValue, updateValue, phrase }) => {
diskStorageService.internalUpdateStore({
[`user_${userId}_fake_fake`]: startingValue,
});
const state = new DefaultSingleUserState(
userId,
new UserKeyDefinition<TestState>(testStateDefinition, "fake", {
deserializer: TestState.fromJSON,
clearOn: [],
debug: {
enableUpdateLogging: true,
},
}),
diskStorageService,
stateEventRegistrarService,
logService,
);
await state.update(() => updateValue);
expect(logService.info).toHaveBeenCalledWith(
`Updating 'user_${userId}_fake_fake' from ${phrase}`,
);
},
);
});
describe("update races", () => {
test("subscriptions during an update should receive the current and latest data", async () => {
const oldData = { date: new Date(2019, 1, 1) };
await userState.update(() => {
return oldData;
});
const initialData = { date: new Date(2020, 1, 1) };
await userState.update(() => {
return initialData;
});
await awaitAsync();
const emissions = trackEmissions(userState.state$);
await awaitAsync();
expect(emissions).toEqual([initialData]);
let emissions2: TestState[];
const originalSave = diskStorageService.save.bind(diskStorageService);
diskStorageService.save = jest.fn().mockImplementation(async (key: string, obj: any) => {
emissions2 = trackEmissions(userState.state$);
await originalSave(key, obj);
});
const val = await userState.update(() => {
return newData;
});
await awaitAsync(10);
expect(val).toEqual(newData);
expect(emissions).toEqual([initialData, newData]);
expect(emissions2).toEqual([initialData, newData]);
});
test("subscription during an aborted update should receive the last value", async () => {
// Seed with interesting data
const initialData = { date: new Date(2020, 1, 1) };
await userState.update(() => {
return initialData;
});
await awaitAsync();
const emissions = trackEmissions(userState.state$);
await awaitAsync();
expect(emissions).toEqual([initialData]);
let emissions2: TestState[];
const val = await userState.update(
(state) => {
return newData;
},
{
shouldUpdate: () => {
emissions2 = trackEmissions(userState.state$);
return false;
},
},
);
await awaitAsync();
expect(val).toEqual(initialData);
expect(emissions).toEqual([initialData]);
expect(emissions2).toEqual([initialData]);
});
test("updates should wait until previous update is complete", async () => {
trackEmissions(userState.state$);
await awaitAsync(); // storage updates are behind a promise
const originalSave = diskStorageService.save.bind(diskStorageService);
diskStorageService.save = jest
.fn()
.mockImplementationOnce(async () => {
let resolved = false;
await Promise.race([
userState.update(() => {
// deadlocks
resolved = true;
return newData;
}),
awaitAsync(100), // limit test to 100ms
]);
expect(resolved).toBe(false);
})
.mockImplementation(originalSave);
await userState.update((state) => {
return newData;
});
});
test("updates with FAKE_DEFAULT initial value should resolve correctly", async () => {
const val = await userState.update((state) => {
return newData;
});
expect(val).toEqual(newData);
const call = diskStorageService.mock.save.mock.calls[0];
expect(call[0]).toEqual(`user_${userId}_fake_fake`);
expect(call[1]).toEqual(newData);
});
});
describe("cleanup", () => {
function assertClean() {
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(0);
}
it("should cleanup after last subscriber", async () => {
const subscription = userState.state$.subscribe();
await awaitAsync(); // storage updates are behind a promise
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
assertClean();
});
it("should not cleanup if there are still subscribers", async () => {
const subscription1 = userState.state$.subscribe();
const sub2Emissions: TestState[] = [];
const subscription2 = userState.state$.subscribe((v) => sub2Emissions.push(v));
await awaitAsync(); // storage updates are behind a promise
subscription1.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(1);
// Still be listening to storage updates
await diskStorageService.save(userKey, newData);
await awaitAsync(); // storage updates are behind a promise
expect(sub2Emissions).toEqual([null, newData]);
subscription2.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
assertClean();
});
it("can re-initialize after cleanup", async () => {
const subscription = userState.state$.subscribe();
await awaitAsync();
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
const emissions = trackEmissions(userState.state$);
await awaitAsync();
await diskStorageService.save(userKey, newData);
await awaitAsync();
expect(emissions).toEqual([null, newData]);
});
it("should not cleanup if a subscriber joins during the cleanup delay", async () => {
const subscription = userState.state$.subscribe();
await awaitAsync();
await diskStorageService.save(userKey, newData);
await awaitAsync();
subscription.unsubscribe();
// Do not wait long enough for cleanup
await awaitAsync(cleanupDelayMs / 2);
const value = await firstValueFrom(userState.state$);
expect(value).toEqual(newData);
// Should be called once for the initial subscription and a second time during the save
// but should not be called for a second subscription if the cleanup hasn't happened yet.
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(2);
});
it("state$ observables are durable to cleanup", async () => {
const observable = userState.state$;
let subscription = observable.subscribe();
await diskStorageService.save(userKey, newData);
await awaitAsync();
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
subscription = observable.subscribe();
await diskStorageService.save(userKey, newData);
await awaitAsync();
expect(await firstValueFrom(observable)).toEqual(newData);
});
});
});

View File

@@ -1,36 +0,0 @@
import { Observable, combineLatest, of } from "rxjs";
import { LogService } from "@bitwarden/logging";
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
import { UserId } from "@bitwarden/user-core";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { CombinedState, SingleUserState } from "../user-state";
import { StateBase } from "./state-base";
export class DefaultSingleUserState<T>
extends StateBase<T, UserKeyDefinition<T>>
implements SingleUserState<T>
{
readonly combinedState$: Observable<CombinedState<T | null>>;
constructor(
readonly userId: UserId,
keyDefinition: UserKeyDefinition<T>,
chosenLocation: AbstractStorageService & ObservableStorageService,
private stateEventRegistrarService: StateEventRegistrarService,
logService: LogService,
) {
super(keyDefinition.buildKey(userId), chosenLocation, keyDefinition, logService);
this.combinedState$ = combineLatest([of(userId), this.state$]);
}
protected override async doStorageSave(newState: T, oldState: T): Promise<void> {
await super.doStorageSave(newState, oldState);
if (newState != null && oldState == null) {
await this.stateEventRegistrarService.registerEvents(this.keyDefinition);
}
}
}

View File

@@ -1,253 +0,0 @@
/**
* need to update test environment so structuredClone works appropriately
* @jest-environment ../shared/test.environment.ts
*/
import { Observable, of } from "rxjs";
import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils";
import {
FakeActiveUserAccessor,
FakeActiveUserStateProvider,
FakeDerivedStateProvider,
FakeGlobalStateProvider,
FakeSingleUserStateProvider,
} from "@bitwarden/state-test-utils";
import { UserId } from "@bitwarden/user-core";
import { DeriveDefinition } from "../derive-definition";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { UserKeyDefinition } from "../user-key-definition";
import { DefaultStateProvider } from "./default-state.provider";
describe("DefaultStateProvider", () => {
let sut: DefaultStateProvider;
let activeUserStateProvider: FakeActiveUserStateProvider;
let singleUserStateProvider: FakeSingleUserStateProvider;
let globalStateProvider: FakeGlobalStateProvider;
let derivedStateProvider: FakeDerivedStateProvider;
let activeAccountAccessor: FakeActiveUserAccessor;
const userId = "fakeUserId" as UserId;
beforeEach(() => {
activeAccountAccessor = new FakeActiveUserAccessor(userId);
activeUserStateProvider = new FakeActiveUserStateProvider(activeAccountAccessor);
singleUserStateProvider = new FakeSingleUserStateProvider();
globalStateProvider = new FakeGlobalStateProvider();
derivedStateProvider = new FakeDerivedStateProvider();
sut = new DefaultStateProvider(
activeUserStateProvider,
singleUserStateProvider,
globalStateProvider,
derivedStateProvider,
);
});
describe("activeUserId$", () => {
it("should track the active User id from active user state provider", () => {
expect(sut.activeUserId$).toBe(activeUserStateProvider.activeUserId$);
});
});
describe.each([
[
"getUserState$",
(keyDefinition: UserKeyDefinition<string>, userId?: UserId) =>
sut.getUserState$(keyDefinition, userId),
],
[
"getUserStateOrDefault$",
(keyDefinition: UserKeyDefinition<string>, userId?: UserId) =>
sut.getUserStateOrDefault$(keyDefinition, { userId: userId }),
],
])(
"Shared behavior for %s",
(
_testName: string,
methodUnderTest: (
keyDefinition: UserKeyDefinition<string>,
userId?: UserId,
) => Observable<string>,
) => {
const keyDefinition = new UserKeyDefinition<string>(
new StateDefinition("test", "disk"),
"test",
{
deserializer: (s) => s,
clearOn: [],
},
);
it("should follow the specified user if userId is provided", async () => {
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(methodUnderTest(keyDefinition, userId));
state.nextState("value2");
state.nextState("value3");
expect(emissions).toEqual(["value", "value2", "value3"]);
});
it("should follow the current active user if no userId is provided", async () => {
activeAccountAccessor.switch(userId);
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(methodUnderTest(keyDefinition));
state.nextState("value2");
state.nextState("value3");
expect(emissions).toEqual(["value", "value2", "value3"]);
});
it("should continue to follow the state of the user that was active when called, even if active user changes", async () => {
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(methodUnderTest(keyDefinition));
activeAccountAccessor.switch("newUserId" as UserId);
const newUserEmissions = trackEmissions(sut.getUserState$(keyDefinition));
state.nextState("value2");
state.nextState("value3");
expect(emissions).toEqual(["value", "value2", "value3"]);
expect(newUserEmissions).toEqual([null]);
});
},
);
describe("getUserState$", () => {
const keyDefinition = new UserKeyDefinition<string>(
new StateDefinition("test", "disk"),
"test",
{
deserializer: (s) => s,
clearOn: [],
},
);
it("should not emit any values until a truthy user id is supplied", async () => {
activeAccountAccessor.switch(null);
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(sut.getUserState$(keyDefinition));
await awaitAsync();
expect(emissions).toHaveLength(0);
activeAccountAccessor.switch(userId);
await awaitAsync();
expect(emissions).toEqual(["value"]);
});
});
describe("getUserStateOrDefault$", () => {
const keyDefinition = new UserKeyDefinition<string>(
new StateDefinition("test", "disk"),
"test",
{
deserializer: (s) => s,
clearOn: [],
},
);
it("should emit default value if no userId supplied and first active user id emission in falsy", async () => {
activeAccountAccessor.switch(null);
const emissions = trackEmissions(
sut.getUserStateOrDefault$(keyDefinition, {
userId: undefined,
defaultValue: "I'm default!",
}),
);
expect(emissions).toEqual(["I'm default!"]);
});
});
describe("setUserState", () => {
const keyDefinition = new UserKeyDefinition<string>(
new StateDefinition("test", "disk"),
"test",
{
deserializer: (s) => s,
clearOn: [],
},
);
it("should set the state for the active user if no userId is provided", async () => {
const value = "value";
await sut.setUserState(keyDefinition, value);
const state = activeUserStateProvider.getFake(keyDefinition);
expect(state.nextMock).toHaveBeenCalledWith([expect.any(String), value]);
});
it("should not set state for a single user if no userId is provided", async () => {
const value = "value";
await sut.setUserState(keyDefinition, value);
const state = singleUserStateProvider.getFake(userId, keyDefinition);
expect(state.nextMock).not.toHaveBeenCalled();
});
it("should set the state for the provided userId", async () => {
const value = "value";
await sut.setUserState(keyDefinition, value, userId);
const state = singleUserStateProvider.getFake(userId, keyDefinition);
expect(state.nextMock).toHaveBeenCalledWith(value);
});
it("should not set the active user state if userId is provided", async () => {
const value = "value";
await sut.setUserState(keyDefinition, value, userId);
const state = activeUserStateProvider.getFake(keyDefinition);
expect(state.nextMock).not.toHaveBeenCalled();
});
});
it("should bind the activeUserStateProvider", () => {
const keyDefinition = new UserKeyDefinition(new StateDefinition("test", "disk"), "test", {
deserializer: () => null,
clearOn: [],
});
const existing = activeUserStateProvider.get(keyDefinition);
const actual = sut.getActive(keyDefinition);
expect(actual).toBe(existing);
});
it("should bind the singleUserStateProvider", () => {
const userId = "user" as UserId;
const keyDefinition = new UserKeyDefinition(new StateDefinition("test", "disk"), "test", {
deserializer: () => null,
clearOn: [],
});
const existing = singleUserStateProvider.get(userId, keyDefinition);
const actual = sut.getUser(userId, keyDefinition);
expect(actual).toBe(existing);
});
it("should bind the globalStateProvider", () => {
const keyDefinition = new KeyDefinition(new StateDefinition("test", "disk"), "test", {
deserializer: () => null,
});
const existing = globalStateProvider.get(keyDefinition);
const actual = sut.getGlobal(keyDefinition);
expect(actual).toBe(existing);
});
it("should bind the derivedStateProvider", () => {
const derivedDefinition = new DeriveDefinition(new StateDefinition("test", "disk"), "test", {
derive: () => null,
deserializer: () => null,
});
const parentState$ = of(null);
const existing = derivedStateProvider.get(parentState$, derivedDefinition, {});
const actual = sut.getDerived(parentState$, derivedDefinition, {});
expect(actual).toBe(existing);
});
});

View File

@@ -1,80 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, filter, of, switchMap, take } from "rxjs";
import { UserId } from "@bitwarden/user-core";
import { DerivedStateDependencies } from "../../types/state";
import { DeriveDefinition } from "../derive-definition";
import { DerivedState } from "../derived-state";
import { DerivedStateProvider } from "../derived-state.provider";
import { GlobalStateProvider } from "../global-state.provider";
import { StateProvider } from "../state.provider";
import { UserKeyDefinition } from "../user-key-definition";
import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider";
export class DefaultStateProvider implements StateProvider {
activeUserId$: Observable<UserId>;
constructor(
private readonly activeUserStateProvider: ActiveUserStateProvider,
private readonly singleUserStateProvider: SingleUserStateProvider,
private readonly globalStateProvider: GlobalStateProvider,
private readonly derivedStateProvider: DerivedStateProvider,
) {
this.activeUserId$ = this.activeUserStateProvider.activeUserId$;
}
getUserState$<T>(userKeyDefinition: UserKeyDefinition<T>, userId?: UserId): Observable<T> {
if (userId) {
return this.getUser<T>(userId, userKeyDefinition).state$;
} else {
return this.activeUserId$.pipe(
filter((userId) => userId != null), // Filter out null-ish user ids since we can't get state for a null user id
take(1),
switchMap((userId) => this.getUser<T>(userId, userKeyDefinition).state$),
);
}
}
getUserStateOrDefault$<T>(
userKeyDefinition: UserKeyDefinition<T>,
config: { userId: UserId | undefined; defaultValue?: T },
): Observable<T> {
const { userId, defaultValue = null } = config;
if (userId) {
return this.getUser<T>(userId, userKeyDefinition).state$;
} else {
return this.activeUserId$.pipe(
take(1),
switchMap((userId) =>
userId != null ? this.getUser<T>(userId, userKeyDefinition).state$ : of(defaultValue),
),
);
}
}
async setUserState<T>(
userKeyDefinition: UserKeyDefinition<T>,
value: T | null,
userId?: UserId,
): Promise<[UserId, T | null]> {
if (userId) {
return [userId, await this.getUser<T>(userId, userKeyDefinition).update(() => value)];
} else {
return await this.getActive<T>(userKeyDefinition).update(() => value);
}
}
getActive: InstanceType<typeof ActiveUserStateProvider>["get"] =
this.activeUserStateProvider.get.bind(this.activeUserStateProvider);
getUser: InstanceType<typeof SingleUserStateProvider>["get"] =
this.singleUserStateProvider.get.bind(this.singleUserStateProvider);
getGlobal: InstanceType<typeof GlobalStateProvider>["get"] = this.globalStateProvider.get.bind(
this.globalStateProvider,
);
getDerived: <TFrom, TTo, TDeps extends DerivedStateDependencies>(
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<unknown, TTo, TDeps>,
dependencies: TDeps,
) => DerivedState<TTo> = this.derivedStateProvider.get.bind(this.derivedStateProvider);
}

View File

@@ -1,12 +0,0 @@
export * from "./default-active-user-state.provider";
export * from "./default-active-user-state";
export * from "./default-derived-state.provider";
export * from "./default-derived-state";
export * from "./default-global-state.provider";
export * from "./default-global-state";
export * from "./default-single-user-state.provider";
export * from "./default-single-user-state";
export * from "./default-state.provider";
export * from "./inline-derived-state";
export * from "./state-base";
export * from "./util";

View File

@@ -1,62 +0,0 @@
import { Subject, firstValueFrom } from "rxjs";
import { DeriveDefinition } from "../derive-definition";
import { StateDefinition } from "../state-definition";
import { InlineDerivedState } from "./inline-derived-state";
describe("InlineDerivedState", () => {
const syncDeriveDefinition = new DeriveDefinition<boolean, boolean, Record<string, unknown>>(
new StateDefinition("test", "disk"),
"test",
{
derive: (value, deps) => !value,
deserializer: (value) => value,
},
);
const asyncDeriveDefinition = new DeriveDefinition<boolean, boolean, Record<string, unknown>>(
new StateDefinition("test", "disk"),
"test",
{
derive: async (value, deps) => Promise.resolve(!value),
deserializer: (value) => value,
},
);
const parentState = new Subject<boolean>();
describe("state", () => {
const cases = [
{
it: "works when derive function is sync",
definition: syncDeriveDefinition,
},
{
it: "works when derive function is async",
definition: asyncDeriveDefinition,
},
];
it.each(cases)("$it", async ({ definition }) => {
const sut = new InlineDerivedState(parentState.asObservable(), definition, {});
const valuePromise = firstValueFrom(sut.state$);
parentState.next(true);
const value = await valuePromise;
expect(value).toBe(false);
});
});
describe("forceValue", () => {
it("returns the force value back to the caller", async () => {
const sut = new InlineDerivedState(parentState.asObservable(), syncDeriveDefinition, {});
const value = await sut.forceValue(true);
expect(value).toBe(true);
});
});
});

View File

@@ -1,37 +0,0 @@
import { Observable, concatMap } from "rxjs";
import { DerivedStateDependencies } from "../../types/state";
import { DeriveDefinition } from "../derive-definition";
import { DerivedState } from "../derived-state";
import { DerivedStateProvider } from "../derived-state.provider";
export class InlineDerivedStateProvider implements DerivedStateProvider {
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
): DerivedState<TTo> {
return new InlineDerivedState(parentState$, deriveDefinition, dependencies);
}
}
export class InlineDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>
implements DerivedState<TTo>
{
constructor(
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
) {
this.state$ = parentState$.pipe(
concatMap(async (value) => await deriveDefinition.derive(value, dependencies)),
);
}
state$: Observable<TTo>;
forceValue(value: TTo): Promise<TTo> {
// No need to force anything, we don't keep a cache
return Promise.resolve(value);
}
}

View File

@@ -1,177 +0,0 @@
import { mock } from "jest-mock-extended";
import { LogService } from "@bitwarden/logging";
import { FakeActiveUserAccessor } from "@bitwarden/state-test-utils";
import { StorageServiceProvider } from "@bitwarden/storage-core";
import { FakeStorageService } from "@bitwarden/storage-test-utils";
import { UserId } from "@bitwarden/user-core";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { DefaultActiveUserState } from "./default-active-user-state";
import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider";
import { DefaultGlobalState } from "./default-global-state";
import { DefaultGlobalStateProvider } from "./default-global-state.provider";
import { DefaultSingleUserState } from "./default-single-user-state";
import { DefaultSingleUserStateProvider } from "./default-single-user-state.provider";
describe("Specific State Providers", () => {
const storageServiceProvider = mock<StorageServiceProvider>();
const stateEventRegistrarService = mock<StateEventRegistrarService>();
const logService = mock<LogService>();
let singleSut: DefaultSingleUserStateProvider;
let activeSut: DefaultActiveUserStateProvider;
let globalSut: DefaultGlobalStateProvider;
const fakeUser1 = "00000000-0000-1000-a000-000000000001" as UserId;
beforeEach(() => {
storageServiceProvider.get.mockImplementation((location) => {
return [location, new FakeStorageService()];
});
singleSut = new DefaultSingleUserStateProvider(
storageServiceProvider,
stateEventRegistrarService,
logService,
);
activeSut = new DefaultActiveUserStateProvider(new FakeActiveUserAccessor(null), singleSut);
globalSut = new DefaultGlobalStateProvider(storageServiceProvider, logService);
});
const fakeDiskStateDefinition = new StateDefinition("fake", "disk");
const fakeAlternateDiskStateDefinition = new StateDefinition("fakeAlternate", "disk");
const fakeMemoryStateDefinition = new StateDefinition("fake", "memory");
const makeKeyDefinition = (stateDefinition: StateDefinition, key: string) =>
new KeyDefinition<boolean>(stateDefinition, key, {
deserializer: (b) => b,
});
const makeUserKeyDefinition = (stateDefinition: StateDefinition, key: string) =>
new UserKeyDefinition<boolean>(stateDefinition, key, {
deserializer: (b) => b,
clearOn: [],
});
const keyDefinitions = {
disk: {
keyDefinition: makeKeyDefinition(fakeDiskStateDefinition, "fake"),
userKeyDefinition: makeUserKeyDefinition(fakeDiskStateDefinition, "fake"),
altKeyDefinition: makeKeyDefinition(fakeDiskStateDefinition, "fakeAlternate"),
altUserKeyDefinition: makeUserKeyDefinition(fakeDiskStateDefinition, "fakeAlternate"),
},
memory: {
keyDefinition: makeKeyDefinition(fakeMemoryStateDefinition, "fake"),
userKeyDefinition: makeUserKeyDefinition(fakeMemoryStateDefinition, "fake"),
},
alternateDisk: {
keyDefinition: makeKeyDefinition(fakeAlternateDiskStateDefinition, "fake"),
userKeyDefinition: makeUserKeyDefinition(fakeAlternateDiskStateDefinition, "fake"),
},
};
describe("active provider", () => {
it("returns a DefaultActiveUserState", () => {
const state = activeSut.get(keyDefinitions.disk.userKeyDefinition);
expect(state).toBeInstanceOf(DefaultActiveUserState);
});
it("returns different instances when the storage location differs", () => {
const stateDisk = activeSut.get(keyDefinitions.disk.userKeyDefinition);
const stateMemory = activeSut.get(keyDefinitions.memory.userKeyDefinition);
expect(stateDisk).not.toStrictEqual(stateMemory);
});
it("returns different instances when the state name differs", () => {
const state = activeSut.get(keyDefinitions.disk.userKeyDefinition);
const stateAlt = activeSut.get(keyDefinitions.alternateDisk.userKeyDefinition);
expect(state).not.toStrictEqual(stateAlt);
});
it("returns different instances when the key differs", () => {
const state = activeSut.get(keyDefinitions.disk.userKeyDefinition);
const stateAlt = activeSut.get(keyDefinitions.disk.altUserKeyDefinition);
expect(state).not.toStrictEqual(stateAlt);
});
});
describe("single provider", () => {
it("returns a DefaultSingleUserState", () => {
const state = singleSut.get(fakeUser1, keyDefinitions.disk.userKeyDefinition);
expect(state).toBeInstanceOf(DefaultSingleUserState);
});
it("returns different instances when the storage location differs", () => {
const stateDisk = singleSut.get(fakeUser1, keyDefinitions.disk.userKeyDefinition);
const stateMemory = singleSut.get(fakeUser1, keyDefinitions.memory.userKeyDefinition);
expect(stateDisk).not.toStrictEqual(stateMemory);
});
it("returns different instances when the state name differs", () => {
const state = singleSut.get(fakeUser1, keyDefinitions.disk.userKeyDefinition);
const stateAlt = singleSut.get(fakeUser1, keyDefinitions.alternateDisk.userKeyDefinition);
expect(state).not.toStrictEqual(stateAlt);
});
it("returns different instances when the key differs", () => {
const state = singleSut.get(fakeUser1, keyDefinitions.disk.userKeyDefinition);
const stateAlt = singleSut.get(fakeUser1, keyDefinitions.disk.altUserKeyDefinition);
expect(state).not.toStrictEqual(stateAlt);
});
const fakeUser2 = "00000000-0000-1000-a000-000000000002" as UserId;
it("returns different instances when the user id differs", () => {
const user1State = singleSut.get(fakeUser1, keyDefinitions.disk.userKeyDefinition);
const user2State = singleSut.get(fakeUser2, keyDefinitions.disk.userKeyDefinition);
expect(user1State).not.toStrictEqual(user2State);
});
it("returns an instance with the userId property corresponding to the user id passed in", () => {
const userState = singleSut.get(fakeUser1, keyDefinitions.disk.userKeyDefinition);
expect(userState.userId).toBe(fakeUser1);
});
it("returns cached instance on repeated request", () => {
const stateFirst = singleSut.get(fakeUser1, keyDefinitions.disk.userKeyDefinition);
const stateCached = singleSut.get(fakeUser1, keyDefinitions.disk.userKeyDefinition);
expect(stateFirst).toStrictEqual(stateCached);
});
});
describe("global provider", () => {
it("returns a DefaultGlobalState", () => {
const state = globalSut.get(keyDefinitions.disk.keyDefinition);
expect(state).toBeInstanceOf(DefaultGlobalState);
});
it("returns different instances when the storage location differs", () => {
const stateDisk = globalSut.get(keyDefinitions.disk.keyDefinition);
const stateMemory = globalSut.get(keyDefinitions.memory.keyDefinition);
expect(stateDisk).not.toStrictEqual(stateMemory);
});
it("returns different instances when the state name differs", () => {
const state = globalSut.get(keyDefinitions.disk.keyDefinition);
const stateAlt = globalSut.get(keyDefinitions.alternateDisk.keyDefinition);
expect(state).not.toStrictEqual(stateAlt);
});
it("returns different instances when the key differs", () => {
const state = globalSut.get(keyDefinitions.disk.keyDefinition);
const stateAlt = globalSut.get(keyDefinitions.disk.altKeyDefinition);
expect(state).not.toStrictEqual(stateAlt);
});
it("returns cached instance on repeated request", () => {
const stateFirst = globalSut.get(keyDefinitions.disk.keyDefinition);
const stateCached = globalSut.get(keyDefinitions.disk.keyDefinition);
expect(stateFirst).toStrictEqual(stateCached);
});
});
});

View File

@@ -1,137 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
defer,
filter,
firstValueFrom,
merge,
Observable,
ReplaySubject,
share,
switchMap,
tap,
timeout,
timer,
} from "rxjs";
import { Jsonify } from "type-fest";
import { LogService } from "@bitwarden/logging";
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
import { StorageKey } from "../../types/state";
import { DebugOptions } from "../key-definition";
import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options";
import { getStoredValue } from "./util";
// The parts of a KeyDefinition this class cares about to make it work
type KeyDefinitionRequirements<T> = {
deserializer: (jsonState: Jsonify<T>) => T | null;
cleanupDelayMs: number;
debug: Required<DebugOptions>;
};
export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>> {
private updatePromise: Promise<T>;
readonly state$: Observable<T | null>;
constructor(
protected readonly key: StorageKey,
protected readonly storageService: AbstractStorageService & ObservableStorageService,
protected readonly keyDefinition: KeyDef,
protected readonly logService: LogService,
) {
const storageUpdate$ = storageService.updates$.pipe(
filter((storageUpdate) => storageUpdate.key === key),
switchMap(async (storageUpdate) => {
if (storageUpdate.updateType === "remove") {
return null;
}
return await getStoredValue(key, storageService, keyDefinition.deserializer);
}),
);
let state$ = merge(
defer(() => getStoredValue(key, storageService, keyDefinition.deserializer)),
storageUpdate$,
);
if (keyDefinition.debug.enableRetrievalLogging) {
state$ = state$.pipe(
tap({
next: (v) => {
this.logService.info(
`Retrieving '${key}' from storage, value is ${v == null ? "null" : "non-null"}`,
);
},
}),
);
}
// If 0 cleanup is chosen, treat this as absolutely no cache
if (keyDefinition.cleanupDelayMs !== 0) {
state$ = state$.pipe(
share({
connector: () => new ReplaySubject(1),
resetOnRefCountZero: () => timer(keyDefinition.cleanupDelayMs),
}),
);
}
this.state$ = state$;
}
async update<TCombine>(
configureState: (state: T | null, dependency: TCombine) => T | null,
options: StateUpdateOptions<T, TCombine> = {},
): Promise<T | null> {
options = populateOptionsWithDefault(options);
if (this.updatePromise != null) {
await this.updatePromise;
}
try {
this.updatePromise = this.internalUpdate(configureState, options);
return await this.updatePromise;
} finally {
this.updatePromise = null;
}
}
private async internalUpdate<TCombine>(
configureState: (state: T | null, dependency: TCombine) => T | null,
options: StateUpdateOptions<T, TCombine>,
): Promise<T | null> {
const currentState = await this.getStateForUpdate();
const combinedDependencies =
options.combineLatestWith != null
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null;
if (!options.shouldUpdate(currentState, combinedDependencies)) {
return currentState;
}
const newState = configureState(currentState, combinedDependencies);
await this.doStorageSave(newState, currentState);
return newState;
}
protected async doStorageSave(newState: T | null, oldState: T) {
if (this.keyDefinition.debug.enableUpdateLogging) {
this.logService.info(
`Updating '${this.key}' from ${oldState == null ? "null" : "non-null"} to ${newState == null ? "null" : "non-null"}`,
);
}
await this.storageService.save(this.key, newState);
}
/** For use in update methods, does not wait for update to complete before yielding state.
* The expectation is that that await is already done
*/
private async getStateForUpdate() {
return await getStoredValue(this.key, this.storageService, this.keyDefinition.deserializer);
}
}

View File

@@ -1,50 +0,0 @@
import { FakeStorageService } from "@bitwarden/storage-test-utils";
import { getStoredValue } from "./util";
describe("getStoredValue", () => {
const key = "key";
const deserializedValue = { value: 1 };
const value = JSON.stringify(deserializedValue);
const deserializer = (v: string) => JSON.parse(v);
let storageService: FakeStorageService;
beforeEach(() => {
storageService = new FakeStorageService();
});
describe("when the storage service requires deserialization", () => {
beforeEach(() => {
storageService.internalUpdateValuesRequireDeserialization(true);
});
it("should deserialize", async () => {
await storageService.save(key, value);
const result = await getStoredValue(key, storageService, deserializer);
expect(result).toEqual(deserializedValue);
});
});
describe("when the storage service does not require deserialization", () => {
beforeEach(() => {
storageService.internalUpdateValuesRequireDeserialization(false);
});
it("should not deserialize", async () => {
await storageService.save(key, value);
const result = await getStoredValue(key, storageService, deserializer);
expect(result).toEqual(value);
});
it("should convert undefined to null", async () => {
await storageService.save(key, undefined);
const result = await getStoredValue(key, storageService, deserializer);
expect(result).toEqual(null);
});
});
});

View File

@@ -1,17 +0,0 @@
import { Jsonify } from "type-fest";
import { AbstractStorageService } from "@bitwarden/storage-core";
export async function getStoredValue<T>(
key: string,
storage: AbstractStorageService,
deserializer: (jsonValue: Jsonify<T>) => T | null,
) {
if (storage.valuesRequireDeserialization) {
const jsonValue = await storage.get<Jsonify<T>>(key);
return deserializer(jsonValue);
} else {
const value = await storage.get<T>(key);
return value ?? null;
}
}

View File

@@ -6,14 +6,12 @@ export { StateProvider } from "./state.provider";
export { GlobalStateProvider } from "./global-state.provider";
export { ActiveUserState, SingleUserState, CombinedState } from "./user-state";
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
export { KeyDefinition, KeyDefinitionOptions } from "./key-definition";
export { KeyDefinition, KeyDefinitionOptions, DebugOptions } from "./key-definition";
export { StateUpdateOptions } from "./state-update-options";
export { UserKeyDefinitionOptions, UserKeyDefinition } from "./user-key-definition";
export { UserKeyDefinitionOptions, UserKeyDefinition, ClearEvent } from "./user-key-definition";
export { StateEventRunnerService } from "./state-event-runner.service";
export { activeMarker } from "./user-state";
export { StateDefinition } from "./state-definition";
export { ActiveUserAccessor } from "./active-user.accessor";
export * from "./state-definitions";
export * from "./implementations";
export * from "./state-event-registrar.service";

View File

@@ -4,8 +4,6 @@ import { Jsonify } from "type-fest";
import { array, record } from "@bitwarden/serialization";
import { StorageKey } from "../types/state";
import { StateDefinition } from "./state-definition";
export type DebugOptions = {
@@ -172,12 +170,3 @@ export class KeyDefinition<T> {
return `${this.stateDefinition.name} > ${this.key}`;
}
}
/**
* Creates a {@link StorageKey}
* @param keyDefinition The key definition of which data the key should point to.
* @returns A key that is ready to be used in a storage service to get data.
*/
export function globalKeyBuilder(keyDefinition: KeyDefinition<unknown>): StorageKey {
return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey;
}

View File

@@ -1,88 +0,0 @@
import { mock } from "jest-mock-extended";
import { FakeGlobalStateProvider } from "@bitwarden/state-test-utils";
import {
AbstractStorageService,
ObservableStorageService,
StorageServiceProvider,
} from "@bitwarden/storage-core";
import { StateDefinition } from "./state-definition";
import { STATE_LOCK_EVENT, StateEventRegistrarService } from "./state-event-registrar.service";
import { UserKeyDefinition } from "./user-key-definition";
describe("StateEventRegistrarService", () => {
const globalStateProvider = new FakeGlobalStateProvider();
const lockState = globalStateProvider.getFake(STATE_LOCK_EVENT);
const storageServiceProvider = mock<StorageServiceProvider>();
const sut = new StateEventRegistrarService(globalStateProvider, storageServiceProvider);
describe("registerEvents", () => {
const fakeKeyDefinition = new UserKeyDefinition<boolean>(
new StateDefinition("fakeState", "disk"),
"fakeKey",
{
deserializer: (s) => s,
clearOn: ["lock"],
},
);
beforeEach(() => {
jest.resetAllMocks();
});
it("adds event on null storage", async () => {
storageServiceProvider.get.mockReturnValue([
"disk",
mock<AbstractStorageService & ObservableStorageService>(),
]);
await sut.registerEvents(fakeKeyDefinition);
expect(lockState.nextMock).toHaveBeenCalledWith([
{
key: "fakeKey",
location: "disk",
state: "fakeState",
},
]);
});
it("adds event on empty array in storage", async () => {
lockState.stateSubject.next([]);
storageServiceProvider.get.mockReturnValue([
"disk",
mock<AbstractStorageService & ObservableStorageService>(),
]);
await sut.registerEvents(fakeKeyDefinition);
expect(lockState.nextMock).toHaveBeenCalledWith([
{
key: "fakeKey",
location: "disk",
state: "fakeState",
},
]);
});
it("doesn't add a duplicate", async () => {
lockState.stateSubject.next([
{
key: "fakeKey",
location: "disk",
state: "fakeState",
},
]);
storageServiceProvider.get.mockReturnValue([
"disk",
mock<AbstractStorageService & ObservableStorageService>(),
]);
await sut.registerEvents(fakeKeyDefinition);
expect(lockState.nextMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,76 +1,5 @@
import { PossibleLocation, StorageServiceProvider } from "@bitwarden/storage-core";
import { UserKeyDefinition } from "./user-key-definition";
import { GlobalState } from "./global-state";
import { GlobalStateProvider } from "./global-state.provider";
import { KeyDefinition } from "./key-definition";
import { CLEAR_EVENT_DISK } from "./state-definitions";
import { ClearEvent, UserKeyDefinition } from "./user-key-definition";
export type StateEventInfo = {
state: string;
key: string;
location: PossibleLocation;
};
export const STATE_LOCK_EVENT = KeyDefinition.array<StateEventInfo>(CLEAR_EVENT_DISK, "lock", {
deserializer: (e) => e,
});
export const STATE_LOGOUT_EVENT = KeyDefinition.array<StateEventInfo>(CLEAR_EVENT_DISK, "logout", {
deserializer: (e) => e,
});
export class StateEventRegistrarService {
private readonly stateEventStateMap: { [Prop in ClearEvent]: GlobalState<StateEventInfo[]> };
constructor(
globalStateProvider: GlobalStateProvider,
private storageServiceProvider: StorageServiceProvider,
) {
this.stateEventStateMap = {
lock: globalStateProvider.get(STATE_LOCK_EVENT),
logout: globalStateProvider.get(STATE_LOGOUT_EVENT),
};
}
async registerEvents(keyDefinition: UserKeyDefinition<unknown>) {
for (const clearEvent of keyDefinition.clearOn) {
const eventState = this.stateEventStateMap[clearEvent];
// Determine the storage location for this
const [storageLocation] = this.storageServiceProvider.get(
keyDefinition.stateDefinition.defaultStorageLocation,
keyDefinition.stateDefinition.storageLocationOverrides,
);
const newEvent: StateEventInfo = {
state: keyDefinition.stateDefinition.name,
key: keyDefinition.key,
location: storageLocation,
};
// Only update the event state if the existing list doesn't have a matching entry
await eventState.update(
(existingTickets) => {
existingTickets ??= [];
existingTickets.push(newEvent);
return existingTickets;
},
{
shouldUpdate: (currentTickets) => {
return (
// If the current tickets are null, then it will for sure be added
currentTickets == null ||
// If an existing match couldn't be found, we also need to add one
currentTickets.findIndex(
(e) =>
e.state === newEvent.state &&
e.key === newEvent.key &&
e.location === newEvent.location,
) === -1
);
},
},
);
}
}
export abstract class StateEventRegistrarService {
abstract registerEvents(keyDefinition: UserKeyDefinition<unknown>): Promise<void>;
}

View File

@@ -1,72 +0,0 @@
import { mock } from "jest-mock-extended";
import { FakeGlobalStateProvider } from "@bitwarden/state-test-utils";
import {
AbstractStorageService,
ObservableStorageService,
StorageServiceProvider,
} from "@bitwarden/storage-core";
import { UserId } from "@bitwarden/user-core";
import { STATE_LOCK_EVENT } from "./state-event-registrar.service";
import { StateEventRunnerService } from "./state-event-runner.service";
describe("EventRunnerService", () => {
const fakeGlobalStateProvider = new FakeGlobalStateProvider();
const lockState = fakeGlobalStateProvider.getFake(STATE_LOCK_EVENT);
const storageServiceProvider = mock<StorageServiceProvider>();
const sut = new StateEventRunnerService(fakeGlobalStateProvider, storageServiceProvider);
describe("handleEvent", () => {
it("does nothing if there are no events in state", async () => {
const mockStorageService = mock<AbstractStorageService & ObservableStorageService>();
storageServiceProvider.get.mockReturnValue(["disk", mockStorageService]);
await sut.handleEvent("lock", "bff09d3c-762a-4551-9275-45b137b2f073" as UserId);
expect(lockState.nextMock).not.toHaveBeenCalled();
});
it("loops through and acts on all events", async () => {
const mockDiskStorageService = mock<AbstractStorageService & ObservableStorageService>();
const mockMemoryStorageService = mock<AbstractStorageService & ObservableStorageService>();
lockState.stateSubject.next([
{
state: "fakeState1",
key: "fakeKey1",
location: "disk",
},
{
state: "fakeState2",
key: "fakeKey2",
location: "memory",
},
]);
storageServiceProvider.get.mockImplementation((defaultLocation, overrides) => {
if (defaultLocation === "disk") {
return [defaultLocation, mockDiskStorageService];
} else if (defaultLocation === "memory") {
return [defaultLocation, mockMemoryStorageService];
}
});
mockMemoryStorageService.get.mockResolvedValue("something");
await sut.handleEvent("lock", "bff09d3c-762a-4551-9275-45b137b2f073" as UserId);
expect(mockDiskStorageService.get).toHaveBeenCalledTimes(1);
expect(mockDiskStorageService.get).toHaveBeenCalledWith(
"user_bff09d3c-762a-4551-9275-45b137b2f073_fakeState1_fakeKey1",
);
expect(mockMemoryStorageService.get).toHaveBeenCalledTimes(1);
expect(mockMemoryStorageService.get).toHaveBeenCalledWith(
"user_bff09d3c-762a-4551-9275-45b137b2f073_fakeState2_fakeKey2",
);
expect(mockMemoryStorageService.remove).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,82 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { StorageServiceProvider, StorageLocation } from "@bitwarden/storage-core";
import { UserId } from "@bitwarden/user-core";
import { GlobalState } from "./global-state";
import { GlobalStateProvider } from "./global-state.provider";
import { StateDefinition } from "./state-definition";
import {
STATE_LOCK_EVENT,
STATE_LOGOUT_EVENT,
StateEventInfo,
} from "./state-event-registrar.service";
import { ClearEvent, UserKeyDefinition } from "./user-key-definition";
import { ClearEvent } from "./user-key-definition";
export class StateEventRunnerService {
private readonly stateEventMap: { [Prop in ClearEvent]: GlobalState<StateEventInfo[]> };
constructor(
globalStateProvider: GlobalStateProvider,
private storageServiceProvider: StorageServiceProvider,
) {
this.stateEventMap = {
lock: globalStateProvider.get(STATE_LOCK_EVENT),
logout: globalStateProvider.get(STATE_LOGOUT_EVENT),
};
}
async handleEvent(event: ClearEvent, userId: UserId) {
let tickets = await firstValueFrom(this.stateEventMap[event].state$);
tickets ??= [];
const failures: string[] = [];
for (const ticket of tickets) {
try {
const [, service] = this.storageServiceProvider.get(
ticket.location,
{}, // The storage location is already the computed storage location for this client
);
const ticketStorageKey = this.storageKeyFor(userId, ticket);
// Evaluate current value so we can avoid writing to state if we don't need to
const currentValue = await service.get(ticketStorageKey);
if (currentValue != null) {
await service.remove(ticketStorageKey);
}
} catch (err: unknown) {
let errorMessage = "Unknown Error";
if (typeof err === "object" && "message" in err && typeof err.message === "string") {
errorMessage = err.message;
}
failures.push(
`${errorMessage} in ${ticket.state} > ${ticket.key} located ${ticket.location}`,
);
}
}
if (failures.length > 0) {
// Throw aggregated error
throw new Error(
`One or more errors occurred while handling event '${event}' for user ${userId}.\n${failures.join("\n")}`,
);
}
}
private storageKeyFor(userId: UserId, ticket: StateEventInfo) {
const userKey = new UserKeyDefinition<unknown>(
new StateDefinition(ticket.state, ticket.location as unknown as StorageLocation),
ticket.key,
{
deserializer: (v) => v,
clearOn: [],
},
);
return userKey.buildKey(userId);
}
export abstract class StateEventRunnerService {
abstract handleEvent(event: ClearEvent, userId: UserId): Promise<void>;
}

View File

@@ -2,27 +2,8 @@
// @ts-strict-ignore
import { Observable } from "rxjs";
export const DEFAULT_OPTIONS = {
shouldUpdate: () => true,
combineLatestWith: null as Observable<unknown>,
msTimeout: 1000,
export type StateUpdateOptions<T, TCombine> = {
readonly shouldUpdate: (state: T, dependency: TCombine) => boolean;
readonly combineLatestWith: Observable<TCombine> | null;
readonly msTimeout: number;
};
type DefinitelyTypedDefault<T, TCombine> = Omit<
typeof DEFAULT_OPTIONS,
"shouldUpdate" | "combineLatestWith"
> & {
shouldUpdate: (state: T, dependency: TCombine) => boolean;
combineLatestWith?: Observable<TCombine>;
};
export type StateUpdateOptions<T, TCombine> = Partial<DefinitelyTypedDefault<T, TCombine>>;
export function populateOptionsWithDefault<T, TCombine>(
options: StateUpdateOptions<T, TCombine>,
): StateUpdateOptions<T, TCombine> {
return {
...(DEFAULT_OPTIONS as StateUpdateOptions<T, TCombine>),
...options,
};
}

View File

@@ -39,7 +39,7 @@ export interface ActiveUserState<T> extends UserState<T> {
*/
readonly update: <TCombine>(
configureState: (state: T | null, dependencies: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>,
options?: Partial<StateUpdateOptions<T, TCombine>>,
) => Promise<[UserId, T | null]>;
}
@@ -59,6 +59,6 @@ export interface SingleUserState<T> extends UserState<T> {
*/
readonly update: <TCombine>(
configureState: (state: T | null, dependencies: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>,
options?: Partial<StateUpdateOptions<T, TCombine>>,
) => Promise<T | null>;
}

View File

@@ -1,107 +0,0 @@
import { firstValueFrom } from "rxjs";
import { StorageService } from "@bitwarden/storage-core";
import { UserId } from "@bitwarden/user-core";
import { ActiveUserAccessor } from "../core";
import { GlobalState } from "./global-state";
import { RequiredUserId, StateService } from "./state.service";
const keys = {
global: "global",
};
const partialKeys = {
userAutoKey: "_user_auto",
userBiometricKey: "_user_biometric",
};
const DDG_SHARED_KEY = "DuckDuckGoSharedKey";
export class DefaultStateService implements StateService {
constructor(
private readonly storageService: StorageService,
private readonly secureStorageService: StorageService,
private readonly activeUserAccessor: ActiveUserAccessor,
) {}
async clean(options: RequiredUserId): Promise<void> {
await this.setUserKeyAutoUnlock(null, options);
await this.clearUserKeyBiometric(options.userId);
}
/**
* user key when using the "never" option of vault timeout
*/
async getUserKeyAutoUnlock(options: RequiredUserId): Promise<string | null> {
if (options.userId == null) {
return null;
}
return await this.secureStorageService.get<string>(
`${options.userId}${partialKeys.userAutoKey}`,
{
userId: options.userId,
keySuffix: "auto",
},
);
}
/**
* user key when using the "never" option of vault timeout
*/
async setUserKeyAutoUnlock(value: string | null, options: RequiredUserId): Promise<void> {
if (options.userId == null) {
return;
}
await this.saveSecureStorageKey(partialKeys.userAutoKey, value, options.userId, "auto");
}
private async clearUserKeyBiometric(userId: UserId): Promise<void> {
if (userId == null) {
return;
}
await this.saveSecureStorageKey(partialKeys.userBiometricKey, null, userId, "biometric");
}
async getDuckDuckGoSharedKey(): Promise<string | null> {
const userId = await this.getActiveUserIdFromStorage();
if (userId == null) {
return null;
}
return await this.secureStorageService.get<string>(DDG_SHARED_KEY);
}
async setDuckDuckGoSharedKey(value: string): Promise<void> {
const userId = await this.getActiveUserIdFromStorage();
if (userId == null) {
return;
}
value == null
? await this.secureStorageService.remove(DDG_SHARED_KEY)
: await this.secureStorageService.save(DDG_SHARED_KEY, value);
}
async setEnableDuckDuckGoBrowserIntegration(value: boolean): Promise<void> {
const globals = (await this.storageService.get<GlobalState>(keys.global)) ?? new GlobalState();
globals.enableDuckDuckGoBrowserIntegration = value;
await this.storageService.save(keys.global, globals);
}
private async getActiveUserIdFromStorage(): Promise<UserId | null> {
return await firstValueFrom(this.activeUserAccessor.activeUserId$);
}
private async saveSecureStorageKey(
key: string,
value: string | null,
userId: UserId,
keySuffix: string,
) {
return value == null
? await this.secureStorageService.remove(`${userId}${key}`, { keySuffix: keySuffix })
: await this.secureStorageService.save(`${userId}${key}`, value, {
keySuffix: keySuffix,
});
}
}

View File

@@ -1,3 +0,0 @@
export class GlobalState {
enableDuckDuckGoBrowserIntegration?: boolean;
}

View File

@@ -1,2 +1 @@
export { StateService } from "./state.service";
export { DefaultStateService } from "./default-state.service";
export { StateService, RequiredUserId } from "./state.service";