1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-28 12:45:45 +01:00

ActiveUserState Update should return the userId of the impacted user. (#7869)

This allows us to ensure that linked updates all go to the same user without risking active account changes in the middle of an operation.
This commit is contained in:
Matt Gibson 2024-02-08 14:54:15 -05:00 committed by GitHub
parent 78730ff18a
commit 4c051f8d7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 63 additions and 31 deletions

View File

@ -147,11 +147,15 @@ export class FakeStateProvider implements StateProvider {
return this.getActive<T>(keyDefinition).state$; return this.getActive<T>(keyDefinition).state$;
} }
async setUserState<T>(keyDefinition: KeyDefinition<T>, value: T, userId?: UserId): Promise<void> { async setUserState<T>(
keyDefinition: KeyDefinition<T>,
value: T,
userId?: UserId,
): Promise<[UserId, T]> {
if (userId) { if (userId) {
await this.getUser(userId, keyDefinition).update(() => value); return [userId, await this.getUser(userId, keyDefinition).update(() => value)];
} else { } else {
await this.getActive(keyDefinition).update(() => value); return await this.getActive(keyDefinition).update(() => value);
} }
} }

View File

@ -170,7 +170,7 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
async update<TCombine>( async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T, configureState: (state: T, dependency: TCombine) => T,
options?: StateUpdateOptions<T, TCombine>, options?: StateUpdateOptions<T, TCombine>,
): Promise<T> { ): Promise<[UserId, T]> {
options = populateOptionsWithDefault(options); options = populateOptionsWithDefault(options);
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout))); const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
const combinedDependencies = const combinedDependencies =
@ -178,12 +178,12 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null; : null;
if (!options.shouldUpdate(current, combinedDependencies)) { if (!options.shouldUpdate(current, combinedDependencies)) {
return current; return [this.userId, current];
} }
const newState = configureState(current, combinedDependencies); const newState = configureState(current, combinedDependencies);
this.stateSubject.next([this.userId, newState]); this.stateSubject.next([this.userId, newState]);
this.nextMock([this.userId, newState]); this.nextMock([this.userId, newState]);
return newState; return [this.userId, newState];
} }
updateMock = this.update as jest.MockedFunction<typeof this.update>; updateMock = this.update as jest.MockedFunction<typeof this.update>;

View File

