mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
refactor: introduce @bitwarden/state and other common libs (#15772)
* refactor: introduce @bitwarden/serialization * refactor: introduce @bitwarden/guid * refactor: introduce @bitwaren/client-type * refactor: introduce @bitwarden/core-test-utils * refactor: introduce @bitwarden/state and @bitwarden/state-test-utils Creates initial project structure for centralized application state management. Part of modularization effort to extract state code from common. * Added state provider documentation to README. * Changed callouts to Github format. * Fixed linting on file name. * Forced git to accept rename --------- Co-authored-by: Todd Martin <tmartin@bitwarden.com>
This commit is contained in:
@@ -1,342 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, map, Observable, of, switchMap, take } from "rxjs";
|
||||
|
||||
import {
|
||||
GlobalState,
|
||||
GlobalStateProvider,
|
||||
KeyDefinition,
|
||||
ActiveUserState,
|
||||
SingleUserState,
|
||||
SingleUserStateProvider,
|
||||
StateProvider,
|
||||
ActiveUserStateProvider,
|
||||
DerivedState,
|
||||
DeriveDefinition,
|
||||
DerivedStateProvider,
|
||||
UserKeyDefinition,
|
||||
ActiveUserAccessor,
|
||||
} from "../src/platform/state";
|
||||
import { UserId } from "../src/types/guid";
|
||||
import { DerivedStateDependencies } from "../src/types/state";
|
||||
|
||||
import {
|
||||
FakeActiveUserState,
|
||||
FakeDerivedState,
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
} from "./fake-state";
|
||||
|
||||
export interface MinimalAccountService {
|
||||
activeUserId: UserId | null;
|
||||
activeAccount$: Observable<{ id: UserId } | null>;
|
||||
}
|
||||
|
||||
export class FakeActiveUserAccessor implements MinimalAccountService, ActiveUserAccessor {
|
||||
private _subject: BehaviorSubject<UserId | null>;
|
||||
|
||||
constructor(startingUser: UserId | null) {
|
||||
this._subject = new BehaviorSubject(startingUser);
|
||||
this.activeAccount$ = this._subject
|
||||
.asObservable()
|
||||
.pipe(map((id) => (id != null ? { id } : null)));
|
||||
this.activeUserId$ = this._subject.asObservable();
|
||||
}
|
||||
|
||||
get activeUserId(): UserId {
|
||||
return this._subject.value;
|
||||
}
|
||||
|
||||
activeUserId$: Observable<UserId>;
|
||||
|
||||
activeAccount$: Observable<{ id: UserId }>;
|
||||
|
||||
switch(user: UserId | null) {
|
||||
this._subject.next(user);
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeGlobalStateProvider implements GlobalStateProvider {
|
||||
mock = mock<GlobalStateProvider>();
|
||||
establishedMocks: Map<string, FakeGlobalState<unknown>> = new Map();
|
||||
states: Map<string, GlobalState<unknown>> = new Map();
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
this.mock.get(keyDefinition);
|
||||
const cacheKey = this.cacheKey(keyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
let fake: FakeGlobalState<T>;
|
||||
// Look for established mock
|
||||
if (this.establishedMocks.has(keyDefinition.key)) {
|
||||
fake = this.establishedMocks.get(keyDefinition.key) as FakeGlobalState<T>;
|
||||
} else {
|
||||
fake = new FakeGlobalState<T>();
|
||||
}
|
||||
fake.keyDefinition = keyDefinition;
|
||||
result = fake;
|
||||
this.states.set(cacheKey, result);
|
||||
|
||||
result = new FakeGlobalState<T>();
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as GlobalState<T>;
|
||||
}
|
||||
|
||||
private cacheKey(keyDefinition: KeyDefinition<unknown>) {
|
||||
return `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
|
||||
}
|
||||
|
||||
getFake<T>(keyDefinition: KeyDefinition<T>): FakeGlobalState<T> {
|
||||
return this.get(keyDefinition) as FakeGlobalState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(keyDefinition: KeyDefinition<T>, initialValue?: T): FakeGlobalState<T> {
|
||||
const cacheKey = this.cacheKey(keyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, new FakeGlobalState<T>(initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeGlobalState<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSingleUserStateProvider implements SingleUserStateProvider {
|
||||
mock = mock<SingleUserStateProvider>();
|
||||
states: Map<string, SingleUserState<unknown>> = new Map();
|
||||
|
||||
constructor(
|
||||
readonly updateSyncCallback?: (
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newValue: unknown,
|
||||
) => Promise<void>,
|
||||
) {}
|
||||
|
||||
get<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
|
||||
this.mock.get(userId, userKeyDefinition);
|
||||
const cacheKey = this.cacheKey(userId, userKeyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
result = this.buildFakeState(userId, userKeyDefinition);
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as SingleUserState<T>;
|
||||
}
|
||||
|
||||
getFake<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
{ allowInit }: { allowInit: boolean } = { allowInit: true },
|
||||
): FakeSingleUserState<T> {
|
||||
if (!allowInit && this.states.get(this.cacheKey(userId, userKeyDefinition)) == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.get(userId, userKeyDefinition) as FakeSingleUserState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
initialValue?: T,
|
||||
): FakeSingleUserState<T> {
|
||||
const cacheKey = this.cacheKey(userId, userKeyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, this.buildFakeState(userId, userKeyDefinition, initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeSingleUserState<T>;
|
||||
}
|
||||
|
||||
private buildFakeState<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
initialValue?: T,
|
||||
) {
|
||||
const state = new FakeSingleUserState(userId, initialValue, async (...args) => {
|
||||
await this.updateSyncCallback?.(userKeyDefinition, ...args);
|
||||
});
|
||||
state.keyDefinition = userKeyDefinition;
|
||||
return state;
|
||||
}
|
||||
|
||||
private cacheKey(userId: UserId, userKeyDefinition: UserKeyDefinition<unknown>) {
|
||||
return `${userKeyDefinitionCacheKey(userKeyDefinition)}_${userId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
|
||||
activeUserId$: Observable<UserId | null>;
|
||||
states: Map<string, FakeActiveUserState<unknown>> = new Map();
|
||||
|
||||
constructor(
|
||||
public accountServiceAccessor: MinimalAccountService,
|
||||
readonly updateSyncCallback?: (
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newValue: unknown,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
this.activeUserId$ = accountServiceAccessor.activeAccount$.pipe(map((a) => a?.id));
|
||||
}
|
||||
|
||||
get<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
|
||||
const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
result = this.buildFakeState(userKeyDefinition);
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as ActiveUserState<T>;
|
||||
}
|
||||
|
||||
getFake<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
{ allowInit }: { allowInit: boolean } = { allowInit: true },
|
||||
): FakeActiveUserState<T> {
|
||||
if (!allowInit && this.states.get(userKeyDefinitionCacheKey(userKeyDefinition)) == null) {
|
||||
return null;
|
||||
}
|
||||
return this.get(userKeyDefinition) as FakeActiveUserState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(userKeyDefinition: UserKeyDefinition<T>, initialValue?: T): FakeActiveUserState<T> {
|
||||
const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, this.buildFakeState(userKeyDefinition, initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeActiveUserState<T>;
|
||||
}
|
||||
|
||||
private buildFakeState<T>(userKeyDefinition: UserKeyDefinition<T>, initialValue?: T) {
|
||||
const state = new FakeActiveUserState<T>(
|
||||
this.accountServiceAccessor,
|
||||
initialValue,
|
||||
async (...args) => {
|
||||
await this.updateSyncCallback?.(userKeyDefinition, ...args);
|
||||
},
|
||||
);
|
||||
state.keyDefinition = userKeyDefinition;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function userKeyDefinitionCacheKey(userKeyDefinition: UserKeyDefinition<unknown>) {
|
||||
return `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`;
|
||||
}
|
||||
|
||||
export class FakeStateProvider implements StateProvider {
|
||||
mock = mock<StateProvider>();
|
||||
getUserState$<T>(userKeyDefinition: UserKeyDefinition<T>, userId?: UserId): Observable<T> {
|
||||
this.mock.getUserState$(userKeyDefinition, userId);
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
return this.getActive(userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
getUserStateOrDefault$<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
config: { userId: UserId | undefined; defaultValue?: T },
|
||||
): Observable<T> {
|
||||
const { userId, defaultValue = null } = config;
|
||||
this.mock.getUserStateOrDefault$(userKeyDefinition, config);
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
return this.activeUserId$.pipe(
|
||||
take(1),
|
||||
switchMap((userId) =>
|
||||
userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async setUserState<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T | null]> {
|
||||
await this.mock.setUserState(userKeyDefinition, value, userId);
|
||||
if (userId) {
|
||||
return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)];
|
||||
} else {
|
||||
return await this.getActive(userKeyDefinition).update(() => value);
|
||||
}
|
||||
}
|
||||
|
||||
getActive<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
|
||||
return this.activeUser.get(userKeyDefinition);
|
||||
}
|
||||
|
||||
getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
return this.global.get(keyDefinition);
|
||||
}
|
||||
|
||||
getUser<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
|
||||
return this.singleUser.get(userId, userKeyDefinition);
|
||||
}
|
||||
|
||||
getDerived<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<unknown, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
return this.derived.get(parentState$, deriveDefinition, dependencies);
|
||||
}
|
||||
|
||||
constructor(private activeAccountAccessor: MinimalAccountService) {}
|
||||
|
||||
private distributeSingleUserUpdate(
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newState: unknown,
|
||||
) {
|
||||
if (this.activeUser.accountServiceAccessor.activeUserId === userId) {
|
||||
const state = this.activeUser.getFake(key, { allowInit: false });
|
||||
state?.nextState(newState, { syncValue: false });
|
||||
}
|
||||
}
|
||||
|
||||
private distributeActiveUserUpdate(
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newState: unknown,
|
||||
) {
|
||||
this.singleUser
|
||||
.getFake(userId, key, { allowInit: false })
|
||||
?.nextState(newState, { syncValue: false });
|
||||
}
|
||||
|
||||
global: FakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
singleUser: FakeSingleUserStateProvider = new FakeSingleUserStateProvider(
|
||||
this.distributeSingleUserUpdate.bind(this),
|
||||
);
|
||||
activeUser: FakeActiveUserStateProvider = new FakeActiveUserStateProvider(
|
||||
this.activeAccountAccessor,
|
||||
this.distributeActiveUserUpdate.bind(this),
|
||||
);
|
||||
derived: FakeDerivedStateProvider = new FakeDerivedStateProvider();
|
||||
activeUserId$: Observable<UserId> = this.activeUser.activeUserId$;
|
||||
}
|
||||
|
||||
export class FakeDerivedStateProvider implements DerivedStateProvider {
|
||||
states: Map<string, DerivedState<unknown>> = new Map();
|
||||
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState<TTo>;
|
||||
|
||||
if (result == null) {
|
||||
result = new FakeDerivedState(parentState$, deriveDefinition, dependencies);
|
||||
this.states.set(deriveDefinition.buildCacheKey(), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
export {
|
||||
MinimalAccountService,
|
||||
FakeActiveUserAccessor,
|
||||
FakeGlobalStateProvider,
|
||||
FakeSingleUserStateProvider,
|
||||
FakeActiveUserStateProvider,
|
||||
FakeStateProvider,
|
||||
FakeDerivedStateProvider,
|
||||
} from "@bitwarden/state-test-utils";
|
||||
|
||||
@@ -1,275 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, ReplaySubject, concatMap, filter, firstValueFrom, map, timeout } from "rxjs";
|
||||
|
||||
import {
|
||||
DerivedState,
|
||||
GlobalState,
|
||||
SingleUserState,
|
||||
ActiveUserState,
|
||||
KeyDefinition,
|
||||
DeriveDefinition,
|
||||
UserKeyDefinition,
|
||||
} from "../src/platform/state";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class
|
||||
import { StateUpdateOptions } from "../src/platform/state/state-update-options";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class
|
||||
import { CombinedState, activeMarker } from "../src/platform/state/user-state";
|
||||
import { UserId } from "../src/types/guid";
|
||||
import { DerivedStateDependencies } from "../src/types/state";
|
||||
|
||||
import { MinimalAccountService } from "./fake-state-provider";
|
||||
|
||||
const DEFAULT_TEST_OPTIONS: StateUpdateOptions<any, any> = {
|
||||
shouldUpdate: () => true,
|
||||
combineLatestWith: null,
|
||||
msTimeout: 10,
|
||||
};
|
||||
|
||||
function populateOptionsWithDefault(
|
||||
options: StateUpdateOptions<any, any>,
|
||||
): StateUpdateOptions<any, any> {
|
||||
return {
|
||||
...DEFAULT_TEST_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
export class FakeGlobalState<T> implements GlobalState<T> {
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<T>(1);
|
||||
|
||||
constructor(initialValue?: T) {
|
||||
this.stateSubject.next(initialValue ?? null);
|
||||
}
|
||||
|
||||
nextState(state: T) {
|
||||
this.stateSubject.next(state);
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
if (this.stateSubject["_buffer"].length == 0) {
|
||||
// throw a more helpful not initialized error
|
||||
throw new Error(
|
||||
"You must initialize the state with a value before calling update. Try calling `stateSubject.next(initialState)` before calling update",
|
||||
);
|
||||
}
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(100)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return current;
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.stateSubject.next(newState);
|
||||
this.nextMock(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [T]>();
|
||||
|
||||
get state$() {
|
||||
return this.stateSubject.asObservable();
|
||||
}
|
||||
|
||||
private _keyDefinition: KeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: KeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSingleUserState<T> implements SingleUserState<T> {
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<{
|
||||
syncValue: boolean;
|
||||
combinedState: CombinedState<T>;
|
||||
}>(1);
|
||||
|
||||
state$: Observable<T>;
|
||||
combinedState$: Observable<CombinedState<T>>;
|
||||
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
initialValue?: T,
|
||||
updateSyncCallback?: (userId: UserId, newValue: T) => Promise<void>,
|
||||
) {
|
||||
// Inform the state provider of updates to keep active user states in sync
|
||||
this.stateSubject
|
||||
.pipe(
|
||||
filter((next) => next.syncValue),
|
||||
concatMap(async ({ combinedState }) => {
|
||||
await updateSyncCallback?.(...combinedState);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
this.nextState(initialValue ?? null, { syncValue: initialValue != null });
|
||||
|
||||
this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState));
|
||||
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
|
||||
}
|
||||
|
||||
nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
this.stateSubject.next({
|
||||
syncValue,
|
||||
combinedState: [this.userId, state],
|
||||
});
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T | null> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return current;
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.nextState(newState);
|
||||
this.nextMock(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [T]>();
|
||||
private _keyDefinition: UserKeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: UserKeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
export class FakeActiveUserState<T> implements ActiveUserState<T> {
|
||||
[activeMarker]: true;
|
||||
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<{
|
||||
syncValue: boolean;
|
||||
combinedState: CombinedState<T>;
|
||||
}>(1);
|
||||
|
||||
state$: Observable<T>;
|
||||
combinedState$: Observable<CombinedState<T>>;
|
||||
|
||||
constructor(
|
||||
private activeAccountAccessor: MinimalAccountService,
|
||||
initialValue?: T,
|
||||
updateSyncCallback?: (userId: UserId, newValue: T) => Promise<void>,
|
||||
) {
|
||||
// Inform the state provider of updates to keep single user states in sync
|
||||
this.stateSubject.pipe(
|
||||
filter((next) => next.syncValue),
|
||||
concatMap(async ({ combinedState }) => {
|
||||
await updateSyncCallback?.(...combinedState);
|
||||
}),
|
||||
);
|
||||
this.nextState(initialValue ?? null, { syncValue: initialValue != null });
|
||||
|
||||
this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState));
|
||||
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
|
||||
}
|
||||
|
||||
nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
this.stateSubject.next({
|
||||
syncValue,
|
||||
combinedState: [this.activeAccountAccessor.activeUserId, state],
|
||||
});
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<[UserId, T | null]> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return [this.activeAccountAccessor.activeUserId, current];
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.nextState(newState);
|
||||
this.nextMock([this.activeAccountAccessor.activeUserId, newState]);
|
||||
return [this.activeAccountAccessor.activeUserId, newState];
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [[UserId, T]]>();
|
||||
|
||||
private _keyDefinition: UserKeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: UserKeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>
|
||||
implements DerivedState<TTo>
|
||||
{
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<TTo>(1);
|
||||
|
||||
constructor(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
) {
|
||||
parentState$
|
||||
.pipe(
|
||||
concatMap(async (v) => {
|
||||
const newState = deriveDefinition.derive(v, dependencies);
|
||||
if (newState instanceof Promise) {
|
||||
return newState;
|
||||
}
|
||||
return Promise.resolve(newState);
|
||||
}),
|
||||
)
|
||||
.subscribe((newState) => {
|
||||
this.stateSubject.next(newState);
|
||||
});
|
||||
}
|
||||
|
||||
forceValue(value: TTo): Promise<TTo> {
|
||||
this.stateSubject.next(value);
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
forceValueMock = this.forceValue as jest.MockedFunction<typeof this.forceValue>;
|
||||
|
||||
get state$() {
|
||||
return this.stateSubject.asObservable();
|
||||
}
|
||||
}
|
||||
export {
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
FakeActiveUserState,
|
||||
FakeDerivedState,
|
||||
} from "@bitwarden/state-test-utils";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
|
||||
@@ -78,57 +77,4 @@ export const mockFromSdk = (stub: any) => {
|
||||
return `${stub}_fromSdk`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks the emissions of the given observable.
|
||||
*
|
||||
* Call this function before you expect any emissions and then use code that will cause the observable to emit values,
|
||||
* then assert after all expected emissions have occurred.
|
||||
* @param observable
|
||||
* @returns An array that will be populated with all emissions of the observable.
|
||||
*/
|
||||
export function trackEmissions<T>(observable: Observable<T>): T[] {
|
||||
const emissions: T[] = [];
|
||||
observable.subscribe((value) => {
|
||||
switch (value) {
|
||||
case undefined:
|
||||
case null:
|
||||
emissions.push(value);
|
||||
return;
|
||||
default:
|
||||
// process by type
|
||||
break;
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
emissions.push(value);
|
||||
break;
|
||||
case "symbol":
|
||||
// Cheating types to make symbols work at all
|
||||
emissions.push(value.toString() as T);
|
||||
break;
|
||||
default: {
|
||||
emissions.push(clone(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
return emissions;
|
||||
}
|
||||
|
||||
function clone(value: any): any {
|
||||
if (global.structuredClone != undefined) {
|
||||
return structuredClone(value);
|
||||
} else {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
export async function awaitAsync(ms = 1) {
|
||||
if (ms < 1) {
|
||||
await Promise.resolve();
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
export { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils";
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum ClientType {
|
||||
Web = "web",
|
||||
Browser = "browser",
|
||||
Desktop = "desktop",
|
||||
// Mobile = "mobile",
|
||||
Cli = "cli",
|
||||
// DirectoryConnector = "connector",
|
||||
}
|
||||
export { ClientType } from "@bitwarden/client-type";
|
||||
|
||||
@@ -252,6 +252,7 @@ export class Utils {
|
||||
}
|
||||
|
||||
// ref: http://stackoverflow.com/a/2117523/1090359
|
||||
/** @deprecated Use newGuid from @bitwarden/guid instead */
|
||||
static newGuid(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
@@ -260,8 +261,10 @@ export class Utils {
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Use guidRegex from @bitwarden/guid instead */
|
||||
static guidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/;
|
||||
|
||||
/** @deprecated Use isGuid from @bitwarden/guid instead */
|
||||
static isGuid(id: string) {
|
||||
return RegExp(Utils.guidRegex, "i").test(id);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "@bitwarden/state";
|
||||
|
||||
import { FakeStorageService } from "../../../spec/fake-storage.service";
|
||||
import { ClientType } from "../../enums";
|
||||
import { MigrationHelper } from "../../state-migrations/migration-helper";
|
||||
|
||||
import { MigrationBuilderService } from "./migration-builder.service";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CURRENT_VERSION, currentVersion, MigrationHelper } from "@bitwarden/state";
|
||||
|
||||
import { ClientType } from "../../enums";
|
||||
import { waitForMigrations } from "../../state-migrations";
|
||||
import { CURRENT_VERSION, currentVersion } from "../../state-migrations/migrate";
|
||||
import { MigrationHelper } from "../../state-migrations/migration-helper";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { AbstractStorageService } from "../abstractions/storage.service";
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { DeriveDefinition } from "./derive-definition";
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
import { StateDefinition } from "./state-definition";
|
||||
|
||||
const derive: () => any = () => null;
|
||||
const deserializer: any = (obj: any) => obj;
|
||||
|
||||
const STATE_DEFINITION = new StateDefinition("test", "disk");
|
||||
const TEST_KEY = new KeyDefinition(STATE_DEFINITION, "test", {
|
||||
deserializer,
|
||||
});
|
||||
const TEST_DERIVE = new DeriveDefinition(STATE_DEFINITION, "test", {
|
||||
derive,
|
||||
deserializer,
|
||||
});
|
||||
|
||||
describe("DeriveDefinition", () => {
|
||||
describe("from", () => {
|
||||
it("should create a new DeriveDefinition from a KeyDefinition", () => {
|
||||
const result = DeriveDefinition.from(TEST_KEY, {
|
||||
derive,
|
||||
deserializer,
|
||||
});
|
||||
|
||||
expect(result).toEqual(TEST_DERIVE);
|
||||
});
|
||||
|
||||
it("should create a new DeriveDefinition from a DeriveDefinition", () => {
|
||||
const result = DeriveDefinition.from([TEST_DERIVE, "newDerive"], {
|
||||
derive,
|
||||
deserializer,
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
new DeriveDefinition(STATE_DEFINITION, "newDerive", {
|
||||
derive,
|
||||
deserializer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,196 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DerivedStateDependencies, StorageKey } from "../../types/state";
|
||||
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
import { StateDefinition } from "./state-definition";
|
||||
import { UserKeyDefinition } from "./user-key-definition";
|
||||
|
||||
declare const depShapeMarker: unique symbol;
|
||||
/**
|
||||
* A set of options for customizing the behavior of a {@link DeriveDefinition}
|
||||
*/
|
||||
type DeriveDefinitionOptions<TFrom, TTo, TDeps extends DerivedStateDependencies = never> = {
|
||||
/**
|
||||
* A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable
|
||||
* and the resulting value will be emitted from the derived state observable.
|
||||
*
|
||||
* @param from Populated with the latest emission from the parent state observable.
|
||||
* @param deps Populated with the dependencies passed into the constructor of the derived state.
|
||||
* These are constant for the lifetime of the derived state.
|
||||
* @returns The derived state value or a Promise that resolves to the derived state value.
|
||||
*/
|
||||
derive: (from: TFrom, deps: TDeps) => TTo | Promise<TTo>;
|
||||
/**
|
||||
* A function to use to safely convert your type from json to your expected type.
|
||||
*
|
||||
* **Important:** Your data may be serialized/deserialized at any time and this
|
||||
* callback needs to be able to faithfully re-initialize from the JSON object representation of your type.
|
||||
*
|
||||
* @param jsonValue The JSON object representation of your state.
|
||||
* @returns The fully typed version of your state.
|
||||
*/
|
||||
deserializer: (serialized: Jsonify<TTo>) => TTo;
|
||||
/**
|
||||
* An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies
|
||||
* and the values are the types of the dependencies.
|
||||
*
|
||||
* for example:
|
||||
* ```
|
||||
* {
|
||||
* myService: MyService,
|
||||
* myOtherService: MyOtherService,
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
[depShapeMarker]?: TDeps;
|
||||
/**
|
||||
* The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
* Defaults to 1000ms.
|
||||
*/
|
||||
cleanupDelayMs?: number;
|
||||
/**
|
||||
* Whether or not to clear the derived state when cleanup occurs. Defaults to true.
|
||||
*/
|
||||
clearOnCleanup?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* DeriveDefinitions describe state derived from another observable, the value type of which is given by `TFrom`.
|
||||
*
|
||||
* The StateDefinition is used to describe the domain of the state, and the DeriveDefinition
|
||||
* sub-divides that domain into specific keys. These keys are used to cache data in memory and enables derived state to
|
||||
* be calculated once regardless of multiple execution contexts.
|
||||
*/
|
||||
|
||||
export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies> {
|
||||
/**
|
||||
* Creates a new instance of a DeriveDefinition. Derived state is always stored in memory, so the storage location
|
||||
* defined in @link{StateDefinition} is ignored.
|
||||
*
|
||||
* @param stateDefinition The state definition for which this key belongs to.
|
||||
* @param uniqueDerivationName The name of the key, this should be unique per domain.
|
||||
* @param options A set of options to customize the behavior of {@link DeriveDefinition}.
|
||||
* @param options.derive A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable
|
||||
* and the resulting value will be emitted from the derived state observable.
|
||||
* @param options.cleanupDelayMs The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
* Defaults to 1000ms.
|
||||
* @param options.dependencyShape An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies
|
||||
* and the values are the types of the dependencies.
|
||||
* for example:
|
||||
* ```
|
||||
* {
|
||||
* myService: MyService,
|
||||
* myOtherService: MyOtherService,
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param options.deserializer A function to use to safely convert your type from json to your expected type.
|
||||
* Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize
|
||||
* from the JSON object representation of your type.
|
||||
*/
|
||||
constructor(
|
||||
readonly stateDefinition: StateDefinition,
|
||||
readonly uniqueDerivationName: string,
|
||||
readonly options: DeriveDefinitionOptions<TFrom, TTo, TDeps>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Factory that produces a {@link DeriveDefinition} from a {@link KeyDefinition} or {@link DeriveDefinition} and new name.
|
||||
*
|
||||
* If a `KeyDefinition` is passed in, the returned definition will have the same key as the given key definition, but
|
||||
* will not collide with it in storage, even if they both reside in memory.
|
||||
*
|
||||
* If a `DeriveDefinition` is passed in, the returned definition will instead use the name given in the second position
|
||||
* of the tuple. It is up to you to ensure this is unique within the domain of derived state.
|
||||
*
|
||||
* @param options A set of options to customize the behavior of {@link DeriveDefinition}.
|
||||
* @param options.derive A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable
|
||||
* and the resulting value will be emitted from the derived state observable.
|
||||
* @param options.cleanupDelayMs The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
* Defaults to 1000ms.
|
||||
* @param options.dependencyShape An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies
|
||||
* and the values are the types of the dependencies.
|
||||
* for example:
|
||||
* ```
|
||||
* {
|
||||
* myService: MyService,
|
||||
* myOtherService: MyOtherService,
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param options.deserializer A function to use to safely convert your type from json to your expected type.
|
||||
* Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize
|
||||
* from the JSON object representation of your type.
|
||||
* @param definition
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
static from<TFrom, TTo, TDeps extends DerivedStateDependencies = never>(
|
||||
definition:
|
||||
| KeyDefinition<TFrom>
|
||||
| UserKeyDefinition<TFrom>
|
||||
| [DeriveDefinition<unknown, TFrom, DerivedStateDependencies>, string],
|
||||
options: DeriveDefinitionOptions<TFrom, TTo, TDeps>,
|
||||
) {
|
||||
if (isFromDeriveDefinition(definition)) {
|
||||
return new DeriveDefinition(definition[0].stateDefinition, definition[1], options);
|
||||
} else {
|
||||
return new DeriveDefinition(definition.stateDefinition, definition.key, options);
|
||||
}
|
||||
}
|
||||
|
||||
static fromWithUserId<TKeyDef, TTo, TDeps extends DerivedStateDependencies = never>(
|
||||
definition:
|
||||
| KeyDefinition<TKeyDef>
|
||||
| UserKeyDefinition<TKeyDef>
|
||||
| [DeriveDefinition<unknown, TKeyDef, DerivedStateDependencies>, string],
|
||||
options: DeriveDefinitionOptions<[UserId, TKeyDef], TTo, TDeps>,
|
||||
) {
|
||||
if (isFromDeriveDefinition(definition)) {
|
||||
return new DeriveDefinition(definition[0].stateDefinition, definition[1], options);
|
||||
} else {
|
||||
return new DeriveDefinition(definition.stateDefinition, definition.key, options);
|
||||
}
|
||||
}
|
||||
|
||||
get derive() {
|
||||
return this.options.derive;
|
||||
}
|
||||
|
||||
deserialize(serialized: Jsonify<TTo>): TTo {
|
||||
return this.options.deserializer(serialized);
|
||||
}
|
||||
|
||||
get cleanupDelayMs() {
|
||||
return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000);
|
||||
}
|
||||
|
||||
get clearOnCleanup() {
|
||||
return this.options.clearOnCleanup ?? true;
|
||||
}
|
||||
|
||||
buildCacheKey(): string {
|
||||
return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link StorageKey} that points to the data for the given derived definition.
|
||||
* @returns A key that is ready to be used in a storage service to get data.
|
||||
*/
|
||||
get storageKey(): StorageKey {
|
||||
return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}` as StorageKey;
|
||||
}
|
||||
}
|
||||
|
||||
function isFromDeriveDefinition(
|
||||
definition:
|
||||
| KeyDefinition<unknown>
|
||||
| UserKeyDefinition<unknown>
|
||||
| [DeriveDefinition<unknown, unknown, DerivedStateDependencies>, string],
|
||||
): definition is [DeriveDefinition<unknown, unknown, DerivedStateDependencies>, string] {
|
||||
return Array.isArray(definition);
|
||||
}
|
||||
export { DeriveDefinition } from "@bitwarden/state";
|
||||
|
||||
@@ -1,25 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DerivedStateDependencies } from "../../types/state";
|
||||
|
||||
import { DeriveDefinition } from "./derive-definition";
|
||||
import { DerivedState } from "./derived-state";
|
||||
|
||||
/**
|
||||
* State derived from an observable and a derive function
|
||||
*/
|
||||
export abstract class DerivedStateProvider {
|
||||
/**
|
||||
* Creates a derived state observable from a parent state observable, a deriveDefinition, and the dependencies
|
||||
* required by the deriveDefinition
|
||||
* @param parentState$ The parent state observable
|
||||
* @param deriveDefinition The deriveDefinition that defines conversion from the parent state to the derived state as
|
||||
* well as some memory persistent information.
|
||||
* @param dependencies The dependencies of the derive function
|
||||
*/
|
||||
abstract get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo>;
|
||||
}
|
||||
export { DerivedStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,23 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export type StateConverter<TFrom extends Array<unknown>, TTo> = (...args: TFrom) => TTo;
|
||||
|
||||
/**
|
||||
* State derived from an observable and a converter function
|
||||
*
|
||||
* Derived state is cached and persisted to memory for sychronization across execution contexts.
|
||||
* For clients with multiple execution contexts, the derived state will be executed only once in the background process.
|
||||
*/
|
||||
export interface DerivedState<T> {
|
||||
/**
|
||||
* The derived state observable
|
||||
*/
|
||||
state$: Observable<T>;
|
||||
/**
|
||||
* Forces the derived state to a given value.
|
||||
*
|
||||
* Useful for setting an in-memory value as a side effect of some event, such as emptying state as a result of a lock.
|
||||
* @param value The value to force the derived state to
|
||||
*/
|
||||
forceValue(value: T): Promise<T>;
|
||||
}
|
||||
export { DerivedState } from "@bitwarden/state";
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { record } from "./deserialization-helpers";
|
||||
|
||||
describe("deserialization helpers", () => {
|
||||
describe("record", () => {
|
||||
it("deserializes a record when keys are strings", () => {
|
||||
const deserializer = record((value: number) => value);
|
||||
const input = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
};
|
||||
const output = deserializer(input);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it("deserializes a record when keys are numbers", () => {
|
||||
const deserializer = record((value: number) => value);
|
||||
const input = {
|
||||
1: 1,
|
||||
2: 2,
|
||||
};
|
||||
const output = deserializer(input);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param elementDeserializer
|
||||
* @returns
|
||||
*/
|
||||
export function array<T>(
|
||||
elementDeserializer: (element: Jsonify<T>) => T,
|
||||
): (array: Jsonify<T[]>) => T[] {
|
||||
return (array) => {
|
||||
if (array == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array.map((element) => elementDeserializer(element));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param valueDeserializer
|
||||
*/
|
||||
export function record<T, TKey extends string | number = string>(
|
||||
valueDeserializer: (value: Jsonify<T>) => T,
|
||||
): (record: Jsonify<Record<TKey, T>>) => Record<TKey, T> {
|
||||
return (jsonValue: Jsonify<Record<TKey, T> | null>) => {
|
||||
if (jsonValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: Record<TKey, T> = {} as any;
|
||||
Object.entries(jsonValue).forEach(([key, value]) => {
|
||||
output[key as TKey] = valueDeserializer(value);
|
||||
});
|
||||
return output;
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1 @@
|
||||
import { GlobalState } from "./global-state";
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
|
||||
/**
|
||||
* A provider for getting an implementation of global state scoped to the given key.
|
||||
*/
|
||||
export abstract class GlobalStateProvider {
|
||||
/**
|
||||
* Gets a {@link GlobalState} scoped to the given {@link KeyDefinition}
|
||||
* @param keyDefinition - The {@link KeyDefinition} for which you want the state for.
|
||||
*/
|
||||
abstract get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T>;
|
||||
}
|
||||
export { GlobalStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,30 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { StateUpdateOptions } from "./state-update-options";
|
||||
|
||||
/**
|
||||
* A helper object for interacting with state that is scoped to a specific domain
|
||||
* but is not scoped to a user. This is application wide storage.
|
||||
*/
|
||||
export interface GlobalState<T> {
|
||||
/**
|
||||
* Method for allowing you to manipulate state in an additive way.
|
||||
* @param configureState callback for how you want to manipulate this section of state
|
||||
* @param options Defaults given by @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 A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
update: <TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<T | null>;
|
||||
|
||||
/**
|
||||
* An observable stream of this state, the first emission of this will be the current state on disk
|
||||
* and subsequent updates will be from an update to that state.
|
||||
*/
|
||||
state$: Observable<T | null>;
|
||||
}
|
||||
export { GlobalState } from "@bitwarden/state";
|
||||
|
||||
@@ -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 { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { awaitAsync, trackEmissions } from "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +1 @@
|
||||
// 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 "../../../types/guid";
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
export { DefaultActiveUserState } from "@bitwarden/state";
|
||||
|
||||
@@ -1,53 +1 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
export { DefaultDerivedStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,211 +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 "../../../../spec";
|
||||
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")]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
export { DefaultDerivedState } from "@bitwarden/state";
|
||||
|
||||
@@ -1,46 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
export { DefaultGlobalStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,411 +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 "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
|
||||
import { DefaultGlobalState } from "./default-global-state";
|
||||
|
||||
class TestState {
|
||||
date: Date;
|
||||
|
||||
static fromJSON(jsonState: Jsonify<TestState>) {
|
||||
if (jsonState == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new TestState(), jsonState, {
|
||||
date: new Date(jsonState.date),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const testStateDefinition = new StateDefinition("fake", "disk");
|
||||
const 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
|
||||
// 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(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();
|
||||
|
||||
// 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(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1 @@
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
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);
|
||||
}
|
||||
}
|
||||
export { DefaultGlobalState } from "@bitwarden/state";
|
||||
|
||||
@@ -1,54 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
export { DefaultSingleUserStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,596 +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 "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { Utils } from "../../misc/utils";
|
||||
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 = Utils.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
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1 @@
|
||||
import { Observable, combineLatest, of } from "rxjs";
|
||||
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
export { DefaultSingleUserState } from "@bitwarden/state";
|
||||
|
||||
@@ -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 { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { awaitAsync, trackEmissions } from "../../../../spec";
|
||||
import {
|
||||
FakeActiveUserAccessor,
|
||||
FakeActiveUserStateProvider,
|
||||
FakeDerivedStateProvider,
|
||||
FakeGlobalStateProvider,
|
||||
FakeSingleUserStateProvider,
|
||||
} from "../../../../spec/fake-state-provider";
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +1 @@
|
||||
// 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 "../../../types/guid";
|
||||
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);
|
||||
}
|
||||
export { DefaultStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,37 +1 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
export { InlineDerivedState, InlineDerivedStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { FakeActiveUserAccessor } from "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,137 +1 @@
|
||||
// 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 { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { StorageKey } from "../../../types/state";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
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);
|
||||
}
|
||||
}
|
||||
export { StateBase } from "@bitwarden/state";
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
|
||||
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 () => {
|
||||
// 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
|
||||
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 () => {
|
||||
// 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
|
||||
storageService.save(key, value);
|
||||
|
||||
const result = await getStoredValue(key, storageService, deserializer);
|
||||
|
||||
expect(result).toEqual(value);
|
||||
});
|
||||
|
||||
it("should convert undefined to null", async () => {
|
||||
// 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
|
||||
storageService.save(key, undefined);
|
||||
|
||||
const result = await getStoredValue(key, storageService, deserializer);
|
||||
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1 @@
|
||||
export { DeriveDefinition } from "./derive-definition";
|
||||
export { DerivedStateProvider } from "./derived-state.provider";
|
||||
export { DerivedState } from "./derived-state";
|
||||
export { GlobalState } from "./global-state";
|
||||
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 { StateUpdateOptions } from "./state-update-options";
|
||||
export { UserKeyDefinitionOptions, UserKeyDefinition } from "./user-key-definition";
|
||||
export { StateEventRunnerService } from "./state-event-runner.service";
|
||||
export { ActiveUserAccessor } from "./active-user.accessor";
|
||||
|
||||
export * from "./state-definitions";
|
||||
export * from "@bitwarden/state";
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
import { DebugOptions, KeyDefinition } from "./key-definition";
|
||||
import { StateDefinition } from "./state-definition";
|
||||
|
||||
const fakeStateDefinition = new StateDefinition("fake", "disk");
|
||||
|
||||
type FancyString = Opaque<string, "FancyString">;
|
||||
|
||||
describe("KeyDefinition", () => {
|
||||
describe("constructor", () => {
|
||||
it("throws on undefined deserializer", () => {
|
||||
expect(() => {
|
||||
new KeyDefinition<boolean>(fakeStateDefinition, "fake", {
|
||||
deserializer: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes debug options set to undefined", () => {
|
||||
const keyDefinition = new KeyDefinition(fakeStateDefinition, "fake", {
|
||||
deserializer: (v) => v,
|
||||
debug: undefined,
|
||||
});
|
||||
|
||||
expect(keyDefinition.debug.enableUpdateLogging).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes no debug options", () => {
|
||||
const keyDefinition = new KeyDefinition(fakeStateDefinition, "fake", {
|
||||
deserializer: (v) => v,
|
||||
});
|
||||
|
||||
expect(keyDefinition.debug.enableUpdateLogging).toBe(false);
|
||||
});
|
||||
|
||||
const cases: {
|
||||
debug: DebugOptions | undefined;
|
||||
expectedEnableUpdateLogging: boolean;
|
||||
expectedEnableRetrievalLogging: boolean;
|
||||
}[] = [
|
||||
{
|
||||
debug: undefined,
|
||||
expectedEnableUpdateLogging: false,
|
||||
expectedEnableRetrievalLogging: false,
|
||||
},
|
||||
{
|
||||
debug: {},
|
||||
expectedEnableUpdateLogging: false,
|
||||
expectedEnableRetrievalLogging: false,
|
||||
},
|
||||
{
|
||||
debug: {
|
||||
enableUpdateLogging: false,
|
||||
},
|
||||
expectedEnableUpdateLogging: false,
|
||||
expectedEnableRetrievalLogging: false,
|
||||
},
|
||||
{
|
||||
debug: {
|
||||
enableRetrievalLogging: false,
|
||||
},
|
||||
expectedEnableUpdateLogging: false,
|
||||
expectedEnableRetrievalLogging: false,
|
||||
},
|
||||
{
|
||||
debug: {
|
||||
enableUpdateLogging: true,
|
||||
},
|
||||
expectedEnableUpdateLogging: true,
|
||||
expectedEnableRetrievalLogging: false,
|
||||
},
|
||||
{
|
||||
debug: {
|
||||
enableRetrievalLogging: true,
|
||||
},
|
||||
expectedEnableUpdateLogging: false,
|
||||
expectedEnableRetrievalLogging: true,
|
||||
},
|
||||
{
|
||||
debug: {
|
||||
enableRetrievalLogging: false,
|
||||
enableUpdateLogging: false,
|
||||
},
|
||||
expectedEnableUpdateLogging: false,
|
||||
expectedEnableRetrievalLogging: false,
|
||||
},
|
||||
{
|
||||
debug: {
|
||||
enableRetrievalLogging: true,
|
||||
enableUpdateLogging: true,
|
||||
},
|
||||
expectedEnableUpdateLogging: true,
|
||||
expectedEnableRetrievalLogging: true,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)(
|
||||
"normalizes debug options to correct values when given $debug",
|
||||
({ debug, expectedEnableUpdateLogging, expectedEnableRetrievalLogging }) => {
|
||||
const keyDefinition = new KeyDefinition(fakeStateDefinition, "fake", {
|
||||
deserializer: (v) => v,
|
||||
debug: debug,
|
||||
});
|
||||
|
||||
expect(keyDefinition.debug.enableUpdateLogging).toBe(expectedEnableUpdateLogging);
|
||||
expect(keyDefinition.debug.enableRetrievalLogging).toBe(expectedEnableRetrievalLogging);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("cleanupDelayMs", () => {
|
||||
it("defaults to 1000ms", () => {
|
||||
const keyDefinition = new KeyDefinition<boolean>(fakeStateDefinition, "fake", {
|
||||
deserializer: (value) => value,
|
||||
});
|
||||
|
||||
expect(keyDefinition).toBeTruthy();
|
||||
expect(keyDefinition.cleanupDelayMs).toBe(1000);
|
||||
});
|
||||
|
||||
it("can be overridden", () => {
|
||||
const keyDefinition = new KeyDefinition<boolean>(fakeStateDefinition, "fake", {
|
||||
deserializer: (value) => value,
|
||||
cleanupDelayMs: 500,
|
||||
});
|
||||
|
||||
expect(keyDefinition).toBeTruthy();
|
||||
expect(keyDefinition.cleanupDelayMs).toBe(500);
|
||||
});
|
||||
|
||||
it("throws on negative", () => {
|
||||
expect(
|
||||
() =>
|
||||
new KeyDefinition<boolean>(fakeStateDefinition, "fake", {
|
||||
deserializer: (value) => value,
|
||||
cleanupDelayMs: -1,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("record", () => {
|
||||
it("runs custom deserializer for each record value", () => {
|
||||
const recordDefinition = KeyDefinition.record<boolean>(fakeStateDefinition, "fake", {
|
||||
// Intentionally negate the value for testing
|
||||
deserializer: (value) => !value,
|
||||
});
|
||||
|
||||
expect(recordDefinition).toBeTruthy();
|
||||
expect(recordDefinition.deserializer).toBeTruthy();
|
||||
|
||||
const deserializedValue = recordDefinition.deserializer({
|
||||
test1: false,
|
||||
test2: true,
|
||||
});
|
||||
|
||||
expect(Object.keys(deserializedValue)).toHaveLength(2);
|
||||
|
||||
// Values should have swapped from their initial value
|
||||
expect(deserializedValue["test1"]).toBeTruthy();
|
||||
expect(deserializedValue["test2"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("can handle fancy string type", () => {
|
||||
// This test is more of a test that I got the typescript typing correctly than actually testing any business logic
|
||||
const recordDefinition = KeyDefinition.record<boolean, FancyString>(
|
||||
fakeStateDefinition,
|
||||
"fake",
|
||||
{
|
||||
deserializer: (value) => !value,
|
||||
},
|
||||
);
|
||||
|
||||
const fancyRecord = recordDefinition.deserializer(
|
||||
JSON.parse(`{ "myKey": false, "mySecondKey": true }`),
|
||||
);
|
||||
|
||||
expect(fancyRecord).toBeTruthy();
|
||||
expect(Object.keys(fancyRecord)).toHaveLength(2);
|
||||
expect(fancyRecord["myKey" as FancyString]).toBeTruthy();
|
||||
expect(fancyRecord["mySecondKey" as FancyString]).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("array", () => {
|
||||
it("run custom deserializer for each array element", () => {
|
||||
const arrayDefinition = KeyDefinition.array<boolean>(fakeStateDefinition, "fake", {
|
||||
deserializer: (value) => !value,
|
||||
});
|
||||
|
||||
expect(arrayDefinition).toBeTruthy();
|
||||
expect(arrayDefinition.deserializer).toBeTruthy();
|
||||
|
||||
// NOTE: `as any` is here until we migrate to Nx: https://bitwarden.atlassian.net/browse/PM-6493
|
||||
const deserializedValue = arrayDefinition.deserializer([false, true] as any);
|
||||
|
||||
expect(deserializedValue).toBeTruthy();
|
||||
expect(deserializedValue).toHaveLength(2);
|
||||
expect(deserializedValue[0]).toBeTruthy();
|
||||
expect(deserializedValue[1]).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,182 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { StorageKey } from "../../types/state";
|
||||
|
||||
import { array, record } from "./deserialization-helpers";
|
||||
import { StateDefinition } from "./state-definition";
|
||||
|
||||
export type DebugOptions = {
|
||||
/**
|
||||
* When true, logs will be written that look like the following:
|
||||
*
|
||||
* ```
|
||||
* "Updating 'global_myState_myKey' from null to non-null"
|
||||
* "Updating 'user_32265eda-62ff-4797-9ead-22214772f888_myState_myKey' from non-null to null."
|
||||
* ```
|
||||
*
|
||||
* It does not include the value of the data, only whether it is null or non-null.
|
||||
*/
|
||||
enableUpdateLogging?: boolean;
|
||||
|
||||
/**
|
||||
* When true, logs will be written that look like the following everytime a value is retrieved from storage.
|
||||
*
|
||||
* "Retrieving 'global_myState_myKey' from storage, value is null."
|
||||
* "Retrieving 'user_32265eda-62ff-4797-9ead-22214772f888_myState_myKey' from storage, value is non-null."
|
||||
*/
|
||||
enableRetrievalLogging?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A set of options for customizing the behavior of a {@link KeyDefinition}
|
||||
*/
|
||||
export type KeyDefinitionOptions<T> = {
|
||||
/**
|
||||
* A function to use to safely convert your type from json to your expected type.
|
||||
*
|
||||
* **Important:** Your data may be serialized/deserialized at any time and this
|
||||
* callback needs to be able to faithfully re-initialize from the JSON object representation of your type.
|
||||
*
|
||||
* @param jsonValue The JSON object representation of your state.
|
||||
* @returns The fully typed version of your state.
|
||||
*/
|
||||
readonly deserializer: (jsonValue: Jsonify<T>) => T | null;
|
||||
/**
|
||||
* The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
* Defaults to 1000ms.
|
||||
*/
|
||||
readonly cleanupDelayMs?: number;
|
||||
|
||||
/**
|
||||
* Options for configuring the debugging behavior, see individual options for more info.
|
||||
*/
|
||||
readonly debug?: DebugOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* KeyDefinitions describe the precise location to store data for a given piece of state.
|
||||
* The StateDefinition is used to describe the domain of the state, and the KeyDefinition
|
||||
* sub-divides that domain into specific keys.
|
||||
*/
|
||||
export class KeyDefinition<T> {
|
||||
readonly debug: Required<DebugOptions>;
|
||||
|
||||
/**
|
||||
* Creates a new instance of a KeyDefinition
|
||||
* @param stateDefinition The state definition for which this key belongs to.
|
||||
* @param key The name of the key, this should be unique per domain.
|
||||
* @param options A set of options to customize the behavior of {@link KeyDefinition}. All options are required.
|
||||
* @param options.deserializer A function to use to safely convert your type from json to your expected type.
|
||||
* Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize
|
||||
* from the JSON object representation of your type.
|
||||
*/
|
||||
constructor(
|
||||
readonly stateDefinition: StateDefinition,
|
||||
readonly key: string,
|
||||
private readonly options: KeyDefinitionOptions<T>,
|
||||
) {
|
||||
if (options.deserializer == null) {
|
||||
throw new Error(`'deserializer' is a required property on key ${this.errorKeyName}`);
|
||||
}
|
||||
|
||||
if (options.cleanupDelayMs < 0) {
|
||||
throw new Error(
|
||||
`'cleanupDelayMs' must be greater than or equal to 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `,
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize optional debug options
|
||||
const { enableUpdateLogging = false, enableRetrievalLogging = false } = options.debug ?? {};
|
||||
this.debug = {
|
||||
enableUpdateLogging,
|
||||
enableRetrievalLogging,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the deserializer configured for this {@link KeyDefinition}
|
||||
*/
|
||||
get deserializer() {
|
||||
return this.options.deserializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
*/
|
||||
get cleanupDelayMs() {
|
||||
return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link KeyDefinition} for state that is an array.
|
||||
* @param stateDefinition The state definition to be added to the KeyDefinition
|
||||
* @param key The key to be added to the KeyDefinition
|
||||
* @param options The options to customize the final {@link KeyDefinition}.
|
||||
* @returns A {@link KeyDefinition} initialized for arrays, the options run
|
||||
* the deserializer on the provided options for each element of an array.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MY_KEY = KeyDefinition.array<MyArrayElement>(MY_STATE, "key", {
|
||||
* deserializer: (myJsonElement) => convertToElement(myJsonElement),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static array<T>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
// We have them provide options for the element of the array, depending on future options we add, this could get a little weird.
|
||||
options: KeyDefinitionOptions<T>, // The array helper forces an initialValue of an empty array
|
||||
) {
|
||||
return new KeyDefinition<T[]>(stateDefinition, key, {
|
||||
...options,
|
||||
deserializer: array((e) => options.deserializer(e)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link KeyDefinition} for state that is a record.
|
||||
* @param stateDefinition The state definition to be added to the KeyDefinition
|
||||
* @param key The key to be added to the KeyDefinition
|
||||
* @param options The options to customize the final {@link KeyDefinition}.
|
||||
* @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each
|
||||
* value in a record and returns every key as a string.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MY_KEY = KeyDefinition.record<MyRecordValue>(MY_STATE, "key", {
|
||||
* deserializer: (myJsonValue) => convertToValue(myJsonValue),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static record<T, TKey extends string | number = string>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
// We have them provide options for the value of the record, depending on future options we add, this could get a little weird.
|
||||
options: KeyDefinitionOptions<T>, // The array helper forces an initialValue of an empty record
|
||||
) {
|
||||
return new KeyDefinition<Record<TKey, T>>(stateDefinition, key, {
|
||||
...options,
|
||||
deserializer: record((v) => options.deserializer(v)),
|
||||
});
|
||||
}
|
||||
|
||||
get fullName() {
|
||||
return `${this.stateDefinition.name}_${this.key}`;
|
||||
}
|
||||
|
||||
protected get errorKeyName() {
|
||||
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;
|
||||
}
|
||||
export { KeyDefinition, KeyDefinitionOptions } from "@bitwarden/state";
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
import { StorageLocation, ClientLocations } from "@bitwarden/storage-core";
|
||||
export { StateDefinition } from "@bitwarden/state";
|
||||
|
||||
// To be removed once references are updated to point to @bitwarden/storage-core
|
||||
export { StorageLocation, ClientLocations };
|
||||
|
||||
/**
|
||||
* Defines the base location and instruction of where this state is expected to be located.
|
||||
*/
|
||||
export class StateDefinition {
|
||||
readonly storageLocationOverrides: Partial<ClientLocations>;
|
||||
|
||||
/**
|
||||
* Creates a new instance of {@link StateDefinition}, the creation of which is owned by the platform team.
|
||||
* @param name The name of the state, this needs to be unique from all other {@link StateDefinition}'s.
|
||||
* @param defaultStorageLocation The location of where this state should be stored.
|
||||
*/
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly defaultStorageLocation: StorageLocation,
|
||||
storageLocationOverrides?: Partial<ClientLocations>,
|
||||
) {
|
||||
this.storageLocationOverrides = storageLocationOverrides ?? {};
|
||||
}
|
||||
}
|
||||
export { StorageLocation, ClientLocations } from "@bitwarden/storage-core";
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { ClientLocations, StateDefinition } from "./state-definition";
|
||||
import * as stateDefinitionsRecord from "./state-definitions";
|
||||
|
||||
describe.each(["web", "cli", "desktop", "browser"])(
|
||||
"state definitions follow rules for client %s",
|
||||
(clientType: keyof ClientLocations) => {
|
||||
const trackedNames: [string, string][] = [];
|
||||
|
||||
test.each(Object.entries(stateDefinitionsRecord))(
|
||||
"that export %s follows all rules",
|
||||
(exportName, stateDefinition) => {
|
||||
// All exports from state-definitions are expected to be StateDefinition's
|
||||
if (!(stateDefinition instanceof StateDefinition)) {
|
||||
throw new Error(`export ${exportName} is expected to be a StateDefinition`);
|
||||
}
|
||||
|
||||
const storageLocation =
|
||||
stateDefinition.storageLocationOverrides[clientType] ??
|
||||
stateDefinition.defaultStorageLocation;
|
||||
|
||||
const fullName = `${stateDefinition.name}_${storageLocation}`;
|
||||
|
||||
const exactConflictingExport = trackedNames.find(
|
||||
([_, trackedName]) => trackedName === fullName,
|
||||
);
|
||||
if (exactConflictingExport !== undefined) {
|
||||
const [conflictingExportName] = exactConflictingExport;
|
||||
throw new Error(
|
||||
`The export '${exportName}' has a conflicting state name and storage location with export ` +
|
||||
`'${conflictingExportName}' please ensure that you choose a unique name and location for all clients.`,
|
||||
);
|
||||
}
|
||||
|
||||
const roughConflictingExport = trackedNames.find(
|
||||
([_, trackedName]) => trackedName.toLowerCase() === fullName.toLowerCase(),
|
||||
);
|
||||
if (roughConflictingExport !== undefined) {
|
||||
const [conflictingExportName] = roughConflictingExport;
|
||||
throw new Error(
|
||||
`The export '${exportName}' differs its state name and storage location ` +
|
||||
`only by casing with export '${conflictingExportName}' please ensure it differs by more than casing.`,
|
||||
);
|
||||
}
|
||||
|
||||
const name = stateDefinition.name;
|
||||
|
||||
expect(name).not.toBeUndefined(); // undefined in an invalid name
|
||||
expect(name).not.toBeNull(); // null is in invalid name
|
||||
expect(name.length).toBeGreaterThan(3); // A 3 characters or less name is not descriptive enough
|
||||
expect(name[0]).toEqual(name[0].toLowerCase()); // First character should be lower case since camelCase is required
|
||||
expect(name).not.toContain(" "); // There should be no spaces in a state name
|
||||
expect(name).not.toContain("_"); // We should not be doing snake_case for state name
|
||||
|
||||
// NOTE: We could expect some details about the export name as well
|
||||
|
||||
trackedNames.push([exportName, fullName]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,219 +0,0 @@
|
||||
import { StateDefinition } from "./state-definition";
|
||||
|
||||
/**
|
||||
* `StateDefinition`s comes with some rules, to facilitate a quick review from
|
||||
* platform of this file, ensure you follow these rules, the ones marked with (tested)
|
||||
* have unit tests that you can run locally.
|
||||
*
|
||||
* 1. (tested) Names should not be null or undefined
|
||||
* 2. (tested) Name and storage location should be unique
|
||||
* 3. (tested) Name and storage location can't differ from another export by only casing
|
||||
* 4. (tested) Name should be longer than 3 characters. It should be descriptive, but brief.
|
||||
* 5. (tested) Name should not contain spaces or underscores
|
||||
* 6. Name should be human readable
|
||||
* 7. Name should be in camelCase format (unit tests ensure the first character is lowercase)
|
||||
* 8. Teams should only use state definitions they have created
|
||||
* 9. StateDefinitions should only be used for keys relating to the state name they chose
|
||||
*
|
||||
*/
|
||||
|
||||
// Admin Console
|
||||
|
||||
export const ORGANIZATIONS_DISK = new StateDefinition("organizations", "disk");
|
||||
export const POLICIES_DISK = new StateDefinition("policies", "disk");
|
||||
export const PROVIDERS_DISK = new StateDefinition("providers", "disk");
|
||||
export const ORGANIZATION_MANAGEMENT_PREFERENCES_DISK = new StateDefinition(
|
||||
"organizationManagementPreferences",
|
||||
"disk",
|
||||
{
|
||||
web: "disk-local",
|
||||
},
|
||||
);
|
||||
export const DELETE_MANAGED_USER_WARNING = new StateDefinition(
|
||||
"showDeleteManagedUserWarning",
|
||||
"disk",
|
||||
{
|
||||
web: "disk-local",
|
||||
},
|
||||
);
|
||||
|
||||
// Billing
|
||||
export const BILLING_DISK = new StateDefinition("billing", "disk");
|
||||
|
||||
// Auth
|
||||
|
||||
export const ACCOUNT_DISK = new StateDefinition("account", "disk");
|
||||
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||
export const AUTH_REQUEST_DISK_LOCAL = new StateDefinition("authRequestLocal", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" });
|
||||
export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk", {
|
||||
web: "disk-local",
|
||||
browser: "disk-backup-local-storage",
|
||||
});
|
||||
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
|
||||
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
|
||||
export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const LOGIN_EMAIL_MEMORY = new StateDefinition("loginEmail", "memory");
|
||||
export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory");
|
||||
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
|
||||
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
|
||||
export const PIN_DISK = new StateDefinition("pinUnlock", "disk");
|
||||
export const PIN_MEMORY = new StateDefinition("pinUnlock", "memory");
|
||||
export const ROUTER_DISK = new StateDefinition("router", "disk");
|
||||
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
|
||||
export const TOKEN_DISK = new StateDefinition("token", "disk");
|
||||
export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const TOKEN_MEMORY = new StateDefinition("token", "memory");
|
||||
export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");
|
||||
export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk");
|
||||
export const ORGANIZATION_INVITE_DISK = new StateDefinition("organizationInvite", "disk");
|
||||
export const VAULT_TIMEOUT_SETTINGS_DISK_LOCAL = new StateDefinition(
|
||||
"vaultTimeoutSettings",
|
||||
"disk",
|
||||
{
|
||||
web: "disk-local",
|
||||
},
|
||||
);
|
||||
|
||||
// Autofill
|
||||
|
||||
export const BADGE_SETTINGS_DISK = new StateDefinition("badgeSettings", "disk");
|
||||
export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition(
|
||||
"userNotificationSettings",
|
||||
"disk",
|
||||
);
|
||||
|
||||
export const DOMAIN_SETTINGS_DISK = new StateDefinition("domainSettings", "disk");
|
||||
export const AUTOFILL_SETTINGS_DISK = new StateDefinition("autofillSettings", "disk");
|
||||
export const AUTOFILL_SETTINGS_DISK_LOCAL = new StateDefinition("autofillSettingsLocal", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
export const AUTOTYPE_SETTINGS_DISK = new StateDefinition("autotypeSettings", "disk");
|
||||
|
||||
// Components
|
||||
|
||||
export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanner", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
// Platform
|
||||
|
||||
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const BADGE_MEMORY = new StateDefinition("badge", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
|
||||
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
|
||||
export const CONFIG_DISK = new StateDefinition("config", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
||||
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
|
||||
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
|
||||
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
|
||||
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
|
||||
export const POPUP_VIEW_MEMORY = new StateDefinition("popupView", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
export const SYNC_DISK = new StateDefinition("sync", "disk", { web: "memory" });
|
||||
export const THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-local" });
|
||||
export const TRANSLATION_DISK = new StateDefinition("translation", "disk", { web: "disk-local" });
|
||||
export const ANIMATION_DISK = new StateDefinition("animation", "disk");
|
||||
export const TASK_SCHEDULER_DISK = new StateDefinition("taskScheduler", "disk");
|
||||
export const EXTENSION_INITIAL_INSTALL_DISK = new StateDefinition(
|
||||
"extensionInitialInstall",
|
||||
"disk",
|
||||
);
|
||||
export const WEB_PUSH_SUBSCRIPTION = new StateDefinition("webPushSubscription", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
// Design System
|
||||
|
||||
export const POPUP_STYLE_DISK = new StateDefinition("popupStyle", "disk");
|
||||
|
||||
// Secrets Manager
|
||||
|
||||
export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
// Tools
|
||||
|
||||
export const EXTENSION_DISK = new StateDefinition("extension", "disk");
|
||||
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
|
||||
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
|
||||
export const BROWSER_SEND_MEMORY = new StateDefinition("sendBrowser", "memory");
|
||||
export const EVENT_COLLECTION_DISK = new StateDefinition("eventCollection", "disk");
|
||||
export const SEND_DISK = new StateDefinition("encryptedSend", "disk", {
|
||||
web: "memory",
|
||||
});
|
||||
export const SEND_MEMORY = new StateDefinition("decryptedSend", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
export const SEND_ACCESS_AUTH_MEMORY = new StateDefinition("sendAccessAuth", "memory");
|
||||
|
||||
// Vault
|
||||
|
||||
export const COLLECTION_DISK = new StateDefinition("collection", "disk", {
|
||||
web: "memory",
|
||||
});
|
||||
export const COLLECTION_MEMORY = new StateDefinition("decryptedCollections", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
|
||||
export const FOLDER_DISK = new StateDefinition("folder", "disk", { web: "memory" });
|
||||
export const FOLDER_MEMORY = new StateDefinition("decryptedFolders", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
export const VAULT_FILTER_DISK = new StateDefinition("vaultFilter", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
export const VAULT_SEARCH_MEMORY = new StateDefinition("vaultSearch", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
export const CIPHERS_DISK = new StateDefinition("ciphers", "disk", { web: "memory" });
|
||||
export const CIPHERS_DISK_LOCAL = new StateDefinition("ciphersLocal", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const CIPHERS_MEMORY = new StateDefinition("ciphersMemory", "memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
export const PREMIUM_BANNER_DISK_LOCAL = new StateDefinition("premiumBannerReprompt", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const BANNERS_DISMISSED_DISK = new StateDefinition("bannersDismissed", "disk");
|
||||
export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
|
||||
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
|
||||
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
|
||||
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
|
||||
export const NUDGES_DISK = new StateDefinition("nudges", "disk", { web: "disk-local" });
|
||||
export const SETUP_EXTENSION_DISMISSED_DISK = new StateDefinition(
|
||||
"setupExtensionDismissed",
|
||||
"disk",
|
||||
{
|
||||
web: "disk-local",
|
||||
},
|
||||
);
|
||||
export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition(
|
||||
"vaultBrowserIntroCarousel",
|
||||
"disk",
|
||||
);
|
||||
@@ -1,89 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
StorageServiceProvider,
|
||||
} from "@bitwarden/storage-core";
|
||||
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +1,6 @@
|
||||
import { PossibleLocation, StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
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 {
|
||||
StateEventRegistrarService,
|
||||
StateEventInfo,
|
||||
STATE_LOCK_EVENT,
|
||||
STATE_LOGOUT_EVENT,
|
||||
} from "@bitwarden/state";
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
StorageServiceProvider,
|
||||
} from "@bitwarden/storage-core";
|
||||
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,83 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { GlobalState } from "./global-state";
|
||||
import { GlobalStateProvider } from "./global-state.provider";
|
||||
import { StateDefinition, StorageLocation } from "./state-definition";
|
||||
import {
|
||||
STATE_LOCK_EVENT,
|
||||
STATE_LOGOUT_EVENT,
|
||||
StateEventInfo,
|
||||
} from "./state-event-registrar.service";
|
||||
import { ClearEvent, UserKeyDefinition } 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 { StateEventRunnerService } from "@bitwarden/state";
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export const DEFAULT_OPTIONS = {
|
||||
shouldUpdate: () => true,
|
||||
combineLatestWith: null as Observable<unknown>,
|
||||
msTimeout: 1000,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,80 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DerivedStateDependencies } from "../../types/state";
|
||||
|
||||
import { DeriveDefinition } from "./derive-definition";
|
||||
import { DerivedState } from "./derived-state";
|
||||
import { GlobalState } from "./global-state";
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs
|
||||
import { GlobalStateProvider } from "./global-state.provider";
|
||||
import { KeyDefinition } from "./key-definition";
|
||||
import { UserKeyDefinition } from "./user-key-definition";
|
||||
import { ActiveUserState, SingleUserState } from "./user-state";
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs
|
||||
import { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
|
||||
|
||||
/** Convenience wrapper class for {@link ActiveUserStateProvider}, {@link SingleUserStateProvider},
|
||||
* and {@link GlobalStateProvider}.
|
||||
*/
|
||||
export abstract class StateProvider {
|
||||
/** @see{@link ActiveUserStateProvider.activeUserId$} */
|
||||
abstract activeUserId$: Observable<UserId | undefined>;
|
||||
|
||||
/**
|
||||
* Gets a state observable for a given key and userId.
|
||||
*
|
||||
* @remarks If userId is falsy the observable returned will attempt to point to the currently active user _and not update if the active user changes_.
|
||||
* This is different to how `getActive` works and more similar to `getUser` for whatever user happens to be active at the time of the call.
|
||||
* If no user happens to be active at the time this method is called with a falsy userId then this observable will not emit a value until
|
||||
* a user becomes active. If you are not confident a user is active at the time this method is called, you may want to pipe a call to `timeout`
|
||||
* or instead call {@link getUserStateOrDefault$} and supply a value you would rather have given in the case of no passed in userId and no active user.
|
||||
*
|
||||
* @param keyDefinition - The key definition for the state you want to get.
|
||||
* @param userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned.
|
||||
*/
|
||||
abstract getUserState$<T>(keyDefinition: UserKeyDefinition<T>, userId?: UserId): Observable<T>;
|
||||
|
||||
/**
|
||||
* Gets a state observable for a given key and userId
|
||||
*
|
||||
* @remarks If userId is falsy the observable return will first attempt to point to the currently active user but will not follow subsequent active user changes,
|
||||
* if there is no immediately available active user, then it will fallback to returning a default value in an observable that immediately completes.
|
||||
*
|
||||
* @param keyDefinition - The key definition for the state you want to get.
|
||||
* @param config.userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned.
|
||||
* @param config.defaultValue - The default value that should be wrapped in an observable if no active user is immediately available and no truthy userId is passed in.
|
||||
*/
|
||||
abstract getUserStateOrDefault$<T>(
|
||||
keyDefinition: UserKeyDefinition<T>,
|
||||
config: { userId: UserId | undefined; defaultValue?: T },
|
||||
): Observable<T>;
|
||||
|
||||
/**
|
||||
* Sets the state for a given key and userId.
|
||||
*
|
||||
* @overload
|
||||
* @param keyDefinition - The key definition for the state you want to set.
|
||||
* @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.
|
||||
*/
|
||||
abstract setUserState<T>(
|
||||
keyDefinition: UserKeyDefinition<T>,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T | null]>;
|
||||
|
||||
/** @see{@link ActiveUserStateProvider.get} */
|
||||
abstract getActive<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T>;
|
||||
|
||||
/** @see{@link SingleUserStateProvider.get} */
|
||||
abstract getUser<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T>;
|
||||
|
||||
/** @see{@link GlobalStateProvider.get} */
|
||||
abstract getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T>;
|
||||
abstract getDerived<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo>;
|
||||
}
|
||||
export { StateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,142 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { UserId } from "../../types/guid";
|
||||
import { StorageKey } from "../../types/state";
|
||||
import { Utils } from "../misc/utils";
|
||||
|
||||
import { array, record } from "./deserialization-helpers";
|
||||
import { DebugOptions, KeyDefinitionOptions } from "./key-definition";
|
||||
import { StateDefinition } from "./state-definition";
|
||||
|
||||
export type ClearEvent = "lock" | "logout";
|
||||
|
||||
export type UserKeyDefinitionOptions<T> = KeyDefinitionOptions<T> & {
|
||||
clearOn: ClearEvent[];
|
||||
};
|
||||
|
||||
const USER_KEY_DEFINITION_MARKER: unique symbol = Symbol("UserKeyDefinition");
|
||||
|
||||
export class UserKeyDefinition<T> {
|
||||
readonly [USER_KEY_DEFINITION_MARKER] = true;
|
||||
/**
|
||||
* A unique array of events that the state stored at this key should be cleared on.
|
||||
*/
|
||||
readonly clearOn: ClearEvent[];
|
||||
|
||||
/**
|
||||
* Normalized options used for debugging purposes.
|
||||
*/
|
||||
readonly debug: Required<DebugOptions>;
|
||||
|
||||
constructor(
|
||||
readonly stateDefinition: StateDefinition,
|
||||
readonly key: string,
|
||||
private readonly options: UserKeyDefinitionOptions<T>,
|
||||
) {
|
||||
if (options.deserializer == null) {
|
||||
throw new Error(`'deserializer' is a required property on key ${this.errorKeyName}`);
|
||||
}
|
||||
|
||||
if (options.cleanupDelayMs < 0) {
|
||||
throw new Error(
|
||||
`'cleanupDelayMs' must be greater than or equal to 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out repeat values
|
||||
this.clearOn = Array.from(new Set(options.clearOn));
|
||||
|
||||
// Normalize optional debug options
|
||||
const { enableUpdateLogging = false, enableRetrievalLogging = false } = options.debug ?? {};
|
||||
this.debug = {
|
||||
enableUpdateLogging,
|
||||
enableRetrievalLogging,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the deserializer configured for this {@link KeyDefinition}
|
||||
*/
|
||||
get deserializer() {
|
||||
return this.options.deserializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
|
||||
*/
|
||||
get cleanupDelayMs() {
|
||||
return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link UserKeyDefinition} for state that is an array.
|
||||
* @param stateDefinition The state definition to be added to the UserKeyDefinition
|
||||
* @param key The key to be added to the KeyDefinition
|
||||
* @param options The options to customize the final {@link UserKeyDefinition}.
|
||||
* @returns A {@link UserKeyDefinition} initialized for arrays, the options run
|
||||
* the deserializer on the provided options for each element of an array
|
||||
* **unless that array is null, in which case it will return an empty list.**
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MY_KEY = UserKeyDefinition.array<MyArrayElement>(MY_STATE, "key", {
|
||||
* deserializer: (myJsonElement) => convertToElement(myJsonElement),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static array<T>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
// We have them provide options for the element of the array, depending on future options we add, this could get a little weird.
|
||||
options: UserKeyDefinitionOptions<T>,
|
||||
) {
|
||||
return new UserKeyDefinition<T[]>(stateDefinition, key, {
|
||||
...options,
|
||||
deserializer: array((e) => options.deserializer(e)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link UserKeyDefinition} for state that is a record.
|
||||
* @param stateDefinition The state definition to be added to the UserKeyDefinition
|
||||
* @param key The key to be added to the KeyDefinition
|
||||
* @param options The options to customize the final {@link UserKeyDefinition}.
|
||||
* @returns A {@link UserKeyDefinition} that contains a serializer that will run the provided deserializer for each
|
||||
* value in a record and returns every key as a string **unless that record is null, in which case it will return an record.**
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MY_KEY = UserKeyDefinition.record<MyRecordValue>(MY_STATE, "key", {
|
||||
* deserializer: (myJsonValue) => convertToValue(myJsonValue),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static record<T, TKey extends string | number = string>(
|
||||
stateDefinition: StateDefinition,
|
||||
key: string,
|
||||
// We have them provide options for the value of the record, depending on future options we add, this could get a little weird.
|
||||
options: UserKeyDefinitionOptions<T>, // The array helper forces an initialValue of an empty record
|
||||
) {
|
||||
return new UserKeyDefinition<Record<TKey, T>>(stateDefinition, key, {
|
||||
...options,
|
||||
deserializer: record((v) => options.deserializer(v)),
|
||||
});
|
||||
}
|
||||
|
||||
get fullName() {
|
||||
return `${this.stateDefinition.name}_${this.key}`;
|
||||
}
|
||||
|
||||
buildKey(userId: UserId) {
|
||||
if (!Utils.isGuid(userId)) {
|
||||
throw new Error(
|
||||
`You cannot build a user key without a valid UserId, building for key ${this.fullName}`,
|
||||
);
|
||||
}
|
||||
return `user_${userId}_${this.stateDefinition.name}_${this.key}` as StorageKey;
|
||||
}
|
||||
|
||||
private get errorKeyName() {
|
||||
return `${this.stateDefinition.name} > ${this.key}`;
|
||||
}
|
||||
}
|
||||
export { UserKeyDefinition, UserKeyDefinitionOptions } from "@bitwarden/state";
|
||||
|
||||
@@ -1,35 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { UserKeyDefinition } from "./user-key-definition";
|
||||
import { ActiveUserState, SingleUserState } from "./user-state";
|
||||
|
||||
/** A provider for getting an implementation of state scoped to a given key and userId */
|
||||
export abstract class SingleUserStateProvider {
|
||||
/**
|
||||
* Gets a {@link SingleUserState} scoped to the given {@link UserKeyDefinition} and {@link UserId}
|
||||
*
|
||||
* @param userId - The {@link UserId} for which you want the user state for.
|
||||
* @param userKeyDefinition - The {@link UserKeyDefinition} for which you want the user state for.
|
||||
*/
|
||||
abstract get<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T>;
|
||||
}
|
||||
|
||||
/** A provider for getting an implementation of state scoped to a given key, but always pointing
|
||||
* to the currently active user
|
||||
*/
|
||||
export abstract class ActiveUserStateProvider {
|
||||
/**
|
||||
* Convenience re-emission of active user ID from {@link AccountService.activeAccount$}
|
||||
*/
|
||||
abstract activeUserId$: Observable<UserId | undefined>;
|
||||
|
||||
/**
|
||||
* Gets a {@link ActiveUserState} scoped to the given {@link KeyDefinition}, but updates when active user changes such
|
||||
* that the emitted values always represents the state for the currently active user.
|
||||
*
|
||||
* @param keyDefinition - The {@link UserKeyDefinition} for which you want the user state for.
|
||||
*/
|
||||
abstract get<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T>;
|
||||
}
|
||||
export { ActiveUserStateProvider, SingleUserStateProvider } from "@bitwarden/state";
|
||||
|
||||
@@ -1,64 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { StateUpdateOptions } from "./state-update-options";
|
||||
|
||||
export type CombinedState<T> = readonly [userId: UserId, state: T];
|
||||
|
||||
/** A helper object for interacting with state that is scoped to a specific user. */
|
||||
export interface UserState<T> {
|
||||
/** Emits a stream of data. Emits null if the user does not have specified state. */
|
||||
readonly state$: Observable<T | null>;
|
||||
|
||||
/** Emits a stream of tuples, with the first element being a user id and the second element being the data for that user. */
|
||||
readonly combinedState$: Observable<CombinedState<T | null>>;
|
||||
}
|
||||
|
||||
export const activeMarker: unique symbol = Symbol("active");
|
||||
|
||||
export interface ActiveUserState<T> extends UserState<T> {
|
||||
readonly [activeMarker]: true;
|
||||
|
||||
/**
|
||||
* Emits a stream of data. Emits null if the user does not have specified state.
|
||||
* Note: Will not emit if there is no active user.
|
||||
*/
|
||||
readonly state$: Observable<T | null>;
|
||||
|
||||
/**
|
||||
* 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 A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
readonly update: <TCombine>(
|
||||
configureState: (state: T | null, dependencies: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<[UserId, T | null]>;
|
||||
}
|
||||
|
||||
export interface SingleUserState<T> extends UserState<T> {
|
||||
readonly userId: UserId;
|
||||
|
||||
/**
|
||||
* 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 A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
readonly update: <TCombine>(
|
||||
configureState: (state: T | null, dependencies: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<T | null>;
|
||||
}
|
||||
export { ActiveUserState, SingleUserState, CombinedState } from "@bitwarden/state";
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { createMigrationBuilder, waitForMigrations, CURRENT_VERSION } from "./migrate";
|
||||
// Compatibility re-export for @bitwarden/common/state-migrations
|
||||
export * from "@bitwarden/state";
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
import { AbstractStorageService } from "../platform/abstractions/storage.service";
|
||||
|
||||
import { currentVersion } from "./migrate";
|
||||
|
||||
describe("currentVersion", () => {
|
||||
let storage: MockProxy<AbstractStorageService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = mock();
|
||||
logService = mock();
|
||||
});
|
||||
|
||||
it("should return -1 if no version", async () => {
|
||||
storage.get.mockReturnValueOnce(null);
|
||||
expect(await currentVersion(storage, logService)).toEqual(-1);
|
||||
});
|
||||
|
||||
it("should return version", async () => {
|
||||
storage.get.calledWith("stateVersion").mockReturnValueOnce(1 as any);
|
||||
expect(await currentVersion(storage, logService)).toEqual(1);
|
||||
});
|
||||
|
||||
it("should return version from global", async () => {
|
||||
storage.get.calledWith("stateVersion").mockReturnValueOnce(null);
|
||||
storage.get.calledWith("global").mockReturnValueOnce({ stateVersion: 1 } as any);
|
||||
expect(await currentVersion(storage, logService)).toEqual(1);
|
||||
});
|
||||
|
||||
it("should prefer root version to global", async () => {
|
||||
storage.get.calledWith("stateVersion").mockReturnValue(1 as any);
|
||||
storage.get.calledWith("global").mockReturnValue({ stateVersion: 2 } as any);
|
||||
expect(await currentVersion(storage, logService)).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -1,218 +0,0 @@
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
import { AbstractStorageService } from "../platform/abstractions/storage.service";
|
||||
|
||||
import { MigrationBuilder } from "./migration-builder";
|
||||
import { EverHadUserKeyMigrator } from "./migrations/10-move-ever-had-user-key-to-state-providers";
|
||||
import { OrganizationKeyMigrator } from "./migrations/11-move-org-keys-to-state-providers";
|
||||
import { MoveEnvironmentStateToProviders } from "./migrations/12-move-environment-state-to-providers";
|
||||
import { ProviderKeyMigrator } from "./migrations/13-move-provider-keys-to-state-providers";
|
||||
import { MoveBiometricClientKeyHalfToStateProviders } from "./migrations/14-move-biometric-client-key-half-state-to-providers";
|
||||
import { FolderMigrator } from "./migrations/15-move-folder-state-to-state-provider";
|
||||
import { LastSyncMigrator } from "./migrations/16-move-last-sync-to-state-provider";
|
||||
import { EnablePasskeysMigrator } from "./migrations/17-move-enable-passkeys-to-state-providers";
|
||||
import { AutofillSettingsKeyMigrator } from "./migrations/18-move-autofill-settings-to-state-providers";
|
||||
import { RequirePasswordOnStartMigrator } from "./migrations/19-migrate-require-password-on-start";
|
||||
import { PrivateKeyMigrator } from "./migrations/20-move-private-key-to-state-providers";
|
||||
import { CollectionMigrator } from "./migrations/21-move-collections-state-to-state-provider";
|
||||
import { CollapsedGroupingsMigrator } from "./migrations/22-move-collapsed-groupings-to-state-provider";
|
||||
import { MoveBiometricPromptsToStateProviders } from "./migrations/23-move-biometric-prompts-to-state-providers";
|
||||
import { SmOnboardingTasksMigrator } from "./migrations/24-move-sm-onboarding-key-to-state-providers";
|
||||
import { ClearClipboardDelayMigrator } from "./migrations/25-move-clear-clipboard-to-autofill-settings-state-provider";
|
||||
import { RevertLastSyncMigrator } from "./migrations/26-revert-move-last-sync-to-state-provider";
|
||||
import { BadgeSettingsMigrator } from "./migrations/27-move-badge-settings-to-state-providers";
|
||||
import { MoveBiometricUnlockToStateProviders } from "./migrations/28-move-biometric-unlock-to-state-providers";
|
||||
import { UserNotificationSettingsKeyMigrator } from "./migrations/29-move-user-notification-settings-to-state-provider";
|
||||
import { PolicyMigrator } from "./migrations/30-move-policy-state-to-state-provider";
|
||||
import { EnableContextMenuMigrator } from "./migrations/31-move-enable-context-menu-to-autofill-settings-state-provider";
|
||||
import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language";
|
||||
import { AppIdMigrator } from "./migrations/33-move-app-id-to-state-providers";
|
||||
import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-to-state-providers";
|
||||
import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to-state-providers";
|
||||
import { VaultSettingsKeyMigrator } from "./migrations/36-move-show-card-and-identity-to-state-provider";
|
||||
import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state-providers";
|
||||
import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token-svc-to-state-provider";
|
||||
import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers";
|
||||
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
|
||||
import { OrganizationMigrator } from "./migrations/40-move-organization-state-to-state-provider";
|
||||
import { EventCollectionMigrator } from "./migrations/41-move-event-collection-to-state-provider";
|
||||
import { EnableFaviconMigrator } from "./migrations/42-move-enable-favicon-to-domain-settings-state-provider";
|
||||
import { AutoConfirmFingerPrintsMigrator } from "./migrations/43-move-auto-confirm-finger-prints-to-state-provider";
|
||||
import { UserDecryptionOptionsMigrator } from "./migrations/44-move-user-decryption-options-to-state-provider";
|
||||
import { MergeEnvironmentState } from "./migrations/45-merge-environment-state";
|
||||
import { DeleteBiometricPromptCancelledData } from "./migrations/46-delete-orphaned-biometric-prompt-data";
|
||||
import { MoveDesktopSettingsMigrator } from "./migrations/47-move-desktop-settings";
|
||||
import { MoveDdgToStateProviderMigrator } from "./migrations/48-move-ddg-to-state-provider";
|
||||
import { AccountServerConfigMigrator } from "./migrations/49-move-account-server-configs";
|
||||
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
|
||||
import { KeyConnectorMigrator } from "./migrations/50-move-key-connector-to-state-provider";
|
||||
import { RememberedEmailMigrator } from "./migrations/51-move-remembered-email-to-state-providers";
|
||||
import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version";
|
||||
import { DeviceTrustServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-svc-to-state-providers";
|
||||
import { SendMigrator } from "./migrations/54-move-encrypted-sends";
|
||||
import { MoveMasterKeyStateToProviderMigrator } from "./migrations/55-move-master-key-state-to-provider";
|
||||
import { AuthRequestMigrator } from "./migrations/56-move-auth-requests";
|
||||
import { CipherServiceMigrator } from "./migrations/57-move-cipher-service-to-state-provider";
|
||||
import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag";
|
||||
import { KdfConfigMigrator } from "./migrations/59-move-kdf-config-to-state-provider";
|
||||
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
||||
import { KnownAccountsMigrator } from "./migrations/60-known-accounts";
|
||||
import { PinStateMigrator } from "./migrations/61-move-pin-state-to-providers";
|
||||
import { VaultTimeoutSettingsServiceStateProviderMigrator } from "./migrations/62-migrate-vault-timeout-settings-svc-to-state-provider";
|
||||
import { PasswordOptionsMigrator } from "./migrations/63-migrate-password-settings";
|
||||
import { GeneratorHistoryMigrator } from "./migrations/64-migrate-generator-history";
|
||||
import { ForwarderOptionsMigrator } from "./migrations/65-migrate-forwarder-settings";
|
||||
import { MoveFinalDesktopSettingsMigrator } from "./migrations/66-move-final-desktop-settings";
|
||||
import { RemoveUnassignedItemsBannerDismissed } from "./migrations/67-remove-unassigned-items-banner-dismissed";
|
||||
import { MoveLastSyncDate } from "./migrations/68-move-last-sync-date";
|
||||
import { MigrateIncorrectFolderKey } from "./migrations/69-migrate-incorrect-folder-key";
|
||||
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
|
||||
import { RemoveAcBannersDismissed } from "./migrations/70-remove-ac-banner-dismissed";
|
||||
import { RemoveNewCustomizationOptionsCalloutDismissed } from "./migrations/71-remove-new-customization-options-callout-dismissed";
|
||||
import { RemoveAccountDeprovisioningBannerDismissed } from "./migrations/72-remove-account-deprovisioning-banner-dismissed";
|
||||
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
|
||||
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
|
||||
import { MinVersionMigrator } from "./migrations/min-version";
|
||||
|
||||
export const MIN_VERSION = 3;
|
||||
export const CURRENT_VERSION = 72;
|
||||
export type MinVersion = typeof MIN_VERSION;
|
||||
|
||||
export function createMigrationBuilder() {
|
||||
return MigrationBuilder.create()
|
||||
.with(MinVersionMigrator)
|
||||
.with(RemoveEverBeenUnlockedMigrator, 3, 4)
|
||||
.with(AddKeyTypeToOrgKeysMigrator, 4, 5)
|
||||
.with(RemoveLegacyEtmKeyMigrator, 5, 6)
|
||||
.with(MoveBiometricAutoPromptToAccount, 6, 7)
|
||||
.with(MoveStateVersionMigrator, 7, 8)
|
||||
.with(MoveBrowserSettingsToGlobal, 8, 9)
|
||||
.with(EverHadUserKeyMigrator, 9, 10)
|
||||
.with(OrganizationKeyMigrator, 10, 11)
|
||||
.with(MoveEnvironmentStateToProviders, 11, 12)
|
||||
.with(ProviderKeyMigrator, 12, 13)
|
||||
.with(MoveBiometricClientKeyHalfToStateProviders, 13, 14)
|
||||
.with(FolderMigrator, 14, 15)
|
||||
.with(LastSyncMigrator, 15, 16)
|
||||
.with(EnablePasskeysMigrator, 16, 17)
|
||||
.with(AutofillSettingsKeyMigrator, 17, 18)
|
||||
.with(RequirePasswordOnStartMigrator, 18, 19)
|
||||
.with(PrivateKeyMigrator, 19, 20)
|
||||
.with(CollectionMigrator, 20, 21)
|
||||
.with(CollapsedGroupingsMigrator, 21, 22)
|
||||
.with(MoveBiometricPromptsToStateProviders, 22, 23)
|
||||
.with(SmOnboardingTasksMigrator, 23, 24)
|
||||
.with(ClearClipboardDelayMigrator, 24, 25)
|
||||
.with(RevertLastSyncMigrator, 25, 26)
|
||||
.with(BadgeSettingsMigrator, 26, 27)
|
||||
.with(MoveBiometricUnlockToStateProviders, 27, 28)
|
||||
.with(UserNotificationSettingsKeyMigrator, 28, 29)
|
||||
.with(PolicyMigrator, 29, 30)
|
||||
.with(EnableContextMenuMigrator, 30, 31)
|
||||
.with(PreferredLanguageMigrator, 31, 32)
|
||||
.with(AppIdMigrator, 32, 33)
|
||||
.with(DomainSettingsMigrator, 33, 34)
|
||||
.with(MoveThemeToStateProviderMigrator, 34, 35)
|
||||
.with(VaultSettingsKeyMigrator, 35, 36)
|
||||
.with(AvatarColorMigrator, 36, 37)
|
||||
.with(TokenServiceStateProviderMigrator, 37, 38)
|
||||
.with(MoveBillingAccountProfileMigrator, 38, 39)
|
||||
.with(OrganizationMigrator, 39, 40)
|
||||
.with(EventCollectionMigrator, 40, 41)
|
||||
.with(EnableFaviconMigrator, 41, 42)
|
||||
.with(AutoConfirmFingerPrintsMigrator, 42, 43)
|
||||
.with(UserDecryptionOptionsMigrator, 43, 44)
|
||||
.with(MergeEnvironmentState, 44, 45)
|
||||
.with(DeleteBiometricPromptCancelledData, 45, 46)
|
||||
.with(MoveDesktopSettingsMigrator, 46, 47)
|
||||
.with(MoveDdgToStateProviderMigrator, 47, 48)
|
||||
.with(AccountServerConfigMigrator, 48, 49)
|
||||
.with(KeyConnectorMigrator, 49, 50)
|
||||
.with(RememberedEmailMigrator, 50, 51)
|
||||
.with(DeleteInstalledVersion, 51, 52)
|
||||
.with(DeviceTrustServiceStateProviderMigrator, 52, 53)
|
||||
.with(SendMigrator, 53, 54)
|
||||
.with(MoveMasterKeyStateToProviderMigrator, 54, 55)
|
||||
.with(AuthRequestMigrator, 55, 56)
|
||||
.with(CipherServiceMigrator, 56, 57)
|
||||
.with(RemoveRefreshTokenMigratedFlagMigrator, 57, 58)
|
||||
.with(KdfConfigMigrator, 58, 59)
|
||||
.with(KnownAccountsMigrator, 59, 60)
|
||||
.with(PinStateMigrator, 60, 61)
|
||||
.with(VaultTimeoutSettingsServiceStateProviderMigrator, 61, 62)
|
||||
.with(PasswordOptionsMigrator, 62, 63)
|
||||
.with(GeneratorHistoryMigrator, 63, 64)
|
||||
.with(ForwarderOptionsMigrator, 64, 65)
|
||||
.with(MoveFinalDesktopSettingsMigrator, 65, 66)
|
||||
.with(RemoveUnassignedItemsBannerDismissed, 66, 67)
|
||||
.with(MoveLastSyncDate, 67, 68)
|
||||
.with(MigrateIncorrectFolderKey, 68, 69)
|
||||
.with(RemoveAcBannersDismissed, 69, 70)
|
||||
.with(RemoveNewCustomizationOptionsCalloutDismissed, 70, 71)
|
||||
.with(RemoveAccountDeprovisioningBannerDismissed, 71, CURRENT_VERSION);
|
||||
}
|
||||
|
||||
export async function currentVersion(
|
||||
storageService: AbstractStorageService,
|
||||
logService: LogService,
|
||||
) {
|
||||
let state = await storageService.get<number>("stateVersion");
|
||||
if (state == null) {
|
||||
// Pre v8
|
||||
state = (await storageService.get<{ stateVersion: number }>("global"))?.stateVersion;
|
||||
}
|
||||
if (state == null) {
|
||||
logService.info("No state version found, assuming empty state.");
|
||||
return -1;
|
||||
}
|
||||
logService.info(`State version: ${state}`);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for migrations to have a chance to run and will resolve the promise once they are.
|
||||
*
|
||||
* @param storageService Disk storage where the `stateVersion` will or is already saved in.
|
||||
* @param logService Log service
|
||||
*/
|
||||
export async function waitForMigrations(
|
||||
storageService: AbstractStorageService,
|
||||
logService: LogService,
|
||||
) {
|
||||
const isReady = async () => {
|
||||
const version = await currentVersion(storageService, logService);
|
||||
// The saved version is what we consider the latest
|
||||
// migrations should be complete, the state version
|
||||
// shouldn't become larger than `CURRENT_VERSION` in
|
||||
// any normal usage of the application but it is common
|
||||
// enough in dev scenarios where we want to consider that
|
||||
// ready as well and return true in that scenario.
|
||||
return version >= CURRENT_VERSION;
|
||||
};
|
||||
|
||||
const wait = async (time: number) => {
|
||||
// Wait exponentially
|
||||
const nextTime = time * 2;
|
||||
if (nextTime > 8192) {
|
||||
// Don't wait longer than ~8 seconds in a single wait,
|
||||
// if the migrations still haven't happened. They aren't
|
||||
// likely to.
|
||||
return;
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(async () => {
|
||||
if (!(await isReady())) {
|
||||
logService.info(`Waiting for migrations to finish, waiting for ${nextTime}ms`);
|
||||
await wait(nextTime);
|
||||
}
|
||||
resolve();
|
||||
}, time);
|
||||
});
|
||||
};
|
||||
|
||||
if (!(await isReady())) {
|
||||
// Wait for 2ms to start with
|
||||
await wait(2);
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths
|
||||
import { ClientType } from "../enums";
|
||||
|
||||
import { MigrationBuilder } from "./migration-builder";
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
import { Migrator } from "./migrator";
|
||||
|
||||
describe("MigrationBuilder", () => {
|
||||
class TestMigrator extends Migrator<0, 1> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
await helper.set("test", "test");
|
||||
return;
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
await helper.set("test", "rollback");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
class TestMigratorWithInstanceMethod extends Migrator<0, 1> {
|
||||
private async instanceMethod(helper: MigrationHelper, value: string) {
|
||||
await helper.set("test", value);
|
||||
}
|
||||
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
await this.instanceMethod(helper, "migrate");
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
await this.instanceMethod(helper, "rollback");
|
||||
}
|
||||
}
|
||||
|
||||
let sut: MigrationBuilder<number>;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = MigrationBuilder.create();
|
||||
});
|
||||
|
||||
class TestBadMigrator extends Migrator<1, 0> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
await helper.set("test", "test");
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
await helper.set("test", "rollback");
|
||||
}
|
||||
}
|
||||
|
||||
it("should throw if instantiated incorrectly", () => {
|
||||
expect(() => MigrationBuilder.create().with(TestMigrator, null, null)).toThrow();
|
||||
expect(() =>
|
||||
MigrationBuilder.create().with(TestMigrator, 0, 1).with(TestBadMigrator, 1, 0),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("should be able to create a new MigrationBuilder", () => {
|
||||
expect(sut).toBeInstanceOf(MigrationBuilder);
|
||||
});
|
||||
|
||||
it("should be able to add a migrator", () => {
|
||||
const newBuilder = sut.with(TestMigrator, 0, 1);
|
||||
const migrations = newBuilder["migrations"];
|
||||
expect(migrations.length).toBe(1);
|
||||
expect(migrations[0]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "up" });
|
||||
});
|
||||
|
||||
it("should be able to add a rollback", () => {
|
||||
const newBuilder = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
|
||||
const migrations = newBuilder["migrations"];
|
||||
expect(migrations.length).toBe(2);
|
||||
expect(migrations[1]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "down" });
|
||||
});
|
||||
|
||||
const clientTypes = Object.values(ClientType);
|
||||
|
||||
describe.each(clientTypes)("for client %s", (clientType) => {
|
||||
describe("migrate", () => {
|
||||
let migrator: TestMigrator;
|
||||
let rollback_migrator: TestMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
|
||||
migrator = (sut as any).migrations[0].migrator;
|
||||
rollback_migrator = (sut as any).migrations[1].migrator;
|
||||
});
|
||||
|
||||
it("should migrate", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general", clientType);
|
||||
const spy = jest.spyOn(migrator, "migrate");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper);
|
||||
});
|
||||
|
||||
it("should rollback", async () => {
|
||||
const helper = new MigrationHelper(1, mock(), mock(), "general", clientType);
|
||||
const spy = jest.spyOn(rollback_migrator, "rollback");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper);
|
||||
});
|
||||
|
||||
it("should update version on migrate", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general", clientType);
|
||||
const spy = jest.spyOn(migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper, "up");
|
||||
});
|
||||
|
||||
it("should update version on rollback", async () => {
|
||||
const helper = new MigrationHelper(1, mock(), mock(), "general", clientType);
|
||||
const spy = jest.spyOn(rollback_migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper, "down");
|
||||
});
|
||||
|
||||
it("should not run the migrator if the current version does not match the from version", async () => {
|
||||
const helper = new MigrationHelper(3, mock(), mock(), "general", clientType);
|
||||
const migrate = jest.spyOn(migrator, "migrate");
|
||||
const rollback = jest.spyOn(rollback_migrator, "rollback");
|
||||
await sut.migrate(helper);
|
||||
expect(migrate).not.toBeCalled();
|
||||
expect(rollback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("should not update version if the current version does not match the from version", async () => {
|
||||
const helper = new MigrationHelper(3, mock(), mock(), "general", clientType);
|
||||
const migrate = jest.spyOn(migrator, "updateVersion");
|
||||
const rollback = jest.spyOn(rollback_migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(migrate).not.toBeCalled();
|
||||
expect(rollback).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to call instance methods", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general", clientType);
|
||||
await sut.with(TestMigratorWithInstanceMethod, 0, 1).migrate(helper);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +1 @@
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator";
|
||||
|
||||
export class MigrationBuilder<TCurrent extends number = 0> {
|
||||
/** Create a new MigrationBuilder with an empty buffer of migrations to perform.
|
||||
*
|
||||
* Add migrations to the buffer with {@link with} and {@link rollback}.
|
||||
* @returns A new MigrationBuilder.
|
||||
*/
|
||||
static create(): MigrationBuilder<0> {
|
||||
return new MigrationBuilder([]);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private migrations: readonly { migrator: Migrator<number, number>; direction: Direction }[],
|
||||
) {}
|
||||
|
||||
/** Add a migrator to the MigrationBuilder. Types are updated such that the chained MigrationBuilder must currently be
|
||||
* at state version equal to the from version of the migrator. Return as MigrationBuilder<TTo> where TTo is the to
|
||||
* version of the migrator, so that the next migrator can be chained.
|
||||
*
|
||||
* @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
|
||||
* required to instantiate version numbers unless a default constructor is defined.
|
||||
* @returns A new MigrationBuilder with the to version of the migrator as the current version.
|
||||
*/
|
||||
with<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
TFrom extends VersionFrom<TMigrator> & TCurrent,
|
||||
TTo extends VersionTo<TMigrator>,
|
||||
>(
|
||||
...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo]
|
||||
): MigrationBuilder<TTo> {
|
||||
return this.addMigrator(migrate, "up");
|
||||
}
|
||||
|
||||
/** Add a migrator to rollback on the MigrationBuilder's list of migrations. As with {@link with}, types of
|
||||
* MigrationBuilder and Migrator must align. However, this time the migration is reversed so TCurrent of the
|
||||
* MigrationBuilder must be equal to the to version of the migrator. Return as MigrationBuilder<TFrom> where TFrom
|
||||
* is the from version of the migrator, so that the next migrator can be chained.
|
||||
*
|
||||
* @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
|
||||
* required to instantiate version numbers unless a default constructor is defined.
|
||||
* @returns A new MigrationBuilder with the from version of the migrator as the current version.
|
||||
*/
|
||||
rollback<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
TFrom extends VersionFrom<TMigrator>,
|
||||
TTo extends VersionTo<TMigrator> & TCurrent,
|
||||
>(
|
||||
...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom]
|
||||
): MigrationBuilder<TFrom> {
|
||||
if (migrate.length === 3) {
|
||||
migrate = [migrate[0], migrate[2], migrate[1]];
|
||||
}
|
||||
return this.addMigrator(migrate, "down");
|
||||
}
|
||||
|
||||
/** Execute the migrations as defined in the MigrationBuilder's migrator buffer */
|
||||
migrate(helper: MigrationHelper): Promise<void> {
|
||||
return this.migrations.reduce(
|
||||
(promise, migrator) =>
|
||||
promise.then(async () => {
|
||||
await this.runMigrator(migrator.migrator, helper, migrator.direction);
|
||||
}),
|
||||
Promise.resolve(),
|
||||
);
|
||||
}
|
||||
|
||||
private addMigrator<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
TFrom extends VersionFrom<TMigrator> & TCurrent,
|
||||
TTo extends VersionTo<TMigrator>,
|
||||
>(
|
||||
migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo],
|
||||
direction: Direction = "up",
|
||||
) {
|
||||
const newMigration =
|
||||
migrate.length === 1
|
||||
? { migrator: new migrate[0](), direction }
|
||||
: { migrator: new migrate[0](migrate[1], migrate[2]), direction };
|
||||
|
||||
return new MigrationBuilder<TTo>([...this.migrations, newMigration]);
|
||||
}
|
||||
|
||||
private async runMigrator(
|
||||
migrator: Migrator<number, number>,
|
||||
helper: MigrationHelper,
|
||||
direction: Direction,
|
||||
): Promise<void> {
|
||||
const shouldMigrate = await migrator.shouldMigrate(helper, direction);
|
||||
helper.info(
|
||||
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) should migrate: ${shouldMigrate} - ${direction}`,
|
||||
);
|
||||
if (shouldMigrate) {
|
||||
const method = direction === "up" ? migrator.migrate : migrator.rollback;
|
||||
await method.bind(migrator)(helper);
|
||||
helper.info(
|
||||
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}`,
|
||||
);
|
||||
await migrator.updateVersion(helper, direction);
|
||||
helper.info(
|
||||
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) updated version - ${direction}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
export { MigrationBuilder } from "@bitwarden/state";
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
import { FakeStorageService } from "../../spec/fake-storage.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed client type enum
|
||||
import { ClientType } from "../enums";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
import { AbstractStorageService } from "../platform/abstractions/storage.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to generate unique strings for injection
|
||||
import { Utils } from "../platform/misc/utils";
|
||||
|
||||
import { MigrationHelper, MigrationHelperType } from "./migration-helper";
|
||||
import { Migrator } from "./migrator";
|
||||
|
||||
const exampleJSON = {
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
global_serviceName_key: "global_serviceName_key",
|
||||
user_userId_serviceName_key: "user_userId_serviceName_key",
|
||||
global_account_accounts: {
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||
let storage: MockProxy<AbstractStorageService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let sut: MigrationHelper;
|
||||
|
||||
const clientTypes = Object.values(ClientType);
|
||||
|
||||
describe.each(clientTypes)("for client %s", (clientType) => {
|
||||
beforeEach(() => {
|
||||
logService = mock();
|
||||
storage = mock();
|
||||
storage.get.mockImplementation((key) => (exampleJSON as any)[key]);
|
||||
|
||||
sut = new MigrationHelper(0, storage, logService, "general", clientType);
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("should delegate to storage.get", async () => {
|
||||
await sut.get("key");
|
||||
expect(storage.get).toHaveBeenCalledWith("key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("set", () => {
|
||||
it("should delegate to storage.save", async () => {
|
||||
await sut.set("key", "value");
|
||||
expect(storage.save).toHaveBeenCalledWith("key", "value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAccounts", () => {
|
||||
it("should return all accounts", async () => {
|
||||
const accounts = await sut.getAccounts();
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
userId: "c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
account: { otherStuff: "otherStuff1" },
|
||||
},
|
||||
{
|
||||
userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
account: { otherStuff: "otherStuff2" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle missing authenticatedAccounts", async () => {
|
||||
storage.get.mockImplementation((key) =>
|
||||
key === "authenticatedAccounts" ? undefined : (exampleJSON as any)[key],
|
||||
);
|
||||
const accounts = await sut.getAccounts();
|
||||
expect(accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles global scoped known accounts for version 60 and after", async () => {
|
||||
sut.currentVersion = 60;
|
||||
const accounts = await sut.getAccounts();
|
||||
expect(accounts).toEqual([
|
||||
// Note, still gets values stored in state service objects, just grabs user ids from global
|
||||
{
|
||||
userId: "c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
account: { otherStuff: "otherStuff1" },
|
||||
},
|
||||
{
|
||||
userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
account: { otherStuff: "otherStuff2" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKnownUserIds", () => {
|
||||
it("returns all user ids", async () => {
|
||||
const userIds = await sut.getKnownUserIds();
|
||||
expect(userIds).toEqual([
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns all user ids when version is 60 or greater", async () => {
|
||||
sut.currentVersion = 60;
|
||||
const userIds = await sut.getKnownUserIds();
|
||||
expect(userIds).toEqual([
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFromGlobal", () => {
|
||||
it("should return the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
const value = await sut.getFromGlobal({
|
||||
stateDefinition: { name: "serviceName" },
|
||||
key: "key",
|
||||
});
|
||||
expect(value).toEqual("global_serviceName_key");
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.getFromGlobal({ stateDefinition: { name: "serviceName" }, key: "key" }),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setToGlobal", () => {
|
||||
it("should set the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
await sut.setToGlobal(
|
||||
{ stateDefinition: { name: "serviceName" }, key: "key" },
|
||||
"new_value",
|
||||
);
|
||||
expect(storage.save).toHaveBeenCalledWith("global_serviceName_key", "new_value");
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.setToGlobal(
|
||||
{ stateDefinition: { name: "serviceName" }, key: "key" },
|
||||
"global_serviceName_key",
|
||||
),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFromUser", () => {
|
||||
it("should return the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
const value = await sut.getFromUser("userId", {
|
||||
stateDefinition: { name: "serviceName" },
|
||||
key: "key",
|
||||
});
|
||||
expect(value).toEqual("user_userId_serviceName_key");
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.getFromUser("userId", { stateDefinition: { name: "serviceName" }, key: "key" }),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setToUser", () => {
|
||||
it("should set the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
await sut.setToUser(
|
||||
"userId",
|
||||
{ stateDefinition: { name: "serviceName" }, key: "key" },
|
||||
"new_value",
|
||||
);
|
||||
expect(storage.save).toHaveBeenCalledWith("user_userId_serviceName_key", "new_value");
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.setToUser(
|
||||
"userId",
|
||||
{ stateDefinition: { name: "serviceName" }, key: "key" },
|
||||
"new_value",
|
||||
),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Helper to create well-mocked migration helpers in migration tests */
|
||||
export function mockMigrationHelper(
|
||||
storageJson: any,
|
||||
stateVersion = 0,
|
||||
type: MigrationHelperType = "general",
|
||||
clientType: ClientType = ClientType.Web,
|
||||
): MockProxy<MigrationHelper> {
|
||||
const logService: MockProxy<LogService> = mock();
|
||||
const storage: MockProxy<AbstractStorageService> = mock();
|
||||
storage.get.mockImplementation((key) => (storageJson as any)[key]);
|
||||
storage.save.mockImplementation(async (key, value) => {
|
||||
(storageJson as any)[key] = value;
|
||||
});
|
||||
const helper = new MigrationHelper(stateVersion, storage, logService, type, clientType);
|
||||
|
||||
const mockHelper = mock<MigrationHelper>();
|
||||
mockHelper.get.mockImplementation((key) => helper.get(key));
|
||||
mockHelper.set.mockImplementation((key, value) => helper.set(key, value));
|
||||
mockHelper.getFromGlobal.mockImplementation((keyDefinition) =>
|
||||
helper.getFromGlobal(keyDefinition),
|
||||
);
|
||||
mockHelper.setToGlobal.mockImplementation((keyDefinition, value) =>
|
||||
helper.setToGlobal(keyDefinition, value),
|
||||
);
|
||||
mockHelper.getFromUser.mockImplementation((userId, keyDefinition) =>
|
||||
helper.getFromUser(userId, keyDefinition),
|
||||
);
|
||||
mockHelper.setToUser.mockImplementation((userId, keyDefinition, value) =>
|
||||
helper.setToUser(userId, keyDefinition, value),
|
||||
);
|
||||
mockHelper.getAccounts.mockImplementation(() => helper.getAccounts());
|
||||
mockHelper.getKnownUserIds.mockImplementation(() => helper.getKnownUserIds());
|
||||
mockHelper.removeFromGlobal.mockImplementation((keyDefinition) =>
|
||||
helper.removeFromGlobal(keyDefinition),
|
||||
);
|
||||
mockHelper.remove.mockImplementation((key) => helper.remove(key));
|
||||
|
||||
mockHelper.type = helper.type;
|
||||
mockHelper.clientType = helper.clientType;
|
||||
|
||||
return mockHelper;
|
||||
}
|
||||
|
||||
export type InitialDataHint<TUsers extends readonly string[]> = {
|
||||
/**
|
||||
* A string array of the users id who are authenticated
|
||||
*/
|
||||
authenticatedAccounts?: TUsers;
|
||||
/**
|
||||
* Global data
|
||||
*/
|
||||
global?: unknown;
|
||||
/**
|
||||
* Other top level data
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
} & {
|
||||
/**
|
||||
* A users data
|
||||
*/
|
||||
[userData in TUsers[number]]?: unknown;
|
||||
};
|
||||
|
||||
type InjectedData = {
|
||||
propertyName: string;
|
||||
propertyValue: string;
|
||||
originalPath: string[];
|
||||
};
|
||||
|
||||
// This is a slight lie, technically the type is `Record<string | symbol, unknown>
|
||||
// but for the purposes of things in the migrations this is enough.
|
||||
function isStringRecord(object: unknown | undefined): object is Record<string, unknown> {
|
||||
return object && typeof object === "object" && !Array.isArray(object);
|
||||
}
|
||||
|
||||
function injectData(data: Record<string, unknown>, path: string[]): InjectedData[] {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const injectedData: InjectedData[] = [];
|
||||
|
||||
// Traverse keys for other objects
|
||||
const keys = Object.keys(data);
|
||||
for (const key of keys) {
|
||||
const currentProperty = data[key];
|
||||
if (isStringRecord(currentProperty)) {
|
||||
injectedData.push(...injectData(currentProperty, [...path, key]));
|
||||
}
|
||||
}
|
||||
|
||||
const propertyName = `__injectedProperty__${Utils.newGuid()}`;
|
||||
const propertyValue = `__injectedValue__${Utils.newGuid()}`;
|
||||
|
||||
injectedData.push({
|
||||
propertyName: propertyName,
|
||||
propertyValue: propertyValue,
|
||||
// Track the path it was originally injected in just for a better error
|
||||
originalPath: path,
|
||||
});
|
||||
data[propertyName] = propertyValue;
|
||||
return injectedData;
|
||||
}
|
||||
|
||||
function expectInjectedData(
|
||||
data: Record<string, unknown>,
|
||||
injectedData: InjectedData[],
|
||||
): [data: Record<string, unknown>, leftoverInjectedData: InjectedData[]] {
|
||||
const keys = Object.keys(data);
|
||||
for (const key of keys) {
|
||||
const propertyValue = data[key];
|
||||
// Injected data does not have to be found exactly where it was injected,
|
||||
// just that it exists at all.
|
||||
const injectedIndex = injectedData.findIndex(
|
||||
(d) =>
|
||||
d.propertyName === key &&
|
||||
typeof propertyValue === "string" &&
|
||||
propertyValue === d.propertyValue,
|
||||
);
|
||||
|
||||
if (injectedIndex !== -1) {
|
||||
// We found something we injected, remove it
|
||||
injectedData.splice(injectedIndex, 1);
|
||||
delete data[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isStringRecord(propertyValue)) {
|
||||
const [updatedData, leftoverInjectedData] = expectInjectedData(propertyValue, injectedData);
|
||||
data[key] = updatedData;
|
||||
injectedData = leftoverInjectedData;
|
||||
}
|
||||
}
|
||||
|
||||
return [data, injectedData];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the {@link Migrator.migrate} method of your migrator. You may pass in your test data and get back the data after the migration.
|
||||
* This also injects extra properties at every level of your state and makes sure that it can be found.
|
||||
* @param migrator Your migrator to use to do the migration
|
||||
* @param initalData The data to start with
|
||||
* @returns State after your migration has ran.
|
||||
*/
|
||||
export async function runMigrator<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
const TUsers extends readonly string[],
|
||||
>(
|
||||
migrator: TMigrator,
|
||||
initalData?: InitialDataHint<TUsers>,
|
||||
direction: "migrate" | "rollback" = "migrate",
|
||||
): Promise<Record<string, unknown>> {
|
||||
const clonedData = JSON.parse(JSON.stringify(initalData ?? {}));
|
||||
|
||||
// Inject fake data at every level of the object
|
||||
const allInjectedData = injectData(clonedData, []);
|
||||
|
||||
const fakeStorageService = new FakeStorageService(clonedData);
|
||||
const helper = new MigrationHelper(
|
||||
migrator.fromVersion,
|
||||
fakeStorageService,
|
||||
mock(),
|
||||
"general",
|
||||
ClientType.Web,
|
||||
);
|
||||
|
||||
// Run their migrations
|
||||
if (direction === "rollback") {
|
||||
await migrator.rollback(helper);
|
||||
} else {
|
||||
await migrator.migrate(helper);
|
||||
}
|
||||
const [data, leftoverInjectedData] = expectInjectedData(
|
||||
fakeStorageService.internalStore,
|
||||
allInjectedData,
|
||||
);
|
||||
expect(leftoverInjectedData).toHaveLength(0);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -1,261 +1 @@
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to provide client type to migrations
|
||||
import { ClientType } from "../enums";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
import { AbstractStorageService } from "../platform/abstractions/storage.service";
|
||||
|
||||
export type StateDefinitionLike = { name: string };
|
||||
export type KeyDefinitionLike = {
|
||||
stateDefinition: StateDefinitionLike;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type MigrationHelperType = "general" | "web-disk-local";
|
||||
|
||||
export class MigrationHelper {
|
||||
constructor(
|
||||
public currentVersion: number,
|
||||
private storageService: AbstractStorageService,
|
||||
public logService: LogService,
|
||||
type: MigrationHelperType,
|
||||
public clientType: ClientType,
|
||||
) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* On some clients, migrations are ran multiple times without direct action from the migration writer.
|
||||
*
|
||||
* All clients will run through migrations at least once, this run is referred to as `"general"`. If a migration is
|
||||
* ran more than that single time, they will get a unique name if that the write can make conditional logic based on which
|
||||
* migration run this is.
|
||||
*
|
||||
* @remarks The preferrable way of writing migrations is ALWAYS to be defensive and reflect on the data you are given back. This
|
||||
* should really only be used when reflecting on the data given isn't enough.
|
||||
*/
|
||||
type: MigrationHelperType;
|
||||
|
||||
/**
|
||||
* Gets a value from the storage service at the given key.
|
||||
*
|
||||
* This is a brute force method to just get a value from the storage service. If you can use {@link getFromGlobal} or {@link getFromUser}, you should.
|
||||
* @param key location
|
||||
* @returns the value at the location
|
||||
*/
|
||||
get<T>(key: string): Promise<T> {
|
||||
return this.storageService.get<T>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value in the storage service at the given key.
|
||||
*
|
||||
* This is a brute force method to just set a value in the storage service. If you can use {@link setToGlobal} or {@link setToUser}, you should.
|
||||
* @param key location
|
||||
* @param value the value to set
|
||||
* @returns
|
||||
*/
|
||||
set<T>(key: string, value: T): Promise<void> {
|
||||
this.logService.info(`Setting ${key}`);
|
||||
return this.storageService.save(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a value in the storage service at the given key.
|
||||
*
|
||||
* This is a brute force method to just remove a value in the storage service. If you can use {@link removeFromGlobal} or {@link removeFromUser}, you should.
|
||||
* @param key location
|
||||
* @returns void
|
||||
*/
|
||||
remove(key: string): Promise<void> {
|
||||
this.logService.info(`Removing ${key}`);
|
||||
return this.storageService.remove(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a globally scoped value from a location derived through the key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link get} for those.
|
||||
* @param keyDefinition unique key definition
|
||||
* @returns value from store
|
||||
*/
|
||||
getFromGlobal<T>(keyDefinition: KeyDefinitionLike): Promise<T> {
|
||||
return this.get<T>(this.getGlobalKey(keyDefinition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a globally scoped value to a location derived through the key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link set} for those.
|
||||
* @param keyDefinition unique key definition
|
||||
* @param value value to store
|
||||
* @returns void
|
||||
*/
|
||||
setToGlobal<T>(keyDefinition: KeyDefinitionLike, value: T): Promise<void> {
|
||||
return this.set(this.getGlobalKey(keyDefinition), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a globally scoped location derived through the key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link remove} for those.
|
||||
* @param keyDefinition unique key definition
|
||||
* @returns void
|
||||
*/
|
||||
removeFromGlobal(keyDefinition: KeyDefinitionLike): Promise<void> {
|
||||
return this.remove(this.getGlobalKey(keyDefinition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user scoped value from a location derived through the user id and key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link get} for those.
|
||||
* @param userId userId to use in the key
|
||||
* @param keyDefinition unique key definition
|
||||
* @returns value from store
|
||||
*/
|
||||
getFromUser<T>(userId: string, keyDefinition: KeyDefinitionLike): Promise<T> {
|
||||
return this.get<T>(this.getUserKey(userId, keyDefinition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a user scoped value to a location derived through the user id and key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link set} for those.
|
||||
* @param userId userId to use in the key
|
||||
* @param keyDefinition unique key definition
|
||||
* @param value value to store
|
||||
* @returns void
|
||||
*/
|
||||
setToUser<T>(userId: string, keyDefinition: KeyDefinitionLike, value: T): Promise<void> {
|
||||
return this.set(this.getUserKey(userId, keyDefinition), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user scoped location derived through the key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link remove} for those.
|
||||
* @param keyDefinition unique key definition
|
||||
* @returns void
|
||||
*/
|
||||
removeFromUser(userId: string, keyDefinition: KeyDefinitionLike): Promise<void> {
|
||||
return this.remove(this.getUserKey(userId, keyDefinition));
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.logService.info(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to read all Account objects stored by the State Service.
|
||||
*
|
||||
* This is useful from creating migrations off of this paradigm, but should not be used once a value is migrated to a state provider.
|
||||
*
|
||||
* @returns a list of all accounts that have been authenticated with state service, cast the expected type.
|
||||
*/
|
||||
async getAccounts<ExpectedAccountType>(): Promise<
|
||||
{ userId: string; account: ExpectedAccountType }[]
|
||||
> {
|
||||
const userIds = await this.getKnownUserIds();
|
||||
return Promise.all(
|
||||
userIds.map(async (userId) => ({
|
||||
userId,
|
||||
account: await this.get<ExpectedAccountType>(userId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to read known users ids.
|
||||
*/
|
||||
async getKnownUserIds(): Promise<string[]> {
|
||||
if (this.currentVersion < 60) {
|
||||
return knownAccountUserIdsBuilderPre60(this.storageService);
|
||||
} else {
|
||||
return knownAccountUserIdsBuilder(this.storageService);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a user storage key appropriate for the current version.
|
||||
*
|
||||
* @param userId userId to use in the key
|
||||
* @param keyDefinition state and key to use in the key
|
||||
* @returns
|
||||
*/
|
||||
private getUserKey(userId: string, keyDefinition: KeyDefinitionLike): string {
|
||||
if (this.currentVersion < 9) {
|
||||
return userKeyBuilderPre9();
|
||||
} else {
|
||||
return userKeyBuilder(userId, keyDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a global storage key appropriate for the current version.
|
||||
*
|
||||
* @param keyDefinition state and key to use in the key
|
||||
* @returns
|
||||
*/
|
||||
private getGlobalKey(keyDefinition: KeyDefinitionLike): string {
|
||||
if (this.currentVersion < 9) {
|
||||
return globalKeyBuilderPre9();
|
||||
} else {
|
||||
return globalKeyBuilder(keyDefinition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When this is updated, rename this function to `userKeyBuilderXToY` where `X` is the version number it
|
||||
* became relevant, and `Y` prior to the version it was updated.
|
||||
*
|
||||
* Be sure to update the map in `MigrationHelper` to point to the appropriate function for the current version.
|
||||
* @param userId The userId of the user you want the key to be for.
|
||||
* @param keyDefinition the key definition of which data the key should point to.
|
||||
* @returns
|
||||
*/
|
||||
function userKeyBuilder(userId: string, keyDefinition: KeyDefinitionLike): string {
|
||||
return `user_${userId}_${keyDefinition.stateDefinition.name}_${keyDefinition.key}`;
|
||||
}
|
||||
|
||||
function userKeyBuilderPre9(): string {
|
||||
throw Error("No key builder should be used for versions prior to 9.");
|
||||
}
|
||||
|
||||
/**
|
||||
* When this is updated, rename this function to `globalKeyBuilderXToY` where `X` is the version number
|
||||
* it became relevant, and `Y` prior to the version it was updated.
|
||||
*
|
||||
* Be sure to update the map in `MigrationHelper` to point to the appropriate function for the current version.
|
||||
* @param keyDefinition the key definition of which data the key should point to.
|
||||
* @returns
|
||||
*/
|
||||
function globalKeyBuilder(keyDefinition: KeyDefinitionLike): string {
|
||||
return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}`;
|
||||
}
|
||||
|
||||
function globalKeyBuilderPre9(): string {
|
||||
throw Error("No key builder should be used for versions prior to 9.");
|
||||
}
|
||||
|
||||
async function knownAccountUserIdsBuilderPre60(
|
||||
storageService: AbstractStorageService,
|
||||
): Promise<string[]> {
|
||||
return (await storageService.get<string[]>("authenticatedAccounts")) ?? [];
|
||||
}
|
||||
|
||||
async function knownAccountUserIdsBuilder(
|
||||
storageService: AbstractStorageService,
|
||||
): Promise<string[]> {
|
||||
const accounts = await storageService.get<Record<string, unknown>>(
|
||||
globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }),
|
||||
);
|
||||
return Object.keys(accounts ?? {});
|
||||
}
|
||||
export { MigrationHelper } from "@bitwarden/state";
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { EverHadUserKeyMigrator } from "./10-move-ever-had-user-key-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
profile: {
|
||||
everHadUserKey: false,
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
profile: {
|
||||
everHadUserKey: true,
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_c493ed01-4e08-4e88-abc7-332f380ca760_crypto_everHadUserKey": false,
|
||||
"user_23e61a5f-2ece-4f5e-b499-f0bc489482a9_crypto_everHadUserKey": true,
|
||||
"user_fd005ea6-a16a-45ef-ba4a-a194269bfd73_crypto_everHadUserKey": false,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
profile: {
|
||||
everHadUserKey: false,
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
profile: {
|
||||
everHadUserKey: true,
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("EverHadUserKeyMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: EverHadUserKeyMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "everHadUserKey",
|
||||
stateDefinition: {
|
||||
name: "crypto",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 9);
|
||||
sut = new EverHadUserKeyMigrator(9, 10);
|
||||
});
|
||||
|
||||
it("should remove everHadUserKey from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
profile: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set everHadUserKey provider value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
keyDefinitionLike,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
keyDefinitionLike,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
|
||||
keyDefinitionLike,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 10);
|
||||
sut = new EverHadUserKeyMigrator(9, 10);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
|
||||
])("should null out new values", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
profile: {
|
||||
everHadUserKey: false,
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
|
||||
profile: {
|
||||
everHadUserKey: true,
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("fd005ea6-a16a-45ef-ba4a-a194269bfd73", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
profile?: {
|
||||
everHadUserKey?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const USER_EVER_HAD_USER_KEY: KeyDefinitionLike = {
|
||||
key: "everHadUserKey",
|
||||
stateDefinition: {
|
||||
name: "crypto",
|
||||
},
|
||||
};
|
||||
|
||||
export class EverHadUserKeyMigrator extends Migrator<9, 10> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.profile?.everHadUserKey;
|
||||
await helper.setToUser(userId, USER_EVER_HAD_USER_KEY, value ?? false);
|
||||
if (value != null) {
|
||||
delete account.profile.everHadUserKey;
|
||||
}
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser(userId, USER_EVER_HAD_USER_KEY);
|
||||
if (account) {
|
||||
account.profile = Object.assign(account.profile ?? {}, {
|
||||
everHadUserKey: value,
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, USER_EVER_HAD_USER_KEY, null);
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { OrganizationKeyMigrator } from "./11-move-org-keys-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
"org-id-1": {
|
||||
type: "organization",
|
||||
key: "org-key-1",
|
||||
},
|
||||
"org-id-2": {
|
||||
type: "provider",
|
||||
key: "org-key-2",
|
||||
providerId: "provider-id-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_crypto_organizationKeys": {
|
||||
"org-id-1": {
|
||||
type: "organization",
|
||||
key: "org-key-1",
|
||||
},
|
||||
"org-id-2": {
|
||||
type: "provider",
|
||||
key: "org-key-2",
|
||||
providerId: "provider-id-2",
|
||||
},
|
||||
},
|
||||
"user_user-2_crypto_organizationKeys": null as any,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
keys: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("OrganizationKeysMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: OrganizationKeyMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "organizationKeys",
|
||||
stateDefinition: {
|
||||
name: "crypto",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 10);
|
||||
sut = new OrganizationKeyMigrator(10, 11);
|
||||
});
|
||||
|
||||
it("should remove organizationKeys from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
keys: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set organizationKeys value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(1);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
|
||||
"org-id-1": {
|
||||
type: "organization",
|
||||
key: "org-key-1",
|
||||
},
|
||||
"org-id-2": {
|
||||
type: "provider",
|
||||
key: "org-key-2",
|
||||
providerId: "provider-id-2",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 11);
|
||||
sut = new OrganizationKeyMigrator(10, 11);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2", "user-3"])("should null out new values %s", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
"org-id-1": {
|
||||
type: "organization",
|
||||
key: "org-key-1",
|
||||
},
|
||||
"org-id-2": {
|
||||
type: "provider",
|
||||
key: "org-key-2",
|
||||
providerId: "provider-id-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type OrgKeyDataType = {
|
||||
type: "organization" | "provider";
|
||||
key: string;
|
||||
providerId?: string;
|
||||
};
|
||||
|
||||
type ExpectedAccountType = {
|
||||
keys?: {
|
||||
organizationKeys?: {
|
||||
encrypted?: Record<string, OrgKeyDataType>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const USER_ENCRYPTED_ORGANIZATION_KEYS: KeyDefinitionLike = {
|
||||
key: "organizationKeys",
|
||||
stateDefinition: {
|
||||
name: "crypto",
|
||||
},
|
||||
};
|
||||
|
||||
export class OrganizationKeyMigrator extends Migrator<10, 11> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.keys?.organizationKeys?.encrypted;
|
||||
if (value != null) {
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_ORGANIZATION_KEYS, value);
|
||||
delete account.keys.organizationKeys;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser<Record<string, OrgKeyDataType>>(
|
||||
userId,
|
||||
USER_ENCRYPTED_ORGANIZATION_KEYS,
|
||||
);
|
||||
if (account && value) {
|
||||
account.keys = Object.assign(account.keys ?? {}, {
|
||||
organizationKeys: {
|
||||
encrypted: value,
|
||||
},
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_ORGANIZATION_KEYS, null);
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { runMigrator } from "../migration-helper.spec";
|
||||
|
||||
import { MoveEnvironmentStateToProviders } from "./12-move-environment-state-to-providers";
|
||||
|
||||
describe("MoveEnvironmentStateToProviders", () => {
|
||||
const migrator = new MoveEnvironmentStateToProviders(11, 12);
|
||||
|
||||
it("can migrate all data", async () => {
|
||||
const output = await runMigrator(migrator, {
|
||||
authenticatedAccounts: ["user1", "user2"] as const,
|
||||
global: {
|
||||
region: "US",
|
||||
environmentUrls: {
|
||||
base: "example.com",
|
||||
},
|
||||
extra: "data",
|
||||
},
|
||||
user1: {
|
||||
extra: "data",
|
||||
settings: {
|
||||
extra: "data",
|
||||
region: "US",
|
||||
environmentUrls: {
|
||||
base: "example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
user2: {
|
||||
extra: "data",
|
||||
settings: {
|
||||
region: "EU",
|
||||
environmentUrls: {
|
||||
base: "other.example.com",
|
||||
},
|
||||
extra: "data",
|
||||
},
|
||||
},
|
||||
extra: "data",
|
||||
});
|
||||
|
||||
expect(output).toEqual({
|
||||
authenticatedAccounts: ["user1", "user2"],
|
||||
global: {
|
||||
extra: "data",
|
||||
},
|
||||
global_environment_region: "US",
|
||||
global_environment_urls: {
|
||||
base: "example.com",
|
||||
},
|
||||
user1: {
|
||||
extra: "data",
|
||||
settings: {
|
||||
extra: "data",
|
||||
},
|
||||
},
|
||||
user2: {
|
||||
extra: "data",
|
||||
settings: {
|
||||
extra: "data",
|
||||
},
|
||||
},
|
||||
extra: "data",
|
||||
user_user1_environment_region: "US",
|
||||
user_user2_environment_region: "EU",
|
||||
user_user1_environment_urls: {
|
||||
base: "example.com",
|
||||
},
|
||||
user_user2_environment_urls: {
|
||||
base: "other.example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("handles missing parts", async () => {
|
||||
const output = await runMigrator(migrator, {
|
||||
authenticatedAccounts: ["user1", "user2"],
|
||||
global: {
|
||||
extra: "data",
|
||||
},
|
||||
user1: {
|
||||
extra: "data",
|
||||
settings: {
|
||||
extra: "data",
|
||||
},
|
||||
},
|
||||
user2: null,
|
||||
});
|
||||
|
||||
expect(output).toEqual({
|
||||
authenticatedAccounts: ["user1", "user2"],
|
||||
global: {
|
||||
extra: "data",
|
||||
},
|
||||
user1: {
|
||||
extra: "data",
|
||||
settings: {
|
||||
extra: "data",
|
||||
},
|
||||
},
|
||||
user2: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("can migrate only global data", async () => {
|
||||
const output = await runMigrator(migrator, {
|
||||
authenticatedAccounts: [] as const,
|
||||
global: {
|
||||
region: "Self-Hosted",
|
||||
},
|
||||
});
|
||||
|
||||
expect(output).toEqual({
|
||||
authenticatedAccounts: [],
|
||||
global_environment_region: "Self-Hosted",
|
||||
global: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("can migrate only user state", async () => {
|
||||
const output = await runMigrator(migrator, {
|
||||
authenticatedAccounts: ["user1"] as const,
|
||||
global: null,
|
||||
user1: {
|
||||
settings: {
|
||||
region: "Self-Hosted",
|
||||
environmentUrls: {
|
||||
base: "some-base-url",
|
||||
api: "some-api-url",
|
||||
identity: "some-identity-url",
|
||||
icons: "some-icons-url",
|
||||
notifications: "some-notifications-url",
|
||||
events: "some-events-url",
|
||||
webVault: "some-webVault-url",
|
||||
keyConnector: "some-keyConnector-url",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(output).toEqual({
|
||||
authenticatedAccounts: ["user1"] as const,
|
||||
global: null,
|
||||
user1: { settings: {} },
|
||||
user_user1_environment_region: "Self-Hosted",
|
||||
user_user1_environment_urls: {
|
||||
base: "some-base-url",
|
||||
api: "some-api-url",
|
||||
identity: "some-identity-url",
|
||||
icons: "some-icons-url",
|
||||
notifications: "some-notifications-url",
|
||||
events: "some-events-url",
|
||||
webVault: "some-webVault-url",
|
||||
keyConnector: "some-keyConnector-url",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type EnvironmentUrls = Record<string, string>;
|
||||
|
||||
type ExpectedAccountType = {
|
||||
settings?: { region?: string; environmentUrls?: EnvironmentUrls };
|
||||
};
|
||||
|
||||
type ExpectedGlobalType = { region?: string; environmentUrls?: EnvironmentUrls };
|
||||
|
||||
const ENVIRONMENT_STATE: StateDefinitionLike = { name: "environment" };
|
||||
|
||||
const REGION_KEY: KeyDefinitionLike = { key: "region", stateDefinition: ENVIRONMENT_STATE };
|
||||
const URLS_KEY: KeyDefinitionLike = { key: "urls", stateDefinition: ENVIRONMENT_STATE };
|
||||
|
||||
export class MoveEnvironmentStateToProviders extends Migrator<11, 12> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const legacyGlobal = await helper.get<ExpectedGlobalType>("global");
|
||||
|
||||
// Move global data
|
||||
if (legacyGlobal?.region != null) {
|
||||
await helper.setToGlobal(REGION_KEY, legacyGlobal.region);
|
||||
}
|
||||
|
||||
if (legacyGlobal?.environmentUrls != null) {
|
||||
await helper.setToGlobal(URLS_KEY, legacyGlobal.environmentUrls);
|
||||
}
|
||||
|
||||
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(
|
||||
legacyAccounts.map(async ({ userId, account }) => {
|
||||
// Move account data
|
||||
if (account?.settings?.region != null) {
|
||||
await helper.setToUser(userId, REGION_KEY, account.settings.region);
|
||||
}
|
||||
|
||||
if (account?.settings?.environmentUrls != null) {
|
||||
await helper.setToUser(userId, URLS_KEY, account.settings.environmentUrls);
|
||||
}
|
||||
|
||||
// Delete old account data
|
||||
delete account?.settings?.region;
|
||||
delete account?.settings?.environmentUrls;
|
||||
await helper.set(userId, account);
|
||||
}),
|
||||
);
|
||||
|
||||
// Delete legacy global data
|
||||
delete legacyGlobal?.region;
|
||||
delete legacyGlobal?.environmentUrls;
|
||||
await helper.set("global", legacyGlobal);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
let legacyGlobal = await helper.get<ExpectedGlobalType>("global");
|
||||
|
||||
let updatedLegacyGlobal = false;
|
||||
|
||||
const globalRegion = await helper.getFromGlobal<string>(REGION_KEY);
|
||||
|
||||
if (globalRegion) {
|
||||
if (!legacyGlobal) {
|
||||
legacyGlobal = {};
|
||||
}
|
||||
|
||||
updatedLegacyGlobal = true;
|
||||
legacyGlobal.region = globalRegion;
|
||||
await helper.setToGlobal(REGION_KEY, null);
|
||||
}
|
||||
|
||||
const globalUrls = await helper.getFromGlobal<EnvironmentUrls>(URLS_KEY);
|
||||
|
||||
if (globalUrls) {
|
||||
if (!legacyGlobal) {
|
||||
legacyGlobal = {};
|
||||
}
|
||||
|
||||
updatedLegacyGlobal = true;
|
||||
legacyGlobal.environmentUrls = globalUrls;
|
||||
await helper.setToGlobal(URLS_KEY, null);
|
||||
}
|
||||
|
||||
if (updatedLegacyGlobal) {
|
||||
await helper.set("global", legacyGlobal);
|
||||
}
|
||||
|
||||
async function rollbackUser(userId: string, account: ExpectedAccountType) {
|
||||
let updatedAccount = false;
|
||||
const userRegion = await helper.getFromUser<string>(userId, REGION_KEY);
|
||||
|
||||
if (userRegion) {
|
||||
if (!account) {
|
||||
account = {};
|
||||
}
|
||||
|
||||
if (!account.settings) {
|
||||
account.settings = {};
|
||||
}
|
||||
|
||||
updatedAccount = true;
|
||||
account.settings.region = userRegion;
|
||||
await helper.setToUser(userId, REGION_KEY, null);
|
||||
}
|
||||
|
||||
const userUrls = await helper.getFromUser<EnvironmentUrls>(userId, URLS_KEY);
|
||||
|
||||
if (userUrls) {
|
||||
if (!account) {
|
||||
account = {};
|
||||
}
|
||||
|
||||
if (!account.settings) {
|
||||
account.settings = {};
|
||||
}
|
||||
|
||||
updatedAccount = true;
|
||||
account.settings.environmentUrls = userUrls;
|
||||
await helper.setToUser(userId, URLS_KEY, null);
|
||||
}
|
||||
|
||||
if (updatedAccount) {
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { ProviderKeyMigrator } from "./13-move-provider-keys-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
keys: {
|
||||
providerKeys: {
|
||||
encrypted: {
|
||||
"provider-id-1": "provider-key-1",
|
||||
"provider-id-2": "provider-key-2",
|
||||
},
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_crypto_providerKeys": {
|
||||
"provider-id-1": "provider-key-1",
|
||||
"provider-id-2": "provider-key-2",
|
||||
},
|
||||
"user_user-2_crypto_providerKeys": null as any,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
keys: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProviderKeysMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: ProviderKeyMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "providerKeys",
|
||||
stateDefinition: {
|
||||
name: "crypto",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 12);
|
||||
sut = new ProviderKeyMigrator(12, 13);
|
||||
});
|
||||
|
||||
it("should remove providerKeys from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
keys: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set providerKeys value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(1);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
|
||||
"provider-id-1": "provider-key-1",
|
||||
"provider-id-2": "provider-key-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 13);
|
||||
sut = new ProviderKeyMigrator(12, 13);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2", "user-3"])("should null out new values %s", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
keys: {
|
||||
providerKeys: {
|
||||
encrypted: {
|
||||
"provider-id-1": "provider-key-1",
|
||||
"provider-id-2": "provider-key-2",
|
||||
},
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
keys?: {
|
||||
providerKeys?: {
|
||||
encrypted?: Record<string, string>; // Record<ProviderId, EncryptedString> where EncryptedString is the ProviderKey encrypted by the UserKey.
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const USER_ENCRYPTED_PROVIDER_KEYS: KeyDefinitionLike = {
|
||||
key: "providerKeys",
|
||||
stateDefinition: {
|
||||
name: "crypto",
|
||||
},
|
||||
};
|
||||
|
||||
export class ProviderKeyMigrator extends Migrator<12, 13> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.keys?.providerKeys?.encrypted;
|
||||
if (value != null) {
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_PROVIDER_KEYS, value);
|
||||
delete account.keys.providerKeys;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser<Record<string, string>>(
|
||||
userId,
|
||||
USER_ENCRYPTED_PROVIDER_KEYS,
|
||||
);
|
||||
if (account && value) {
|
||||
account.keys = Object.assign(account.keys ?? {}, {
|
||||
providerKeys: {
|
||||
encrypted: value,
|
||||
},
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_PROVIDER_KEYS, null);
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import {
|
||||
MoveBiometricClientKeyHalfToStateProviders,
|
||||
CLIENT_KEY_HALF,
|
||||
} from "./14-move-biometric-client-key-half-state-to-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
keys: {
|
||||
biometricEncryptionClientKeyHalf: "user1-key-half",
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_biometricSettings_clientKeyHalf": "user1-key-half",
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
keys: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("DesktopBiometricState migrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: MoveBiometricClientKeyHalfToStateProviders;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 13);
|
||||
sut = new MoveBiometricClientKeyHalfToStateProviders(13, 14);
|
||||
});
|
||||
|
||||
it("should remove biometricEncryptionClientKeyHalf from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
keys: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set biometricEncryptionClientKeyHalf value for account that have it", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", CLIENT_KEY_HALF, "user1-key-half");
|
||||
});
|
||||
|
||||
it("should not call extra setToUser", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 14);
|
||||
sut = new MoveBiometricClientKeyHalfToStateProviders(13, 14);
|
||||
});
|
||||
|
||||
it("should null out new values", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", CLIENT_KEY_HALF, null);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
keys: {
|
||||
biometricEncryptionClientKeyHalf: "user1-key-half",
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["user-2", "user-3"])(
|
||||
"should not try to restore values to missing accounts",
|
||||
async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
settings?: {
|
||||
disableAutoBiometricsPrompt?: boolean;
|
||||
biometricUnlock?: boolean;
|
||||
dismissedBiometricRequirePasswordOnStartCallout?: boolean;
|
||||
};
|
||||
keys?: { biometricEncryptionClientKeyHalf?: string };
|
||||
};
|
||||
|
||||
// Biometric text, no auto prompt text, fingerprint validated, and prompt cancelled are refreshed on every app start, so we don't need to migrate them
|
||||
export const CLIENT_KEY_HALF: KeyDefinitionLike = {
|
||||
key: "clientKeyHalf",
|
||||
stateDefinition: { name: "biometricSettings" },
|
||||
};
|
||||
|
||||
export class MoveBiometricClientKeyHalfToStateProviders extends Migrator<13, 14> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(
|
||||
legacyAccounts.map(async ({ userId, account }) => {
|
||||
// Move account data
|
||||
if (account?.keys?.biometricEncryptionClientKeyHalf != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
CLIENT_KEY_HALF,
|
||||
account.keys.biometricEncryptionClientKeyHalf,
|
||||
);
|
||||
|
||||
// Delete old account data
|
||||
delete account?.keys?.biometricEncryptionClientKeyHalf;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
async function rollbackUser(userId: string, account: ExpectedAccountType) {
|
||||
let updatedAccount = false;
|
||||
|
||||
const userKeyHalf = await helper.getFromUser<string>(userId, CLIENT_KEY_HALF);
|
||||
|
||||
if (userKeyHalf) {
|
||||
account ??= {};
|
||||
account.keys ??= {};
|
||||
|
||||
updatedAccount = true;
|
||||
account.keys.biometricEncryptionClientKeyHalf = userKeyHalf;
|
||||
await helper.setToUser(userId, CLIENT_KEY_HALF, null);
|
||||
}
|
||||
|
||||
if (updatedAccount) {
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { FolderMigrator } from "./15-move-folder-state-to-state-provider";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
data: {
|
||||
folders: {
|
||||
encrypted: {
|
||||
"folder-id-1": {
|
||||
id: "folder-id-1",
|
||||
name: "folder-name-1",
|
||||
revisionDate: "folder-revision-date-1",
|
||||
},
|
||||
"folder-id-2": {
|
||||
id: "folder-id-2",
|
||||
name: "folder-name-2",
|
||||
revisionDate: "folder-revision-date-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
data: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_folder_folders": {
|
||||
"folder-id-1": {
|
||||
id: "folder-id-1",
|
||||
name: "folder-name-1",
|
||||
revisionDate: "folder-revision-date-1",
|
||||
},
|
||||
"folder-id-2": {
|
||||
id: "folder-id-2",
|
||||
name: "folder-name-2",
|
||||
revisionDate: "folder-revision-date-2",
|
||||
},
|
||||
},
|
||||
"user_user-2_folder_folders": null as any,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
data: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
data: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("FolderMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: FolderMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "folders",
|
||||
stateDefinition: {
|
||||
name: "folder",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 14);
|
||||
sut = new FolderMigrator(14, 15);
|
||||
});
|
||||
|
||||
it("should remove folders from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
data: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set folders value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
|
||||
"folder-id-1": {
|
||||
id: "folder-id-1",
|
||||
name: "folder-name-1",
|
||||
revisionDate: "folder-revision-date-1",
|
||||
},
|
||||
"folder-id-2": {
|
||||
id: "folder-id-2",
|
||||
name: "folder-name-2",
|
||||
revisionDate: "folder-revision-date-2",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 15);
|
||||
sut = new FolderMigrator(14, 15);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
data: {
|
||||
folders: {
|
||||
encrypted: {
|
||||
"folder-id-1": {
|
||||
id: "folder-id-1",
|
||||
name: "folder-name-1",
|
||||
revisionDate: "folder-revision-date-1",
|
||||
},
|
||||
"folder-id-2": {
|
||||
id: "folder-id-2",
|
||||
name: "folder-name-2",
|
||||
revisionDate: "folder-revision-date-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type FolderDataType = {
|
||||
id: string;
|
||||
name: string;
|
||||
revisionDate: string;
|
||||
};
|
||||
|
||||
type ExpectedAccountType = {
|
||||
data?: {
|
||||
folders?: {
|
||||
encrypted?: Record<string, FolderDataType>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const USER_ENCRYPTED_FOLDERS: KeyDefinitionLike = {
|
||||
key: "folders",
|
||||
stateDefinition: {
|
||||
name: "folder",
|
||||
},
|
||||
};
|
||||
|
||||
export class FolderMigrator extends Migrator<14, 15> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.data?.folders?.encrypted;
|
||||
if (value != null) {
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_FOLDERS, value);
|
||||
delete account.data.folders;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser(userId, USER_ENCRYPTED_FOLDERS);
|
||||
if (account) {
|
||||
account.data = Object.assign(account.data ?? {}, {
|
||||
folders: {
|
||||
encrypted: value,
|
||||
},
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_FOLDERS, null);
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { any, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { LastSyncMigrator } from "./16-move-last-sync-to-state-provider";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
profile: {
|
||||
lastSync: "2024-01-24T00:00:00.000Z",
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_sync_lastSync": "2024-01-24T00:00:00.000Z",
|
||||
"user_user-2_sync_lastSync": null as any,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
profile: {
|
||||
lastSync: "2024-01-24T00:00:00.000Z",
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("LastSyncMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: LastSyncMigrator;
|
||||
|
||||
const keyDefinitionLike = {
|
||||
key: "lastSync",
|
||||
stateDefinition: {
|
||||
name: "sync",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 15);
|
||||
sut = new LastSyncMigrator(15, 16);
|
||||
});
|
||||
|
||||
it("should remove lastSync from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set lastSync provider value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
keyDefinitionLike,
|
||||
"2024-01-24T00:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-2", keyDefinitionLike, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 16);
|
||||
sut = new LastSyncMigrator(15, 16);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add lastSync back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
profile: {
|
||||
lastSync: "2024-01-24T00:00:00.000Z",
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-2", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
profile?: {
|
||||
lastSync?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const LAST_SYNC_KEY: KeyDefinitionLike = {
|
||||
key: "lastSync",
|
||||
stateDefinition: {
|
||||
name: "sync",
|
||||
},
|
||||
};
|
||||
|
||||
export class LastSyncMigrator extends Migrator<15, 16> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.profile?.lastSync;
|
||||
await helper.setToUser(userId, LAST_SYNC_KEY, value ?? null);
|
||||
if (value != null) {
|
||||
delete account.profile.lastSync;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser(userId, LAST_SYNC_KEY);
|
||||
if (account) {
|
||||
account.profile = Object.assign(account.profile ?? {}, {
|
||||
lastSync: value,
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, LAST_SYNC_KEY, null);
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { EnablePasskeysMigrator } from "./17-move-enable-passkeys-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
enablePasskeys: true,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
global_vaultSettings_enablePasskeys: true,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("EnablePasskeysMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: EnablePasskeysMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 16);
|
||||
sut = new EnablePasskeysMigrator(16, 17);
|
||||
});
|
||||
|
||||
it("should remove enablePasskeys from global", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 17);
|
||||
sut = new EnablePasskeysMigrator(16, 17);
|
||||
});
|
||||
|
||||
it("should move enablePasskeys to global", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
enablePasskeys: true,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedGlobalType = {
|
||||
enablePasskeys?: boolean;
|
||||
};
|
||||
|
||||
const USER_ENABLE_PASSKEYS: KeyDefinitionLike = {
|
||||
key: "enablePasskeys",
|
||||
stateDefinition: {
|
||||
name: "vaultSettings",
|
||||
},
|
||||
};
|
||||
|
||||
export class EnablePasskeysMigrator extends Migrator<16, 17> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const global = await helper.get<ExpectedGlobalType>("global");
|
||||
|
||||
if (global?.enablePasskeys != null) {
|
||||
await helper.setToGlobal(USER_ENABLE_PASSKEYS, global.enablePasskeys);
|
||||
delete global?.enablePasskeys;
|
||||
await helper.set("global", global);
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
let global = await helper.get<ExpectedGlobalType>("global");
|
||||
const globalEnablePasskeys = await helper.getFromGlobal<boolean>(USER_ENABLE_PASSKEYS);
|
||||
|
||||
if (globalEnablePasskeys != null) {
|
||||
global = Object.assign(global ?? {}, { enablePasskeys: globalEnablePasskeys });
|
||||
await helper.set("global", global);
|
||||
await helper.setToGlobal(USER_ENABLE_PASSKEYS, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { any, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { AutofillSettingsKeyMigrator } from "./18-move-autofill-settings-to-state-providers";
|
||||
|
||||
const AutofillOverlayVisibility = {
|
||||
Off: 0,
|
||||
OnButtonClick: 1,
|
||||
OnFieldFocus: 2,
|
||||
} as const;
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
autoFillOverlayVisibility: AutofillOverlayVisibility.OnButtonClick,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
autoFillOnPageLoadDefault: true,
|
||||
enableAutoFillOnPageLoad: true,
|
||||
dismissedAutoFillOnPageLoadCallout: true,
|
||||
disableAutoTotpCopy: false,
|
||||
activateAutoFillOnPageLoadFromPolicy: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
global_autofillSettingsLocal_inlineMenuVisibility: AutofillOverlayVisibility.OnButtonClick,
|
||||
"user_user-1_autofillSettings_autoCopyTotp": true,
|
||||
"user_user-1_autofillSettings_autofillOnPageLoad": true,
|
||||
"user_user-1_autofillSettings_autofillOnPageLoadCalloutIsDismissed": true,
|
||||
"user_user-1_autofillSettings_autofillOnPageLoadDefault": true,
|
||||
"user_user-1_autofillSettingsLocal_activateAutofillOnPageLoadFromPolicy": true,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const autofillSettingsStateDefinition: {
|
||||
stateDefinition: StateDefinitionLike;
|
||||
} = {
|
||||
stateDefinition: {
|
||||
name: "autofillSettings",
|
||||
},
|
||||
};
|
||||
|
||||
describe("AutofillSettingsKeyMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: AutofillSettingsKeyMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 17);
|
||||
sut = new AutofillSettingsKeyMigrator(17, 18);
|
||||
});
|
||||
|
||||
it("should remove autofill settings from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set autofill setting values for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
key: "inlineMenuVisibility",
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(5);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadDefault" },
|
||||
true,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoad" },
|
||||
true,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadCalloutIsDismissed" },
|
||||
true,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...autofillSettingsStateDefinition, key: "autoCopyTotp" },
|
||||
true,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
key: "activateAutofillOnPageLoadFromPolicy",
|
||||
},
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 18);
|
||||
sut = new AutofillSettingsKeyMigrator(17, 18);
|
||||
});
|
||||
|
||||
it("should null out new values for each account", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
key: "inlineMenuVisibility",
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(5);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadDefault" },
|
||||
null,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoad" },
|
||||
null,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadCalloutIsDismissed" },
|
||||
null,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...autofillSettingsStateDefinition, key: "autoCopyTotp" },
|
||||
null,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
key: "activateAutofillOnPageLoadFromPolicy",
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
autoFillOverlayVisibility: 1,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
autoFillOnPageLoadDefault: true,
|
||||
enableAutoFillOnPageLoad: true,
|
||||
dismissedAutoFillOnPageLoadCallout: true,
|
||||
disableAutoTotpCopy: false,
|
||||
activateAutoFillOnPageLoadFromPolicy: true,
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,274 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const AutofillOverlayVisibility = {
|
||||
Off: 0,
|
||||
OnButtonClick: 1,
|
||||
OnFieldFocus: 2,
|
||||
} as const;
|
||||
|
||||
type InlineMenuVisibilitySetting =
|
||||
(typeof AutofillOverlayVisibility)[keyof typeof AutofillOverlayVisibility];
|
||||
|
||||
type ExpectedAccountState = {
|
||||
settings?: {
|
||||
autoFillOnPageLoadDefault?: boolean;
|
||||
enableAutoFillOnPageLoad?: boolean;
|
||||
dismissedAutoFillOnPageLoadCallout?: boolean;
|
||||
disableAutoTotpCopy?: boolean;
|
||||
activateAutoFillOnPageLoadFromPolicy?: InlineMenuVisibilitySetting;
|
||||
};
|
||||
};
|
||||
|
||||
type ExpectedGlobalState = { autoFillOverlayVisibility?: InlineMenuVisibilitySetting };
|
||||
|
||||
const autofillSettingsStateDefinition: {
|
||||
stateDefinition: StateDefinitionLike;
|
||||
} = {
|
||||
stateDefinition: {
|
||||
name: "autofillSettings",
|
||||
},
|
||||
};
|
||||
|
||||
export class AutofillSettingsKeyMigrator extends Migrator<17, 18> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
// global state (e.g. "autoFillOverlayVisibility -> inlineMenuVisibility")
|
||||
const globalState = await helper.get<ExpectedGlobalState>("global");
|
||||
|
||||
if (globalState?.autoFillOverlayVisibility != null) {
|
||||
await helper.setToGlobal(
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
key: "inlineMenuVisibility",
|
||||
},
|
||||
globalState.autoFillOverlayVisibility,
|
||||
);
|
||||
|
||||
// delete `autoFillOverlayVisibility` from state global
|
||||
delete globalState.autoFillOverlayVisibility;
|
||||
|
||||
await helper.set<ExpectedGlobalState>("global", globalState);
|
||||
}
|
||||
|
||||
// account state (e.g. account settings -> state provider framework keys)
|
||||
const accounts = await helper.getAccounts<ExpectedAccountState>();
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
|
||||
// migrate account state
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
|
||||
let updateAccount = false;
|
||||
const accountSettings = account?.settings;
|
||||
|
||||
if (accountSettings?.autoFillOnPageLoadDefault != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadDefault" },
|
||||
accountSettings.autoFillOnPageLoadDefault,
|
||||
);
|
||||
delete account.settings.autoFillOnPageLoadDefault;
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (accountSettings?.enableAutoFillOnPageLoad != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoad" },
|
||||
accountSettings?.enableAutoFillOnPageLoad,
|
||||
);
|
||||
delete account.settings.enableAutoFillOnPageLoad;
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (accountSettings?.dismissedAutoFillOnPageLoadCallout != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadCalloutIsDismissed" },
|
||||
accountSettings?.dismissedAutoFillOnPageLoadCallout,
|
||||
);
|
||||
delete account.settings.dismissedAutoFillOnPageLoadCallout;
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (accountSettings?.disableAutoTotpCopy != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...autofillSettingsStateDefinition, key: "autoCopyTotp" },
|
||||
// invert the value to match the new naming convention
|
||||
!accountSettings?.disableAutoTotpCopy,
|
||||
);
|
||||
delete account.settings.disableAutoTotpCopy;
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (accountSettings?.activateAutoFillOnPageLoadFromPolicy != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
key: "activateAutofillOnPageLoadFromPolicy",
|
||||
},
|
||||
accountSettings?.activateAutoFillOnPageLoadFromPolicy,
|
||||
);
|
||||
delete account.settings.activateAutoFillOnPageLoadFromPolicy;
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (updateAccount) {
|
||||
// update the state account settings with the migrated values deleted
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
// global state (e.g. "inlineMenuVisibility -> autoFillOverlayVisibility")
|
||||
const globalState = (await helper.get<ExpectedGlobalState>("global")) || {};
|
||||
const inlineMenuVisibility: InlineMenuVisibilitySetting = await helper.getFromGlobal({
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
key: "inlineMenuVisibility",
|
||||
});
|
||||
|
||||
if (inlineMenuVisibility) {
|
||||
await helper.set<ExpectedGlobalState>("global", {
|
||||
...globalState,
|
||||
autoFillOverlayVisibility: inlineMenuVisibility,
|
||||
});
|
||||
|
||||
// remove the global state provider framework key for `inlineMenuVisibility`
|
||||
await helper.setToGlobal(
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
key: "inlineMenuVisibility",
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// account state (e.g. state provider framework keys -> account settings)
|
||||
const accounts = await helper.getAccounts<ExpectedAccountState>();
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
|
||||
// rollback account state
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
|
||||
let updateAccount = false;
|
||||
let settings = account?.settings || {};
|
||||
|
||||
const autoFillOnPageLoadDefault: boolean = await helper.getFromUser(userId, {
|
||||
...autofillSettingsStateDefinition,
|
||||
key: "autofillOnPageLoadDefault",
|
||||
});
|
||||
|
||||
const enableAutoFillOnPageLoad: boolean = await helper.getFromUser(userId, {
|
||||
...autofillSettingsStateDefinition,
|
||||
key: "autofillOnPageLoad",
|
||||
});
|
||||
|
||||
const dismissedAutoFillOnPageLoadCallout: boolean = await helper.getFromUser(userId, {
|
||||
...autofillSettingsStateDefinition,
|
||||
key: "autofillOnPageLoadCalloutIsDismissed",
|
||||
});
|
||||
|
||||
const autoCopyTotp: boolean = await helper.getFromUser(userId, {
|
||||
...autofillSettingsStateDefinition,
|
||||
key: "autoCopyTotp",
|
||||
});
|
||||
|
||||
const activateAutoFillOnPageLoadFromPolicy: InlineMenuVisibilitySetting =
|
||||
await helper.getFromUser(userId, {
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
key: "activateAutofillOnPageLoadFromPolicy",
|
||||
});
|
||||
|
||||
// update new settings and remove the account state provider framework keys for the rolled back values
|
||||
if (autoFillOnPageLoadDefault != null) {
|
||||
settings = { ...settings, autoFillOnPageLoadDefault };
|
||||
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadDefault" },
|
||||
null,
|
||||
);
|
||||
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (enableAutoFillOnPageLoad != null) {
|
||||
settings = { ...settings, enableAutoFillOnPageLoad };
|
||||
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoad" },
|
||||
null,
|
||||
);
|
||||
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (dismissedAutoFillOnPageLoadCallout != null) {
|
||||
settings = { ...settings, dismissedAutoFillOnPageLoadCallout };
|
||||
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadCalloutIsDismissed" },
|
||||
null,
|
||||
);
|
||||
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (autoCopyTotp != null) {
|
||||
// invert the value to match the new naming convention
|
||||
settings = { ...settings, disableAutoTotpCopy: !autoCopyTotp };
|
||||
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...autofillSettingsStateDefinition, key: "autoCopyTotp" },
|
||||
null,
|
||||
);
|
||||
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (activateAutoFillOnPageLoadFromPolicy != null) {
|
||||
settings = { ...settings, activateAutoFillOnPageLoadFromPolicy };
|
||||
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
key: "activateAutofillOnPageLoadFromPolicy",
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (updateAccount) {
|
||||
// commit updated settings to state
|
||||
await helper.set(userId, {
|
||||
...account,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import {
|
||||
REQUIRE_PASSWORD_ON_START,
|
||||
RequirePasswordOnStartMigrator,
|
||||
} from "./19-migrate-require-password-on-start";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
requirePasswordOnStart: true,
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_biometricSettings_requirePasswordOnStart": true,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("DesktopBiometricState migrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: RequirePasswordOnStartMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 18);
|
||||
sut = new RequirePasswordOnStartMigrator(18, 19);
|
||||
});
|
||||
|
||||
it("should remove biometricEncryptionClientKeyHalf from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set biometricEncryptionClientKeyHalf value for account that have it", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", REQUIRE_PASSWORD_ON_START, true);
|
||||
});
|
||||
|
||||
it("should not call extra setToUser", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 19);
|
||||
sut = new RequirePasswordOnStartMigrator(18, 19);
|
||||
});
|
||||
|
||||
it("should null out new values", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", REQUIRE_PASSWORD_ON_START, null);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
requirePasswordOnStart: true,
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["user-2", "user-3"])(
|
||||
"should not try to restore values to missing accounts",
|
||||
async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
settings?: {
|
||||
requirePasswordOnStart?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// Biometric text, no auto prompt text, fingerprint validated, and prompt cancelled are refreshed on every app start, so we don't need to migrate them
|
||||
export const REQUIRE_PASSWORD_ON_START: KeyDefinitionLike = {
|
||||
key: "requirePasswordOnStart",
|
||||
stateDefinition: { name: "biometricSettings" },
|
||||
};
|
||||
|
||||
export class RequirePasswordOnStartMigrator extends Migrator<18, 19> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(
|
||||
legacyAccounts.map(async ({ userId, account }) => {
|
||||
// Move account data
|
||||
if (account?.settings?.requirePasswordOnStart != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
REQUIRE_PASSWORD_ON_START,
|
||||
account.settings.requirePasswordOnStart,
|
||||
);
|
||||
|
||||
// Delete old account data
|
||||
delete account.settings.requirePasswordOnStart;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
async function rollbackUser(userId: string, account: ExpectedAccountType) {
|
||||
const requirePassword = await helper.getFromUser<boolean>(userId, REQUIRE_PASSWORD_ON_START);
|
||||
|
||||
if (requirePassword) {
|
||||
account ??= {};
|
||||
account.settings ??= {};
|
||||
|
||||
account.settings.requirePasswordOnStart = requirePassword;
|
||||
await helper.setToUser(userId, REQUIRE_PASSWORD_ON_START, null);
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { PrivateKeyMigrator } from "./20-move-private-key-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
keys: {
|
||||
privateKey: {
|
||||
encrypted: "user-1-encrypted-private-key",
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_crypto_privateKey": "encrypted-private-key",
|
||||
"user_user-2_crypto_privateKey": null as any,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
keys: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("privateKeyMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: PrivateKeyMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "privateKey",
|
||||
stateDefinition: {
|
||||
name: "crypto",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 19);
|
||||
sut = new PrivateKeyMigrator(19, 20);
|
||||
});
|
||||
|
||||
it("should remove privateKey from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
keys: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set privateKey value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(1);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
keyDefinitionLike,
|
||||
"user-1-encrypted-private-key",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 20);
|
||||
sut = new PrivateKeyMigrator(19, 20);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2", "user-3"])("should null out new values %s", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
keys: {
|
||||
privateKey: {
|
||||
encrypted: "encrypted-private-key",
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
keys?: {
|
||||
privateKey?: {
|
||||
encrypted?: string; // EncryptedString
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const USER_ENCRYPTED_PRIVATE_KEY: KeyDefinitionLike = {
|
||||
key: "privateKey",
|
||||
stateDefinition: {
|
||||
name: "crypto",
|
||||
},
|
||||
};
|
||||
|
||||
export class PrivateKeyMigrator extends Migrator<19, 20> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.keys?.privateKey?.encrypted;
|
||||
if (value != null) {
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_PRIVATE_KEY, value);
|
||||
delete account.keys.privateKey;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser<Record<string, string>>(
|
||||
userId,
|
||||
USER_ENCRYPTED_PRIVATE_KEY,
|
||||
);
|
||||
if (account && value) {
|
||||
account.keys = Object.assign(account.keys ?? {}, {
|
||||
privateKey: {
|
||||
encrypted: value,
|
||||
},
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_PRIVATE_KEY, null);
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { CollectionMigrator } from "./21-move-collections-state-to-state-provider";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
data: {
|
||||
collections: {
|
||||
encrypted: {
|
||||
"877fef70-be32-439e-8678-b0d80125653d": {
|
||||
id: "877fef70-be32-439e-8678-b0d80125653d",
|
||||
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
|
||||
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
|
||||
externalId: "",
|
||||
readOnly: false,
|
||||
manage: true,
|
||||
hidePasswords: false,
|
||||
},
|
||||
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
|
||||
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
|
||||
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
|
||||
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
|
||||
externalId: "",
|
||||
readOnly: false,
|
||||
manage: false,
|
||||
hidePasswords: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
data: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_collection_collections": {
|
||||
"877fef70-be32-439e-8678-b0d80125653d": {
|
||||
id: "877fef70-be32-439e-8678-b0d80125653d",
|
||||
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
|
||||
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
|
||||
externalId: "",
|
||||
readOnly: false,
|
||||
manage: true,
|
||||
hidePasswords: false,
|
||||
},
|
||||
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
|
||||
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
|
||||
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
|
||||
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
|
||||
externalId: "",
|
||||
readOnly: false,
|
||||
manage: false,
|
||||
hidePasswords: false,
|
||||
},
|
||||
},
|
||||
"user_user-2_collection_data": null as any,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
data: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
data: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("CollectionMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: CollectionMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "collections",
|
||||
stateDefinition: {
|
||||
name: "collection",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 20);
|
||||
sut = new CollectionMigrator(20, 21);
|
||||
});
|
||||
|
||||
it("should remove collections from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
data: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set collections value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
|
||||
"877fef70-be32-439e-8678-b0d80125653d": {
|
||||
id: "877fef70-be32-439e-8678-b0d80125653d",
|
||||
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
|
||||
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
|
||||
externalId: "",
|
||||
readOnly: false,
|
||||
manage: true,
|
||||
hidePasswords: false,
|
||||
},
|
||||
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
|
||||
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
|
||||
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
|
||||
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
|
||||
externalId: "",
|
||||
readOnly: false,
|
||||
manage: false,
|
||||
hidePasswords: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 21);
|
||||
sut = new CollectionMigrator(20, 21);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add collection values back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalled();
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
data: {
|
||||
collections: {
|
||||
encrypted: {
|
||||
"877fef70-be32-439e-8678-b0d80125653d": {
|
||||
id: "877fef70-be32-439e-8678-b0d80125653d",
|
||||
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
|
||||
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
|
||||
externalId: "",
|
||||
readOnly: false,
|
||||
manage: true,
|
||||
hidePasswords: false,
|
||||
},
|
||||
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
|
||||
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
|
||||
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
|
||||
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
|
||||
externalId: "",
|
||||
readOnly: false,
|
||||
manage: false,
|
||||
hidePasswords: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type CollectionDataType = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
externalId: string;
|
||||
readOnly: boolean;
|
||||
manage: boolean;
|
||||
hidePasswords: boolean;
|
||||
};
|
||||
|
||||
type ExpectedAccountType = {
|
||||
data?: {
|
||||
collections?: {
|
||||
encrypted?: Record<string, CollectionDataType>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const USER_ENCRYPTED_COLLECTIONS: KeyDefinitionLike = {
|
||||
key: "collections",
|
||||
stateDefinition: {
|
||||
name: "collection",
|
||||
},
|
||||
};
|
||||
|
||||
export class CollectionMigrator extends Migrator<20, 21> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.data?.collections?.encrypted;
|
||||
if (value != null) {
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_COLLECTIONS, value);
|
||||
delete account.data.collections;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser(userId, USER_ENCRYPTED_COLLECTIONS);
|
||||
if (account) {
|
||||
account.data = Object.assign(account.data ?? {}, {
|
||||
collections: {
|
||||
encrypted: value,
|
||||
},
|
||||
});
|
||||
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, USER_ENCRYPTED_COLLECTIONS, null);
|
||||
}
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { CollapsedGroupingsMigrator } from "./22-move-collapsed-groupings-to-state-provider";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
collapsedGroupings: ["grouping-1", "grouping-2"],
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_vaultFilter_collapsedGroupings": ["grouping-1", "grouping-2"],
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("CollapsedGroupingsMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: CollapsedGroupingsMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "collapsedGroupings",
|
||||
stateDefinition: {
|
||||
name: "vaultFilter",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 21);
|
||||
sut = new CollapsedGroupingsMigrator(21, 22);
|
||||
});
|
||||
|
||||
it("should remove collapsedGroupings from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set collapsedGroupings values for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, [
|
||||
"grouping-1",
|
||||
"grouping-2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 22);
|
||||
sut = new CollapsedGroupingsMigrator(21, 22);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
collapsedGroupings: ["grouping-1", "grouping-2"],
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
settings?: {
|
||||
collapsedGroupings?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const COLLAPSED_GROUPINGS: KeyDefinitionLike = {
|
||||
key: "collapsedGroupings",
|
||||
stateDefinition: {
|
||||
name: "vaultFilter",
|
||||
},
|
||||
};
|
||||
|
||||
export class CollapsedGroupingsMigrator extends Migrator<21, 22> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.settings?.collapsedGroupings;
|
||||
if (value != null) {
|
||||
await helper.setToUser(userId, COLLAPSED_GROUPINGS, value);
|
||||
delete account.settings.collapsedGroupings;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser(userId, COLLAPSED_GROUPINGS);
|
||||
if (account) {
|
||||
account.settings = Object.assign(account.settings ?? {}, {
|
||||
collapsedGroupings: value,
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, COLLAPSED_GROUPINGS, null);
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import {
|
||||
MoveBiometricPromptsToStateProviders,
|
||||
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
} from "./23-move-biometric-prompts-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: false,
|
||||
dismissedBiometricRequirePasswordOnStartCallout: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_biometricSettings_dismissedBiometricRequirePasswordOnStartCallout": true,
|
||||
"user_user-1_biometricSettings_promptAutomatically": "false",
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("MoveBiometricPromptsToStateProviders migrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: MoveBiometricPromptsToStateProviders;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 22);
|
||||
sut = new MoveBiometricPromptsToStateProviders(22, 23);
|
||||
});
|
||||
|
||||
it("should remove biometricUnlock, dismissedBiometricRequirePasswordOnStartCallout, and biometricEncryptionClientKeyHalf from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-2", {
|
||||
otherStuff: "otherStuff4",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set dismissedBiometricRequirePasswordOnStartCallout value for account that have it", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not call extra setToUser", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 23);
|
||||
sut = new MoveBiometricPromptsToStateProviders(22, 23);
|
||||
});
|
||||
|
||||
it.each([DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT, PROMPT_AUTOMATICALLY])(
|
||||
"should null out new values %s",
|
||||
async (keyDefinition) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinition, null);
|
||||
},
|
||||
);
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: false,
|
||||
dismissedBiometricRequirePasswordOnStartCallout: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["user-2", "user-3"])(
|
||||
"should not try to restore values to missing accounts",
|
||||
async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
settings?: {
|
||||
disableAutoBiometricsPrompt?: boolean;
|
||||
dismissedBiometricRequirePasswordOnStartCallout?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// prompt cancelled is refreshed on every app start/quit/unlock, so we don't need to migrate it
|
||||
|
||||
export const DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT: KeyDefinitionLike = {
|
||||
key: "dismissedBiometricRequirePasswordOnStartCallout",
|
||||
stateDefinition: { name: "biometricSettings" },
|
||||
};
|
||||
|
||||
export const PROMPT_AUTOMATICALLY: KeyDefinitionLike = {
|
||||
key: "promptAutomatically",
|
||||
stateDefinition: { name: "biometricSettings" },
|
||||
};
|
||||
|
||||
export class MoveBiometricPromptsToStateProviders extends Migrator<22, 23> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(
|
||||
legacyAccounts.map(async ({ userId, account }) => {
|
||||
if (account == null) {
|
||||
return;
|
||||
}
|
||||
// Move account data
|
||||
|
||||
if (account?.settings?.dismissedBiometricRequirePasswordOnStartCallout != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
account.settings.dismissedBiometricRequirePasswordOnStartCallout,
|
||||
);
|
||||
}
|
||||
|
||||
if (account?.settings?.disableAutoBiometricsPrompt != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
!account.settings.disableAutoBiometricsPrompt,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete old account data
|
||||
delete account?.settings?.dismissedBiometricRequirePasswordOnStartCallout;
|
||||
delete account?.settings?.disableAutoBiometricsPrompt;
|
||||
await helper.set(userId, account);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
async function rollbackUser(userId: string, account: ExpectedAccountType) {
|
||||
let updatedAccount = false;
|
||||
|
||||
const userDismissed = await helper.getFromUser<boolean>(
|
||||
userId,
|
||||
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
);
|
||||
|
||||
if (userDismissed) {
|
||||
account ??= {};
|
||||
account.settings ??= {};
|
||||
|
||||
updatedAccount = true;
|
||||
account.settings.dismissedBiometricRequirePasswordOnStartCallout = userDismissed;
|
||||
await helper.setToUser(userId, DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT, null);
|
||||
}
|
||||
|
||||
const userPromptAutomatically = await helper.getFromUser<boolean>(
|
||||
userId,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
);
|
||||
|
||||
if (userPromptAutomatically != null) {
|
||||
account ??= {};
|
||||
account.settings ??= {};
|
||||
|
||||
updatedAccount = true;
|
||||
account.settings.disableAutoBiometricsPrompt = !userPromptAutomatically;
|
||||
await helper.setToUser(userId, PROMPT_AUTOMATICALLY, null);
|
||||
}
|
||||
|
||||
if (updatedAccount) {
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { SmOnboardingTasksMigrator } from "./24-move-sm-onboarding-key-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
smOnboardingTasks: {
|
||||
"0bd005de-c722-473b-a00c-b10101006fcd": {
|
||||
createProject: true,
|
||||
createSecret: true,
|
||||
createServiceAccount: true,
|
||||
importSecrets: true,
|
||||
},
|
||||
"2f0d26ec-493a-4ed7-9183-b10d013597c8": {
|
||||
createProject: false,
|
||||
createSecret: true,
|
||||
createServiceAccount: false,
|
||||
importSecrets: true,
|
||||
},
|
||||
},
|
||||
someOtherProperty: "Some other value",
|
||||
},
|
||||
otherStuff: "otherStuff",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
smOnboardingTasks: {
|
||||
"000000-0000000-0000000-000000000": {
|
||||
createProject: false,
|
||||
createSecret: false,
|
||||
createServiceAccount: false,
|
||||
importSecrets: false,
|
||||
},
|
||||
},
|
||||
someOtherProperty: "Some other value",
|
||||
},
|
||||
otherStuff: "otherStuff",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_smOnboarding_tasks": {
|
||||
"0bd005de-c722-473b-a00c-b10101006fcd": {
|
||||
createProject: true,
|
||||
createSecret: true,
|
||||
createServiceAccount: true,
|
||||
importSecrets: true,
|
||||
},
|
||||
"2f0d26ec-493a-4ed7-9183-b10d013597c8": {
|
||||
createProject: false,
|
||||
createSecret: true,
|
||||
createServiceAccount: false,
|
||||
importSecrets: true,
|
||||
},
|
||||
},
|
||||
"user_user-2_smOnboarding_tasks": {
|
||||
"000000-0000000-0000000-000000000": {
|
||||
createProject: false,
|
||||
createSecret: false,
|
||||
createServiceAccount: false,
|
||||
importSecrets: false,
|
||||
},
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
someOtherProperty: "Some other value",
|
||||
},
|
||||
otherStuff: "otherStuff",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
someOtherProperty: "Some other value",
|
||||
},
|
||||
otherStuff: "otherStuff",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("SmOnboardingTasksMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: SmOnboardingTasksMigrator;
|
||||
|
||||
const keyDefinitionLike = {
|
||||
key: "tasks",
|
||||
stateDefinition: { name: "smOnboarding" },
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 23);
|
||||
sut = new SmOnboardingTasksMigrator(23, 24);
|
||||
});
|
||||
|
||||
it("should remove smOnboardingTasks from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
someOtherProperty: "Some other value",
|
||||
},
|
||||
otherStuff: "otherStuff",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set smOnboardingTasks provider value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
|
||||
"0bd005de-c722-473b-a00c-b10101006fcd": {
|
||||
createProject: true,
|
||||
createSecret: true,
|
||||
createServiceAccount: true,
|
||||
importSecrets: true,
|
||||
},
|
||||
"2f0d26ec-493a-4ed7-9183-b10d013597c8": {
|
||||
createProject: false,
|
||||
createSecret: true,
|
||||
createServiceAccount: false,
|
||||
importSecrets: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-2", keyDefinitionLike, {
|
||||
"000000-0000000-0000000-000000000": {
|
||||
createProject: false,
|
||||
createSecret: false,
|
||||
createServiceAccount: false,
|
||||
importSecrets: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 24);
|
||||
sut = new SmOnboardingTasksMigrator(23, 24);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add smOnboardingTasks back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
smOnboardingTasks: {
|
||||
"0bd005de-c722-473b-a00c-b10101006fcd": {
|
||||
createProject: true,
|
||||
createSecret: true,
|
||||
createServiceAccount: true,
|
||||
importSecrets: true,
|
||||
},
|
||||
"2f0d26ec-493a-4ed7-9183-b10d013597c8": {
|
||||
createProject: false,
|
||||
createSecret: true,
|
||||
createServiceAccount: false,
|
||||
importSecrets: true,
|
||||
},
|
||||
},
|
||||
someOtherProperty: "Some other value",
|
||||
},
|
||||
otherStuff: "otherStuff",
|
||||
});
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("user-2", {
|
||||
settings: {
|
||||
smOnboardingTasks: {
|
||||
"000000-0000000-0000000-000000000": {
|
||||
createProject: false,
|
||||
createSecret: false,
|
||||
createServiceAccount: false,
|
||||
importSecrets: false,
|
||||
},
|
||||
},
|
||||
someOtherProperty: "Some other value",
|
||||
},
|
||||
otherStuff: "otherStuff",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
settings?: {
|
||||
smOnboardingTasks?: Record<string, Record<string, boolean>>;
|
||||
};
|
||||
};
|
||||
|
||||
export const SM_ONBOARDING_TASKS: KeyDefinitionLike = {
|
||||
key: "tasks",
|
||||
stateDefinition: { name: "smOnboarding" },
|
||||
};
|
||||
|
||||
export class SmOnboardingTasksMigrator extends Migrator<23, 24> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(
|
||||
legacyAccounts.map(async ({ userId, account }) => {
|
||||
// Move account data
|
||||
if (account?.settings?.smOnboardingTasks != null) {
|
||||
await helper.setToUser(userId, SM_ONBOARDING_TASKS, account.settings.smOnboardingTasks);
|
||||
|
||||
// Delete old account data
|
||||
delete account.settings.smOnboardingTasks;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
async function rollbackUser(userId: string, account: ExpectedAccountType) {
|
||||
const smOnboardingTasks = await helper.getFromUser<Record<string, Record<string, boolean>>>(
|
||||
userId,
|
||||
SM_ONBOARDING_TASKS,
|
||||
);
|
||||
if (smOnboardingTasks) {
|
||||
account ??= {};
|
||||
account.settings ??= {};
|
||||
|
||||
account.settings.smOnboardingTasks = smOnboardingTasks;
|
||||
await helper.setToUser(userId, SM_ONBOARDING_TASKS, null);
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { any, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { ClearClipboardDelayMigrator } from "./25-move-clear-clipboard-to-autofill-settings-state-provider";
|
||||
|
||||
export const ClearClipboardDelay = {
|
||||
Never: null as null,
|
||||
TenSeconds: 10,
|
||||
TwentySeconds: 20,
|
||||
ThirtySeconds: 30,
|
||||
OneMinute: 60,
|
||||
TwoMinutes: 120,
|
||||
FiveMinutes: 300,
|
||||
} as const;
|
||||
|
||||
const AutofillOverlayVisibility = {
|
||||
Off: 0,
|
||||
OnButtonClick: 1,
|
||||
OnFieldFocus: 2,
|
||||
} as const;
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
clearClipboard: ClearClipboardDelay.TenSeconds,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
clearClipboard: ClearClipboardDelay.Never,
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
"user-3": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
global_autofillSettingsLocal_inlineMenuVisibility: AutofillOverlayVisibility.OnButtonClick,
|
||||
"user_user-1_autofillSettingsLocal_clearClipboardDelay": ClearClipboardDelay.TenSeconds,
|
||||
"user_user-2_autofillSettingsLocal_clearClipboardDelay": ClearClipboardDelay.Never,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const autofillSettingsLocalStateDefinition: {
|
||||
stateDefinition: StateDefinitionLike;
|
||||
} = {
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
};
|
||||
|
||||
describe("ClearClipboardDelayMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: ClearClipboardDelayMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 24);
|
||||
sut = new ClearClipboardDelayMigrator(24, 25);
|
||||
});
|
||||
|
||||
it("should remove clearClipboard setting from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-2", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set autofill setting values for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(2);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
|
||||
ClearClipboardDelay.TenSeconds,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-2",
|
||||
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
|
||||
ClearClipboardDelay.Never,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 25);
|
||||
sut = new ClearClipboardDelayMigrator(24, 25);
|
||||
});
|
||||
|
||||
it("should null out new values for each account", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(2);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
|
||||
null,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-2",
|
||||
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
clearClipboard: ClearClipboardDelay.TenSeconds,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-2", {
|
||||
settings: {
|
||||
clearClipboard: ClearClipboardDelay.Never,
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const ClearClipboardDelay = {
|
||||
Never: null as null,
|
||||
TenSeconds: 10,
|
||||
TwentySeconds: 20,
|
||||
ThirtySeconds: 30,
|
||||
OneMinute: 60,
|
||||
TwoMinutes: 120,
|
||||
FiveMinutes: 300,
|
||||
} as const;
|
||||
|
||||
type ClearClipboardDelaySetting = (typeof ClearClipboardDelay)[keyof typeof ClearClipboardDelay];
|
||||
|
||||
type ExpectedAccountState = {
|
||||
settings?: {
|
||||
clearClipboard?: ClearClipboardDelaySetting;
|
||||
};
|
||||
};
|
||||
|
||||
const autofillSettingsLocalStateDefinition: {
|
||||
stateDefinition: StateDefinitionLike;
|
||||
} = {
|
||||
stateDefinition: {
|
||||
name: "autofillSettingsLocal",
|
||||
},
|
||||
};
|
||||
|
||||
export class ClearClipboardDelayMigrator extends Migrator<24, 25> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
// account state (e.g. account settings -> state provider framework keys)
|
||||
const accounts = await helper.getAccounts<ExpectedAccountState>();
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
|
||||
// migrate account state
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
|
||||
const accountSettings = account?.settings;
|
||||
|
||||
if (accountSettings?.clearClipboard !== undefined) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
|
||||
accountSettings.clearClipboard,
|
||||
);
|
||||
delete account.settings.clearClipboard;
|
||||
|
||||
// update the state account settings with the migrated values deleted
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
// account state (e.g. state provider framework keys -> account settings)
|
||||
const accounts = await helper.getAccounts<ExpectedAccountState>();
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
|
||||
// rollback account state
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
|
||||
let settings = account?.settings || {};
|
||||
|
||||
const clearClipboardDelay: ClearClipboardDelaySetting = await helper.getFromUser(userId, {
|
||||
...autofillSettingsLocalStateDefinition,
|
||||
key: "clearClipboardDelay",
|
||||
});
|
||||
|
||||
// update new settings and remove the account state provider framework keys for the rolled back values
|
||||
if (clearClipboardDelay !== undefined) {
|
||||
settings = { ...settings, clearClipboard: clearClipboardDelay };
|
||||
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
|
||||
null,
|
||||
);
|
||||
|
||||
// commit updated settings to state
|
||||
await helper.set(userId, {
|
||||
...account,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { any, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { RevertLastSyncMigrator } from "./26-revert-move-last-sync-to-state-provider";
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
profile: {
|
||||
lastSync: "2024-01-24T00:00:00.000Z",
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
"user_user-1_sync_lastSync": "2024-01-24T00:00:00.000Z",
|
||||
"user_user-2_sync_lastSync": null as any,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
profile: {
|
||||
lastSync: "2024-01-24T00:00:00.000Z",
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("LastSyncMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: RevertLastSyncMigrator;
|
||||
|
||||
const keyDefinitionLike = {
|
||||
key: "lastSync",
|
||||
stateDefinition: {
|
||||
name: "sync",
|
||||
},
|
||||
};
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 26);
|
||||
sut = new RevertLastSyncMigrator(25, 26);
|
||||
});
|
||||
|
||||
it("should remove lastSync from all accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set lastSync provider value for each account", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
keyDefinitionLike,
|
||||
"2024-01-24T00:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-2", keyDefinitionLike, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 25);
|
||||
sut = new RevertLastSyncMigrator(25, 26);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add lastSync back to accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
profile: {
|
||||
lastSync: "2024-01-24T00:00:00.000Z",
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-2", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
profile?: {
|
||||
lastSync?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const LAST_SYNC_KEY: KeyDefinitionLike = {
|
||||
key: "lastSync",
|
||||
stateDefinition: {
|
||||
name: "sync",
|
||||
},
|
||||
};
|
||||
|
||||
export class RevertLastSyncMigrator extends Migrator<25, 26> {
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.profile?.lastSync;
|
||||
await helper.setToUser(userId, LAST_SYNC_KEY, value ?? null);
|
||||
if (value != null) {
|
||||
delete account.profile.lastSync;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
}
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser(userId, LAST_SYNC_KEY);
|
||||
if (account) {
|
||||
account.profile = Object.assign(account.profile ?? {}, {
|
||||
lastSync: value,
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, LAST_SYNC_KEY, null);
|
||||
}
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { any, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { BadgeSettingsMigrator } from "./27-move-badge-settings-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
disableBadgeCounter: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
disableBadgeCounter: false,
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
"user-3": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff6",
|
||||
},
|
||||
otherStuff: "otherStuff7",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_badgeSettings_enableBadgeCounter": false,
|
||||
"user_user-2_badgeSettings_enableBadgeCounter": true,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
"user-3": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff6",
|
||||
},
|
||||
otherStuff: "otherStuff7",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const badgeSettingsStateDefinition: {
|
||||
stateDefinition: StateDefinitionLike;
|
||||
} = {
|
||||
stateDefinition: {
|
||||
name: "badgeSettings",
|
||||
},
|
||||
};
|
||||
|
||||
describe("BadgeSettingsMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: BadgeSettingsMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 26);
|
||||
sut = new BadgeSettingsMigrator(26, 27);
|
||||
});
|
||||
|
||||
it("should remove disableBadgeCounter setting from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-2", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set badge setting values for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(2);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...badgeSettingsStateDefinition, key: "enableBadgeCounter" },
|
||||
false,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-2",
|
||||
{ ...badgeSettingsStateDefinition, key: "enableBadgeCounter" },
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 27);
|
||||
sut = new BadgeSettingsMigrator(26, 27);
|
||||
});
|
||||
|
||||
it("should null out new values for each account", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(2);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...badgeSettingsStateDefinition, key: "enableBadgeCounter" },
|
||||
null,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-2",
|
||||
{ ...badgeSettingsStateDefinition, key: "enableBadgeCounter" },
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
disableBadgeCounter: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-2", {
|
||||
settings: {
|
||||
disableBadgeCounter: false,
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountState = {
|
||||
settings?: {
|
||||
disableBadgeCounter?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const enableBadgeCounterKeyDefinition: KeyDefinitionLike = {
|
||||
stateDefinition: {
|
||||
name: "badgeSettings",
|
||||
},
|
||||
key: "enableBadgeCounter",
|
||||
};
|
||||
|
||||
export class BadgeSettingsMigrator extends Migrator<26, 27> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
// account state (e.g. account settings -> state provider framework keys)
|
||||
const accounts = await helper.getAccounts<ExpectedAccountState>();
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
|
||||
// migrate account state
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
|
||||
const accountSettings = account?.settings;
|
||||
|
||||
if (accountSettings?.disableBadgeCounter != undefined) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
enableBadgeCounterKeyDefinition,
|
||||
!accountSettings.disableBadgeCounter,
|
||||
);
|
||||
delete account.settings.disableBadgeCounter;
|
||||
|
||||
// update the state account settings with the migrated values deleted
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
// account state (e.g. state provider framework keys -> account settings)
|
||||
const accounts = await helper.getAccounts<ExpectedAccountState>();
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
|
||||
// rollback account state
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
|
||||
let settings = account?.settings || {};
|
||||
|
||||
const enableBadgeCounter: boolean = await helper.getFromUser(
|
||||
userId,
|
||||
enableBadgeCounterKeyDefinition,
|
||||
);
|
||||
|
||||
// update new settings and remove the account state provider framework keys for the rolled back values
|
||||
if (enableBadgeCounter != undefined) {
|
||||
settings = { ...settings, disableBadgeCounter: !enableBadgeCounter };
|
||||
|
||||
await helper.setToUser(userId, enableBadgeCounterKeyDefinition, null);
|
||||
|
||||
// commit updated settings to state
|
||||
await helper.set(userId, {
|
||||
...account,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import {
|
||||
BIOMETRIC_UNLOCK_ENABLED,
|
||||
MoveBiometricUnlockToStateProviders,
|
||||
} from "./28-move-biometric-unlock-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
biometricUnlock: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_biometricSettings_biometricUnlockEnabled": true,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("MoveBiometricPromptsToStateProviders migrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: MoveBiometricUnlockToStateProviders;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 27);
|
||||
sut = new MoveBiometricUnlockToStateProviders(27, 28);
|
||||
});
|
||||
|
||||
it("removes biometricUnlock from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-2", {
|
||||
otherStuff: "otherStuff4",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets biometricUnlock value for account that have it", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", BIOMETRIC_UNLOCK_ENABLED, true);
|
||||
});
|
||||
|
||||
it("should not call extra setToUser", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 28);
|
||||
sut = new MoveBiometricUnlockToStateProviders(27, 28);
|
||||
});
|
||||
|
||||
it("nulls out new values", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", BIOMETRIC_UNLOCK_ENABLED, null);
|
||||
});
|
||||
|
||||
it("adds explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
biometricUnlock: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["user-2", "user-3"])(
|
||||
"does not restore values when accounts are not present",
|
||||
async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
settings?: {
|
||||
biometricUnlock?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const BIOMETRIC_UNLOCK_ENABLED: KeyDefinitionLike = {
|
||||
key: "biometricUnlockEnabled",
|
||||
stateDefinition: { name: "biometricSettings" },
|
||||
};
|
||||
|
||||
export class MoveBiometricUnlockToStateProviders extends Migrator<27, 28> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(
|
||||
legacyAccounts.map(async ({ userId, account }) => {
|
||||
if (account == null) {
|
||||
return;
|
||||
}
|
||||
// Move account data
|
||||
if (account?.settings?.biometricUnlock != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
BIOMETRIC_UNLOCK_ENABLED,
|
||||
account.settings.biometricUnlock,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete old account data
|
||||
delete account?.settings?.biometricUnlock;
|
||||
await helper.set(userId, account);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
async function rollbackUser(userId: string, account: ExpectedAccountType) {
|
||||
const biometricUnlock = await helper.getFromUser<boolean>(userId, BIOMETRIC_UNLOCK_ENABLED);
|
||||
|
||||
if (biometricUnlock != null) {
|
||||
account ??= {};
|
||||
account.settings ??= {};
|
||||
|
||||
account.settings.biometricUnlock = biometricUnlock;
|
||||
await helper.setToUser(userId, BIOMETRIC_UNLOCK_ENABLED, null);
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { any, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { ProviderMigrator } from "./28-move-provider-state-to-state-provider";
|
||||
|
||||
function exampleProvider1() {
|
||||
return JSON.stringify({
|
||||
id: "id",
|
||||
name: "name",
|
||||
status: 0,
|
||||
type: 0,
|
||||
enabled: true,
|
||||
useEvents: true,
|
||||
});
|
||||
}
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
data: {
|
||||
providers: {
|
||||
"provider-id-1": exampleProvider1(),
|
||||
"provider-id-2": {
|
||||
// ...
|
||||
},
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
data: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_providers_providers": {
|
||||
"provider-id-1": exampleProvider1(),
|
||||
"provider-id-2": {
|
||||
// ...
|
||||
},
|
||||
},
|
||||
"user_user-2_providers_providers": null as any,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
data: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
data: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProviderMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: ProviderMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "providers",
|
||||
stateDefinition: {
|
||||
name: "providers",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 28);
|
||||
sut = new ProviderMigrator(27, 28);
|
||||
});
|
||||
|
||||
it("should remove providers from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
data: {
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set providers value for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
|
||||
"provider-id-1": exampleProvider1(),
|
||||
"provider-id-2": {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 27);
|
||||
sut = new ProviderMigrator(27, 28);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
data: {
|
||||
providers: {
|
||||
"provider-id-1": exampleProvider1(),
|
||||
"provider-id-2": {
|
||||
// ...
|
||||
},
|
||||
},
|
||||
otherStuff: "overStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
enum ProviderUserStatusType {
|
||||
Invited = 0,
|
||||
Accepted = 1,
|
||||
Confirmed = 2,
|
||||
Revoked = -1,
|
||||
}
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
enum ProviderUserType {
|
||||
ProviderAdmin = 0,
|
||||
ServiceUser = 1,
|
||||
}
|
||||
|
||||
type ProviderData = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ProviderUserStatusType;
|
||||
type: ProviderUserType;
|
||||
enabled: boolean;
|
||||
userId: string;
|
||||
useEvents: boolean;
|
||||
};
|
||||
|
||||
type ExpectedAccountType = {
|
||||
data?: {
|
||||
providers?: Record<string, Jsonify<ProviderData>>;
|
||||
};
|
||||
};
|
||||
|
||||
const USER_PROVIDERS: KeyDefinitionLike = {
|
||||
key: "providers",
|
||||
stateDefinition: {
|
||||
name: "providers",
|
||||
},
|
||||
};
|
||||
|
||||
export class ProviderMigrator extends Migrator<27, 28> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.data?.providers;
|
||||
if (value != null) {
|
||||
await helper.setToUser(userId, USER_PROVIDERS, value);
|
||||
delete account.data.providers;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account)));
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser(userId, USER_PROVIDERS);
|
||||
if (account) {
|
||||
account.data = Object.assign(account.data ?? {}, {
|
||||
providers: value,
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, USER_PROVIDERS, null);
|
||||
}
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => rollbackAccount(userId, account)));
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { UserNotificationSettingsKeyMigrator } from "./29-move-user-notification-settings-to-state-provider";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
disableAddLoginNotification: false,
|
||||
disableChangedPasswordNotification: false,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
global_userNotificationSettings_enableAddedLoginPrompt: true,
|
||||
global_userNotificationSettings_enableChangedPasswordPrompt: true,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const userNotificationSettingsLocalStateDefinition: {
|
||||
stateDefinition: StateDefinitionLike;
|
||||
} = {
|
||||
stateDefinition: {
|
||||
name: "userNotificationSettings",
|
||||
},
|
||||
};
|
||||
|
||||
describe("ProviderKeysMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: UserNotificationSettingsKeyMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 28);
|
||||
sut = new UserNotificationSettingsKeyMigrator(28, 29);
|
||||
});
|
||||
|
||||
it("should remove disableAddLoginNotification and disableChangedPasswordNotification global setting", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", { otherStuff: "otherStuff1" });
|
||||
expect(helper.set).toHaveBeenCalledWith("global", { otherStuff: "otherStuff1" });
|
||||
});
|
||||
|
||||
it("should set global user notification setting values", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToGlobal).toHaveBeenCalledTimes(2);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(
|
||||
{ ...userNotificationSettingsLocalStateDefinition, key: "enableAddedLoginPrompt" },
|
||||
true,
|
||||
);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(
|
||||
{ ...userNotificationSettingsLocalStateDefinition, key: "enableChangedPasswordPrompt" },
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 29);
|
||||
sut = new UserNotificationSettingsKeyMigrator(28, 29);
|
||||
});
|
||||
|
||||
it("should null out new global values", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToGlobal).toHaveBeenCalledTimes(2);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(
|
||||
{ ...userNotificationSettingsLocalStateDefinition, key: "enableAddedLoginPrompt" },
|
||||
null,
|
||||
);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(
|
||||
{ ...userNotificationSettingsLocalStateDefinition, key: "enableChangedPasswordPrompt" },
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should add explicit global values back", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
disableAddLoginNotification: false,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
disableChangedPasswordNotification: false,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedGlobalState = {
|
||||
disableAddLoginNotification?: boolean;
|
||||
disableChangedPasswordNotification?: boolean;
|
||||
};
|
||||
|
||||
export class UserNotificationSettingsKeyMigrator extends Migrator<28, 29> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const globalState = await helper.get<ExpectedGlobalState>("global");
|
||||
|
||||
// disableAddLoginNotification -> enableAddedLoginPrompt
|
||||
if (globalState?.disableAddLoginNotification != null) {
|
||||
await helper.setToGlobal(
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "userNotificationSettings",
|
||||
},
|
||||
key: "enableAddedLoginPrompt",
|
||||
},
|
||||
!globalState.disableAddLoginNotification,
|
||||
);
|
||||
|
||||
// delete `disableAddLoginNotification` from state global
|
||||
delete globalState.disableAddLoginNotification;
|
||||
|
||||
await helper.set<ExpectedGlobalState>("global", globalState);
|
||||
}
|
||||
|
||||
// disableChangedPasswordNotification -> enableChangedPasswordPrompt
|
||||
if (globalState?.disableChangedPasswordNotification != null) {
|
||||
await helper.setToGlobal(
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "userNotificationSettings",
|
||||
},
|
||||
key: "enableChangedPasswordPrompt",
|
||||
},
|
||||
!globalState.disableChangedPasswordNotification,
|
||||
);
|
||||
|
||||
// delete `disableChangedPasswordNotification` from state global
|
||||
delete globalState.disableChangedPasswordNotification;
|
||||
|
||||
await helper.set<ExpectedGlobalState>("global", globalState);
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const globalState = (await helper.get<ExpectedGlobalState>("global")) || {};
|
||||
|
||||
const enableAddedLoginPrompt: boolean = await helper.getFromGlobal({
|
||||
stateDefinition: {
|
||||
name: "userNotificationSettings",
|
||||
},
|
||||
key: "enableAddedLoginPrompt",
|
||||
});
|
||||
|
||||
const enableChangedPasswordPrompt: boolean = await helper.getFromGlobal({
|
||||
stateDefinition: {
|
||||
name: "userNotificationSettings",
|
||||
},
|
||||
key: "enableChangedPasswordPrompt",
|
||||
});
|
||||
|
||||
// enableAddedLoginPrompt -> disableAddLoginNotification
|
||||
if (enableAddedLoginPrompt) {
|
||||
await helper.set<ExpectedGlobalState>("global", {
|
||||
...globalState,
|
||||
disableAddLoginNotification: !enableAddedLoginPrompt,
|
||||
});
|
||||
|
||||
// remove the global state provider framework key for `enableAddedLoginPrompt`
|
||||
await helper.setToGlobal(
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "userNotificationSettings",
|
||||
},
|
||||
key: "enableAddedLoginPrompt",
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// enableChangedPasswordPrompt -> disableChangedPasswordNotification
|
||||
if (enableChangedPasswordPrompt) {
|
||||
await helper.set<ExpectedGlobalState>("global", {
|
||||
...globalState,
|
||||
disableChangedPasswordNotification: !enableChangedPasswordPrompt,
|
||||
});
|
||||
|
||||
// remove the global state provider framework key for `enableChangedPasswordPrompt`
|
||||
await helper.setToGlobal(
|
||||
{
|
||||
stateDefinition: {
|
||||
name: "userNotificationSettings",
|
||||
},
|
||||
key: "enableChangedPasswordPrompt",
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { PolicyMigrator } from "./30-move-policy-state-to-state-provider";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
data: {
|
||||
policies: {
|
||||
encrypted: {
|
||||
"policy-1": {
|
||||
id: "policy-1",
|
||||
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
|
||||
type: 9, // max vault timeout
|
||||
enabled: true,
|
||||
data: {
|
||||
hours: 1,
|
||||
minutes: 30,
|
||||
action: "lock",
|
||||
},
|
||||
},
|
||||
"policy-2": {
|
||||
id: "policy-2",
|
||||
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
|
||||
type: 3, // single org
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
data: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_policies_policies": {
|
||||
"policy-1": {
|
||||
id: "policy-1",
|
||||
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
|
||||
type: 9,
|
||||
enabled: true,
|
||||
data: {
|
||||
hours: 1,
|
||||
minutes: 30,
|
||||
action: "lock",
|
||||
},
|
||||
},
|
||||
"policy-2": {
|
||||
id: "policy-2",
|
||||
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
|
||||
type: 3,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
"user_user-2_policies_policies": null as any,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user-1": {
|
||||
data: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
data: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("PoliciesMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: PolicyMigrator;
|
||||
const keyDefinitionLike = {
|
||||
key: "policies",
|
||||
stateDefinition: {
|
||||
name: "policies",
|
||||
},
|
||||
};
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 22);
|
||||
sut = new PolicyMigrator(29, 30);
|
||||
});
|
||||
|
||||
it("should remove policies from all old accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
data: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set policies value in StateProvider framework for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
|
||||
"policy-1": {
|
||||
id: "policy-1",
|
||||
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
|
||||
type: 9,
|
||||
enabled: true,
|
||||
data: {
|
||||
hours: 1,
|
||||
minutes: 30,
|
||||
action: "lock",
|
||||
},
|
||||
},
|
||||
"policy-2": {
|
||||
id: "policy-2",
|
||||
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
|
||||
type: 3,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 23);
|
||||
sut = new PolicyMigrator(29, 30);
|
||||
});
|
||||
|
||||
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add policy values back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalled();
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
data: {
|
||||
policies: {
|
||||
encrypted: {
|
||||
"policy-1": {
|
||||
id: "policy-1",
|
||||
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
|
||||
type: 9,
|
||||
enabled: true,
|
||||
data: {
|
||||
hours: 1,
|
||||
minutes: 30,
|
||||
action: "lock",
|
||||
},
|
||||
},
|
||||
"policy-2": {
|
||||
id: "policy-2",
|
||||
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
|
||||
type: 3,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
enum PolicyType {
|
||||
TwoFactorAuthentication = 0, // Requires users to have 2fa enabled
|
||||
MasterPassword = 1, // Sets minimum requirements for master password complexity
|
||||
PasswordGenerator = 2, // Sets minimum requirements/default type for generated passwords/passphrases
|
||||
SingleOrg = 3, // Allows users to only be apart of one organization
|
||||
RequireSso = 4, // Requires users to authenticate with SSO
|
||||
PersonalOwnership = 5, // Disables personal vault ownership for adding/cloning items
|
||||
DisableSend = 6, // Disables the ability to create and edit Bitwarden Sends
|
||||
SendOptions = 7, // Sets restrictions or defaults for Bitwarden Sends
|
||||
ResetPassword = 8, // Allows orgs to use reset password : also can enable auto-enrollment during invite flow
|
||||
MaximumVaultTimeout = 9, // Sets the maximum allowed vault timeout
|
||||
DisablePersonalVaultExport = 10, // Disable personal vault export
|
||||
ActivateAutofill = 11, // Activates autofill with page load on the browser extension
|
||||
}
|
||||
|
||||
type PolicyDataType = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
type: PolicyType;
|
||||
data: Record<string, string | number | boolean>;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type ExpectedAccountType = {
|
||||
data?: {
|
||||
policies?: {
|
||||
encrypted?: Record<string, PolicyDataType>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const POLICIES_KEY: KeyDefinitionLike = {
|
||||
key: "policies",
|
||||
stateDefinition: {
|
||||
name: "policies",
|
||||
},
|
||||
};
|
||||
|
||||
export class PolicyMigrator extends Migrator<29, 30> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = account?.data?.policies?.encrypted;
|
||||
if (value != null) {
|
||||
await helper.setToUser(userId, POLICIES_KEY, value);
|
||||
delete account.data.policies;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account)));
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
|
||||
const value = await helper.getFromUser(userId, POLICIES_KEY);
|
||||
if (account) {
|
||||
account.data = Object.assign(account.data ?? {}, {
|
||||
policies: {
|
||||
encrypted: value,
|
||||
},
|
||||
});
|
||||
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
await helper.setToUser(userId, POLICIES_KEY, null);
|
||||
}
|
||||
await Promise.all(accounts.map(({ userId, account }) => rollbackAccount(userId, account)));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user