@ -266,12 +266,13 @@ describe("DefaultActiveUserState", () => {
}); });
it("should save on update", async () => { it("should save on update", async () => {
const result = await userState.update((state, dependencies) => { const [setUserId, result] = await userState.update((state, dependencies) => {
return newData; return newData;
}); });
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1); expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
expect(result).toEqual(newData); expect(result).toEqual(newData);
expect(setUserId).toEqual("00000000-0000-1000-a000-000000000001");
}); });
it("should emit once per update", async () => { it("should emit once per update", async () => {
@ -316,7 +317,7 @@ describe("DefaultActiveUserState", () => {
const emissions = trackEmissions(userState.state$); const emissions = trackEmissions(userState.state$);
await awaitAsync(); // Need to await for the initial value to be emitted await awaitAsync(); // Need to await for the initial value to be emitted
const result = await userState.update( const [userIdResult, result] = await userState.update(
(state, dependencies) => { (state, dependencies) => {
return newData; return newData;
}, },
@ -328,6 +329,7 @@ describe("DefaultActiveUserState", () => {
await awaitAsync(); await awaitAsync();
expect(diskStorageService.mock.save).not.toHaveBeenCalled(); expect(diskStorageService.mock.save).not.toHaveBeenCalled();
expect(userIdResult).toEqual("00000000-0000-1000-a000-000000000001");
expect(result).toBeNull(); expect(result).toBeNull();
expect(emissions).toEqual([null]); expect(emissions).toEqual([null]);
}); });
@ -422,12 +424,13 @@ describe("DefaultActiveUserState", () => {
await originalSave(key, obj); await originalSave(key, obj);
}); });
const val = await userState.update(() => { const [userIdResult, val] = await userState.update(() => {
return newData; return newData;
}); });
await awaitAsync(10); await awaitAsync(10);
expect(userIdResult).toEqual(userId);
expect(val).toEqual(newData); expect(val).toEqual(newData);
expect(emissions).toEqual([initialData, newData]); expect(emissions).toEqual([initialData, newData]);
expect(emissions2).toEqual([initialData, newData]); expect(emissions2).toEqual([initialData, newData]);
@ -447,7 +450,7 @@ describe("DefaultActiveUserState", () => {
expect(emissions).toEqual([initialData]); expect(emissions).toEqual([initialData]);
let emissions2: TestState[]; let emissions2: TestState[];
const val = await userState.update( const [userIdResult, val] = await userState.update(
(state) => { (state) => {
return newData; return newData;
}, },
@ -461,6 +464,7 @@ describe("DefaultActiveUserState", () => {
await awaitAsync(); await awaitAsync();
expect(userIdResult).toEqual(userId);
expect(val).toEqual(initialData); expect(val).toEqual(initialData);
expect(emissions).toEqual([initialData]); expect(emissions).toEqual([initialData]);
@ -497,10 +501,11 @@ describe("DefaultActiveUserState", () => {
test("updates with FAKE_DEFAULT initial value should resolve correctly", async () => { test("updates with FAKE_DEFAULT initial value should resolve correctly", async () => {
expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(0); expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(0);
const val = await userState.update((state) => { const [userIdResult, val] = await userState.update((state) => {
return newData; return newData;
}); });
expect(userIdResult).toEqual(userId);
expect(val).toEqual(newData); expect(val).toEqual(newData);
const call = diskStorageService.mock.save.mock.calls[0]; const call = diskStorageService.mock.save.mock.calls[0];
expect(call[0]).toEqual(`user_${userId}_fake_fake`); expect(call[0]).toEqual(`user_${userId}_fake_fake`);

View File

@ -31,7 +31,7 @@ const FAKE = Symbol("fake");
export class DefaultActiveUserState<T> implements ActiveUserState<T> { export class DefaultActiveUserState<T> implements ActiveUserState<T> {
[activeMarker]: true; [activeMarker]: true;
private updatePromise: Promise<T> | null = null; private updatePromise: Promise<[UserId, T]> | null = null;
private activeUserId$: Observable<UserId | null>; private activeUserId$: Observable<UserId | null>;
@ -120,15 +120,15 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
async update<TCombine>( async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T, configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine> = {}, options: StateUpdateOptions<T, TCombine> = {},
): Promise<T> { ): Promise<[UserId, T]> {
options = populateOptionsWithDefault(options); options = populateOptionsWithDefault(options);
try { try {
if (this.updatePromise != null) { if (this.updatePromise != null) {
await this.updatePromise; await this.updatePromise;
} }
this.updatePromise = this.internalUpdate(configureState, options); this.updatePromise = this.internalUpdate(configureState, options);
const newState = await this.updatePromise; const [userId, newState] = await this.updatePromise;
return newState; return [userId, newState];
} finally { } finally {
this.updatePromise = null; this.updatePromise = null;
} }
@ -137,20 +137,20 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
private async internalUpdate<TCombine>( private async internalUpdate<TCombine>(
configureState: (state: T, dependency: TCombine) => T, configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine>, options: StateUpdateOptions<T, TCombine>,
) { ): Promise<[UserId, T]> {
const [key, currentState] = await this.getStateForUpdate(); const [userId, key, currentState] = await this.getStateForUpdate();
const combinedDependencies = const combinedDependencies =
options.combineLatestWith != null options.combineLatestWith != null
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null; : null;
if (!options.shouldUpdate(currentState, combinedDependencies)) { if (!options.shouldUpdate(currentState, combinedDependencies)) {
return currentState; return [userId, currentState];
} }
const newState = configureState(currentState, combinedDependencies); const newState = configureState(currentState, combinedDependencies);
await this.saveToStorage(key, newState); await this.saveToStorage(key, newState);
return newState; return [userId, newState];
} }
/** For use in update methods, does not wait for update to complete before yielding state. /** For use in update methods, does not wait for update to complete before yielding state.
@ -170,6 +170,7 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
} }
const fullKey = userKeyBuilder(userId, this.keyDefinition); const fullKey = userKeyBuilder(userId, this.keyDefinition);
return [ return [
userId,
fullKey, fullKey,
await getStoredValue(fullKey, this.chosenStorageLocation, this.keyDefinition.deserializer), await getStoredValue(fullKey, this.chosenStorageLocation, this.keyDefinition.deserializer),
] as const; ] as const;

View File

@ -32,11 +32,15 @@ export class DefaultStateProvider implements StateProvider {
} }
} }
async setUserState<T>(keyDefinition: KeyDefinition<T>, value: T, userId?: UserId): Promise<void> { async setUserState<T>(
keyDefinition: KeyDefinition<T>,
value: T,
userId?: UserId,
): Promise<[UserId, T]> {
if (userId) { if (userId) {
await this.getUser<T>(userId, keyDefinition).update(() => value); return [userId, await this.getUser<T>(userId, keyDefinition).update(() => value)];
} else { } else {
await this.getActive<T>(keyDefinition).update(() => value); return await this.getActive<T>(keyDefinition).update(() => value);
} }
} }

View File

@ -36,7 +36,11 @@ export abstract class StateProvider {
* @param value - The value to set the state to. * @param value - The value to set the state to.
* @param userId - The userId for which you want to set the state for. If not provided, the state for the currently active user will be set. * @param userId - The userId for which you want to set the state for. If not provided, the state for the currently active user will be set.
*/ */
setUserState: <T>(keyDefinition: KeyDefinition<T>, value: T, userId?: UserId) => Promise<void>; setUserState: <T>(
keyDefinition: KeyDefinition<T>,
value: T,
userId?: UserId,
) => Promise<[UserId, T]>;
/** @see{@link ActiveUserStateProvider.get} */ /** @see{@link ActiveUserStateProvider.get} */
getActive: <T>(keyDefinition: KeyDefinition<T>) => ActiveUserState<T>; getActive: <T>(keyDefinition: KeyDefinition<T>) => ActiveUserState<T>;
/** @see{@link SingleUserStateProvider.get} */ /** @see{@link SingleUserStateProvider.get} */

View File

@ -19,6 +19,28 @@ export interface UserState<T> {
* Emits a stream of data alongside the user id the data corresponds to. * Emits a stream of data alongside the user id the data corresponds to.
*/ */
readonly combinedState$: Observable<CombinedState<T>>; readonly combinedState$: Observable<CombinedState<T>>;
}
export const activeMarker: unique symbol = Symbol("active");
export interface ActiveUserState<T> extends UserState<T> {
readonly [activeMarker]: true;
/**
* Updates backing stores for the active user.
* @param configureState function that takes the current state and returns the new state
* @param options Defaults to @see {module:state-update-options#DEFAULT_OPTIONS}
* @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true
* @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null
* @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set.
* @returns The new state
*/
readonly update: <TCombine>(
configureState: (state: T, dependencies: TCombine) => T,
options?: StateUpdateOptions<T, TCombine>,
) => Promise<[UserId, T]>;
}
export interface SingleUserState<T> extends UserState<T> {
readonly userId: UserId;
/** /**
* Updates backing stores for the active user. * Updates backing stores for the active user.
@ -35,11 +57,3 @@ export interface UserState<T> {
options?: StateUpdateOptions<T, TCombine>, options?: StateUpdateOptions<T, TCombine>,
) => Promise<T>; ) => Promise<T>;
} }
export const activeMarker: unique symbol = Symbol("active");
export interface ActiveUserState<T> extends UserState<T> {
readonly [activeMarker]: true;
}
export interface SingleUserState<T> extends UserState<T> {
readonly userId: UserId;
}