mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 07:43:35 +00:00
[PM-8280] email forwarders (#11563)
* forwarder lookup and generation support * localize algorithm names and descriptions in the credential generator service * add encryption support to UserStateSubject * move generic rx utilities to common * move icon button labels to generator configurations
This commit is contained in:
@@ -3,6 +3,8 @@ import { Observable } from "rxjs";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { UserEncryptor } from "./state/user-encryptor.abstraction";
|
||||
|
||||
/** error emitted when the `SingleUserDependency` changes Ids */
|
||||
export type UserChangedError = {
|
||||
/** the userId pinned by the single user dependency */
|
||||
@@ -45,7 +47,35 @@ export type UserDependency = {
|
||||
userId$: Observable<UserId>;
|
||||
};
|
||||
|
||||
/** A pattern for types that depend upon a fixed userid and return
|
||||
/** Decorates a type to indicate the user, if any, that the type is usable only by
|
||||
* a specific user.
|
||||
*/
|
||||
export type UserBound<K extends keyof any, T> = { [P in K]: T } & {
|
||||
/** The user to which T is bound. */
|
||||
userId: UserId;
|
||||
};
|
||||
|
||||
/** A pattern for types that depend upon a fixed-key encryptor and return
|
||||
* an observable.
|
||||
*
|
||||
* Consumers of this dependency should emit a `UserChangedError` if
|
||||
* the bound UserId changes or if the encryptor changes. If
|
||||
* `singleUserEncryptor$` completes, the consumer should complete
|
||||
* once all events received prior to the completion event are
|
||||
* finished processing. The consumer should, where possible,
|
||||
* prioritize these events in order to complete as soon as possible.
|
||||
* If `singleUserEncryptor$` emits an unrecoverable error, the consumer
|
||||
* should also emit the error.
|
||||
*/
|
||||
export type SingleUserEncryptorDependency = {
|
||||
/** A stream that emits an encryptor when subscribed and the user key
|
||||
* is available, and completes when the user key is no longer available.
|
||||
* The stream should not emit null or undefined.
|
||||
*/
|
||||
singleUserEncryptor$: Observable<UserBound<"encryptor", UserEncryptor>>;
|
||||
};
|
||||
|
||||
/** A pattern for types that depend upon a fixed-value userid and return
|
||||
* an observable.
|
||||
*
|
||||
* Consumers of this dependency should emit a `UserChangedError` if
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
export const IntegrationIds = [
|
||||
"anonaddy",
|
||||
"duckduckgo",
|
||||
"fastmail",
|
||||
"firefoxrelay",
|
||||
"forwardemail",
|
||||
"simplelogin",
|
||||
] as const;
|
||||
|
||||
/** Identifies a vendor integrated into bitwarden */
|
||||
export type IntegrationId = Opaque<
|
||||
"anonaddy" | "duckduckgo" | "fastmail" | "firefoxrelay" | "forwardemail" | "simplelogin",
|
||||
"IntegrationId"
|
||||
>;
|
||||
export type IntegrationId = Opaque<(typeof IntegrationIds)[number], "IntegrationId">;
|
||||
|
||||
31
libs/common/src/tools/private-classifier.ts
Normal file
31
libs/common/src/tools/private-classifier.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Classifier } from "@bitwarden/common/tools/state/classifier";
|
||||
|
||||
export class PrivateClassifier<Data> implements Classifier<Data, Record<string, never>, Data> {
|
||||
constructor(private keys: (keyof Jsonify<Data>)[] = undefined) {}
|
||||
|
||||
classify(value: Data): { disclosed: Jsonify<Record<string, never>>; secret: Jsonify<Data> } {
|
||||
const pickMe = JSON.parse(JSON.stringify(value));
|
||||
const keys: (keyof Jsonify<Data>)[] = this.keys ?? (Object.keys(pickMe) as any);
|
||||
|
||||
const picked: Partial<Jsonify<Data>> = {};
|
||||
for (const key of keys) {
|
||||
picked[key] = pickMe[key];
|
||||
}
|
||||
const secret = picked as Jsonify<Data>;
|
||||
|
||||
return { disclosed: null, secret };
|
||||
}
|
||||
|
||||
declassify(_disclosed: Jsonify<Record<keyof Data, never>>, secret: Jsonify<Data>) {
|
||||
const result: Partial<Jsonify<Data>> = {};
|
||||
const keys: (keyof Jsonify<Data>)[] = this.keys ?? (Object.keys(secret) as any);
|
||||
|
||||
for (const key of keys) {
|
||||
result[key] = secret[key];
|
||||
}
|
||||
|
||||
return result as Jsonify<Data>;
|
||||
}
|
||||
}
|
||||
29
libs/common/src/tools/public-classifier.ts
Normal file
29
libs/common/src/tools/public-classifier.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Classifier } from "@bitwarden/common/tools/state/classifier";
|
||||
|
||||
export class PublicClassifier<Data> implements Classifier<Data, Data, Record<string, never>> {
|
||||
constructor(private keys: (keyof Jsonify<Data>)[]) {}
|
||||
|
||||
classify(value: Data): { disclosed: Jsonify<Data>; secret: Jsonify<Record<string, never>> } {
|
||||
const pickMe = JSON.parse(JSON.stringify(value));
|
||||
|
||||
const picked: Partial<Jsonify<Data>> = {};
|
||||
for (const key of this.keys) {
|
||||
picked[key] = pickMe[key];
|
||||
}
|
||||
const disclosed = picked as Jsonify<Data>;
|
||||
|
||||
return { disclosed, secret: null };
|
||||
}
|
||||
|
||||
declassify(disclosed: Jsonify<Data>, _secret: Jsonify<Record<keyof Data, never>>) {
|
||||
const result: Partial<Jsonify<Data>> = {};
|
||||
|
||||
for (const key of this.keys) {
|
||||
result[key] = disclosed[key];
|
||||
}
|
||||
|
||||
return result as Jsonify<Data>;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,18 @@
|
||||
* include structuredClone in test environment.
|
||||
* @jest-environment ../../../../shared/test.environment.ts
|
||||
*/
|
||||
import { of, firstValueFrom } from "rxjs";
|
||||
import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs";
|
||||
|
||||
import { awaitAsync, trackEmissions } from "../../spec";
|
||||
|
||||
import { distinctIfShallowMatch, reduceCollection } from "./rx";
|
||||
import {
|
||||
anyComplete,
|
||||
distinctIfShallowMatch,
|
||||
on,
|
||||
ready,
|
||||
reduceCollection,
|
||||
withLatestReady,
|
||||
} from "./rx";
|
||||
|
||||
describe("reduceCollection", () => {
|
||||
it.each([[null], [undefined], [[]]])(
|
||||
@@ -84,3 +91,488 @@ describe("distinctIfShallowMatch", () => {
|
||||
expect(result).toEqual([{ foo: true, bar: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("anyComplete", () => {
|
||||
it("emits true when its input completes", () => {
|
||||
const input$ = new Subject<void>();
|
||||
|
||||
const emissions: boolean[] = [];
|
||||
anyComplete(input$).subscribe((e) => emissions.push(e));
|
||||
input$.complete();
|
||||
|
||||
expect(emissions).toEqual([true]);
|
||||
});
|
||||
|
||||
it("completes when its input is already complete", () => {
|
||||
const input = new Subject<void>();
|
||||
input.complete();
|
||||
|
||||
let completed = false;
|
||||
anyComplete(input).subscribe({ complete: () => (completed = true) });
|
||||
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it("completes when any input completes", () => {
|
||||
const input$ = new Subject<void>();
|
||||
const completing$ = new Subject<void>();
|
||||
|
||||
let completed = false;
|
||||
anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) });
|
||||
completing$.complete();
|
||||
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores emissions", () => {
|
||||
const input$ = new Subject<number>();
|
||||
|
||||
const emissions: boolean[] = [];
|
||||
anyComplete(input$).subscribe((e) => emissions.push(e));
|
||||
input$.next(1);
|
||||
input$.next(2);
|
||||
input$.complete();
|
||||
|
||||
expect(emissions).toEqual([true]);
|
||||
});
|
||||
|
||||
it("forwards errors", () => {
|
||||
const input$ = new Subject<void>();
|
||||
const expected = { some: "error" };
|
||||
|
||||
let error = null;
|
||||
anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) });
|
||||
input$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ready", () => {
|
||||
it("connects when subscribed", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
let connected = false;
|
||||
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
|
||||
|
||||
// precondition: ready$ should be cold
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
expect(connected).toBe(false);
|
||||
|
||||
ready$.subscribe();
|
||||
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses source emissions until its watch emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const results: number[] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
// precondition: no emissions
|
||||
source$.next(1);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1]);
|
||||
});
|
||||
|
||||
it("suppresses source emissions until all watches emit", () => {
|
||||
const watchA$ = new Subject<void>();
|
||||
const watchB$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready([watchA$, watchB$]));
|
||||
const results: number[] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
// preconditions: no emissions
|
||||
source$.next(1);
|
||||
expect(results).toEqual([]);
|
||||
watchA$.next();
|
||||
expect(results).toEqual([]);
|
||||
|
||||
watchB$.next();
|
||||
|
||||
expect(results).toEqual([1]);
|
||||
});
|
||||
|
||||
it("emits the last source emission when its watch emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const results: number[] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
// precondition: no emissions
|
||||
source$.next(1);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
source$.next(2);
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([2]);
|
||||
});
|
||||
|
||||
it("emits all source emissions after its watch emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const results: number[] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
watch$.next();
|
||||
source$.next(1);
|
||||
source$.next(2);
|
||||
|
||||
expect(results).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("ignores repeated watch emissions", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const results: number[] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
watch$.next();
|
||||
source$.next(1);
|
||||
watch$.next();
|
||||
source$.next(2);
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("completes when its source completes", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
let completed = false;
|
||||
ready$.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
source$.complete();
|
||||
|
||||
expect(completed).toBeTruthy();
|
||||
});
|
||||
|
||||
it("errors when its source errors", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const expected = { some: "error" };
|
||||
let error = null;
|
||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
source$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when its watch errors", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
const expected = { some: "error" };
|
||||
let error = null;
|
||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
watch$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when its watch completes before emitting", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(ready(watch$));
|
||||
let error = null;
|
||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
watch$.complete();
|
||||
|
||||
expect(error).toBeInstanceOf(EmptyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withLatestReady", () => {
|
||||
it("connects when subscribed", () => {
|
||||
const watch$ = new Subject<string>();
|
||||
let connected = false;
|
||||
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
|
||||
|
||||
// precondition: ready$ should be cold
|
||||
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||
expect(connected).toBe(false);
|
||||
|
||||
ready$.subscribe();
|
||||
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses source emissions until its watch emits", () => {
|
||||
const watch$ = new Subject<string>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||
const results: [number, string][] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
// precondition: no emissions
|
||||
source$.next(1);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
watch$.next("watch");
|
||||
|
||||
expect(results).toEqual([[1, "watch"]]);
|
||||
});
|
||||
|
||||
it("emits the last source emission when its watch emits", () => {
|
||||
const watch$ = new Subject<string>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||
const results: [number, string][] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
// precondition: no emissions
|
||||
source$.next(1);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
source$.next(2);
|
||||
watch$.next("watch");
|
||||
|
||||
expect(results).toEqual([[2, "watch"]]);
|
||||
});
|
||||
|
||||
it("emits all source emissions after its watch emits", () => {
|
||||
const watch$ = new Subject<string>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||
const results: [number, string][] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
watch$.next("watch");
|
||||
source$.next(1);
|
||||
source$.next(2);
|
||||
|
||||
expect(results).toEqual([
|
||||
[1, "watch"],
|
||||
[2, "watch"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends the latest watch emission", () => {
|
||||
const watch$ = new Subject<string>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||
const results: [number, string][] = [];
|
||||
ready$.subscribe((n) => results.push(n));
|
||||
|
||||
watch$.next("ignored");
|
||||
watch$.next("watch");
|
||||
source$.next(1);
|
||||
watch$.next("ignored");
|
||||
watch$.next("watch");
|
||||
source$.next(2);
|
||||
|
||||
expect(results).toEqual([
|
||||
[1, "watch"],
|
||||
[2, "watch"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("completes when its source completes", () => {
|
||||
const watch$ = new Subject<string>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||
let completed = false;
|
||||
ready$.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
source$.complete();
|
||||
|
||||
expect(completed).toBeTruthy();
|
||||
});
|
||||
|
||||
it("errors when its source errors", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||
const expected = { some: "error" };
|
||||
let error = null;
|
||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
source$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when its watch errors", () => {
|
||||
const watch$ = new Subject<string>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||
const expected = { some: "error" };
|
||||
let error = null;
|
||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
watch$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when its watch completes before emitting", () => {
|
||||
const watch$ = new Subject<string>();
|
||||
const source$ = new Subject<number>();
|
||||
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||
let error = null;
|
||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
watch$.complete();
|
||||
|
||||
expect(error).toBeInstanceOf(EmptyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on", () => {
|
||||
it("connects when subscribed", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
let connected = false;
|
||||
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
|
||||
|
||||
// precondition: on$ should be cold
|
||||
const on$ = source$.pipe(on(watch$));
|
||||
expect(connected).toBeFalsy();
|
||||
|
||||
on$.subscribe();
|
||||
|
||||
expect(connected).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses source emissions until `on` emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
|
||||
// precondition: on$ should be cold
|
||||
source$.next(1);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1]);
|
||||
});
|
||||
|
||||
it("repeats source emissions when `on` emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
source$.next(1);
|
||||
|
||||
watch$.next();
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it("updates source emissions when `on` emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
|
||||
source$.next(1);
|
||||
watch$.next();
|
||||
source$.next(2);
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("emits a value when `on` emits before the source is ready", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
|
||||
watch$.next();
|
||||
source$.next(1);
|
||||
|
||||
expect(results).toEqual([1]);
|
||||
});
|
||||
|
||||
it("ignores repeated `on` emissions before the source is ready", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
|
||||
watch$.next();
|
||||
watch$.next();
|
||||
source$.next(1);
|
||||
|
||||
expect(results).toEqual([1]);
|
||||
});
|
||||
|
||||
it("emits only the latest source emission when `on` emits", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const results: number[] = [];
|
||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||
source$.next(1);
|
||||
|
||||
watch$.next();
|
||||
|
||||
source$.next(2);
|
||||
source$.next(3);
|
||||
watch$.next();
|
||||
|
||||
expect(results).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("completes when its source completes", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
let complete: boolean = false;
|
||||
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
|
||||
|
||||
source$.complete();
|
||||
|
||||
expect(complete).toBeTruthy();
|
||||
});
|
||||
|
||||
it("completes when its watch completes", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
let complete: boolean = false;
|
||||
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
|
||||
|
||||
watch$.complete();
|
||||
|
||||
expect(complete).toBeTruthy();
|
||||
});
|
||||
|
||||
it("errors when its source errors", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const expected = { some: "error" };
|
||||
let error = null;
|
||||
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
source$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when its watch errors", () => {
|
||||
const watch$ = new Subject<void>();
|
||||
const source$ = new Subject<number>();
|
||||
const expected = { some: "error" };
|
||||
let error = null;
|
||||
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
watch$.error(expected);
|
||||
|
||||
expect(error).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
import { map, distinctUntilChanged, OperatorFunction } from "rxjs";
|
||||
import {
|
||||
map,
|
||||
distinctUntilChanged,
|
||||
OperatorFunction,
|
||||
Observable,
|
||||
ignoreElements,
|
||||
endWith,
|
||||
race,
|
||||
pipe,
|
||||
connect,
|
||||
ReplaySubject,
|
||||
concat,
|
||||
zip,
|
||||
first,
|
||||
takeUntil,
|
||||
withLatestFrom,
|
||||
concatMap,
|
||||
} from "rxjs";
|
||||
|
||||
/**
|
||||
* An observable operator that reduces an emitted collection to a single object,
|
||||
@@ -36,3 +53,109 @@ export function distinctIfShallowMatch<Item>(): OperatorFunction<Item, Item> {
|
||||
return isDistinct;
|
||||
});
|
||||
}
|
||||
|
||||
/** Create an observable that, once subscribed, emits `true` then completes when
|
||||
* any input completes. If an input is already complete when the subscription
|
||||
* occurs, it emits immediately.
|
||||
* @param watch$ the observable(s) to watch for completion; if an array is passed,
|
||||
* null and undefined members are ignored. If `watch$` is empty, `anyComplete`
|
||||
* will never complete.
|
||||
* @returns An observable that emits `true` when any of its inputs
|
||||
* complete. The observable forwards the first error from its input.
|
||||
* @remarks This method is particularly useful in combination with `takeUntil` and
|
||||
* streams that are not guaranteed to complete on their own.
|
||||
*/
|
||||
export function anyComplete(watch$: Observable<any> | Observable<any>[]): Observable<any> {
|
||||
if (Array.isArray(watch$)) {
|
||||
const completes$ = watch$
|
||||
.filter((w$) => !!w$)
|
||||
.map((w$) => w$.pipe(ignoreElements(), endWith(true)));
|
||||
const completed$ = race(completes$);
|
||||
return completed$;
|
||||
} else {
|
||||
return watch$.pipe(ignoreElements(), endWith(true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an observable that delays the input stream until all watches have
|
||||
* emitted a value. The watched values are not included in the source stream.
|
||||
* The last emission from the source is output when all the watches have
|
||||
* emitted at least once.
|
||||
* @param watch$ the observable(s) to watch for readiness. If `watch$` is empty,
|
||||
* `ready` will never emit.
|
||||
* @returns An observable that emits when the source stream emits. The observable
|
||||
* errors if one of its watches completes before emitting. It also errors if one
|
||||
* of its watches errors.
|
||||
*/
|
||||
export function ready<T>(watch$: Observable<any> | Observable<any>[]) {
|
||||
const watching$ = Array.isArray(watch$) ? watch$ : [watch$];
|
||||
return pipe(
|
||||
connect<T, Observable<T>>((source$) => {
|
||||
// this subscription is safe because `source$` connects only after there
|
||||
// is an external subscriber.
|
||||
const source = new ReplaySubject<T>(1);
|
||||
source$.subscribe(source);
|
||||
|
||||
// `concat` is subscribed immediately after it's returned, at which point
|
||||
// `zip` blocks until all items in `watching$` are ready. If that occurs
|
||||
// after `source$` is hot, then the replay subject sends the last-captured
|
||||
// emission through immediately. Otherwise, `ready` waits for the next
|
||||
// emission
|
||||
return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe(
|
||||
takeUntil(anyComplete(source)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function withLatestReady<Source, Watch>(
|
||||
watch$: Observable<Watch>,
|
||||
): OperatorFunction<Source, [Source, Watch]> {
|
||||
return connect((source$) => {
|
||||
// these subscriptions are safe because `source$` connects only after there
|
||||
// is an external subscriber.
|
||||
const source = new ReplaySubject<Source>(1);
|
||||
source$.subscribe(source);
|
||||
const watch = new ReplaySubject<Watch>(1);
|
||||
watch$.subscribe(watch);
|
||||
|
||||
// `concat` is subscribed immediately after it's returned, at which point
|
||||
// `zip` blocks until all items in `watching$` are ready. If that occurs
|
||||
// after `source$` is hot, then the replay subject sends the last-captured
|
||||
// emission through immediately. Otherwise, `ready` waits for the next
|
||||
// emission
|
||||
return concat(zip(watch).pipe(first(), ignoreElements()), source).pipe(
|
||||
withLatestFrom(watch),
|
||||
takeUntil(anyComplete(source)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an observable that emits the latest value of the source stream
|
||||
* when `watch$` emits. If `watch$` emits before the stream emits, then
|
||||
* an emission occurs as soon as a value becomes ready.
|
||||
* @param watch$ the observable that triggers emissions
|
||||
* @returns An observable that emits when `watch$` emits. The observable
|
||||
* errors if its source stream errors. It also errors if `on` errors. It
|
||||
* completes if its watch completes.
|
||||
*
|
||||
* @remarks This works like `audit`, but it repeats emissions when
|
||||
* watch$ fires.
|
||||
*/
|
||||
export function on<T>(watch$: Observable<any>) {
|
||||
return pipe(
|
||||
connect<T, Observable<T>>((source$) => {
|
||||
const source = new ReplaySubject<T>(1);
|
||||
source$.subscribe(source);
|
||||
|
||||
return watch$
|
||||
.pipe(
|
||||
ready(source),
|
||||
concatMap(() => source.pipe(first())),
|
||||
)
|
||||
.pipe(takeUntil(anyComplete(source)));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,3 +17,9 @@ export type ClassifiedFormat<Id, Disclosed> = {
|
||||
*/
|
||||
readonly disclosed: Jsonify<Disclosed>;
|
||||
};
|
||||
|
||||
export function isClassifiedFormat<Id, Disclosed>(
|
||||
value: any,
|
||||
): value is ClassifiedFormat<Id, Disclosed> {
|
||||
return "id" in value && "secret" in value && "disclosed" in value;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Constraints, StateConstraints } from "../types";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
Constraints,
|
||||
DynamicStateConstraints,
|
||||
StateConstraints,
|
||||
SubjectConstraints,
|
||||
} from "../types";
|
||||
|
||||
// The constraints type shares the properties of the state,
|
||||
// but never has any members
|
||||
@@ -9,16 +16,31 @@ const EMPTY_CONSTRAINTS = new Proxy<any>(Object.freeze({}), {
|
||||
});
|
||||
|
||||
/** A constraint that does nothing. */
|
||||
export class IdentityConstraint<State extends object> implements StateConstraints<State> {
|
||||
export class IdentityConstraint<State extends object>
|
||||
implements StateConstraints<State>, DynamicStateConstraints<State>
|
||||
{
|
||||
/** Instantiate the identity constraint */
|
||||
constructor() {}
|
||||
|
||||
readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS;
|
||||
|
||||
calibrate() {
|
||||
return this;
|
||||
}
|
||||
|
||||
adjust(state: State) {
|
||||
return state;
|
||||
}
|
||||
|
||||
fix(state: State) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
/** Emits a constraint that does not alter the input state. */
|
||||
export function unconstrained$<State extends object>(): Observable<SubjectConstraints<State>> {
|
||||
const identity = new IdentityConstraint<State>();
|
||||
const constraints$ = new BehaviorSubject(identity);
|
||||
|
||||
return constraints$;
|
||||
}
|
||||
|
||||
53
libs/common/src/tools/state/object-key.ts
Normal file
53
libs/common/src/tools/state/object-key.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { UserKeyDefinition, UserKeyDefinitionOptions } from "../../platform/state";
|
||||
// eslint-disable-next-line -- `StateDefinition` used as a type
|
||||
import type { StateDefinition } from "../../platform/state/state-definition";
|
||||
|
||||
import { ClassifiedFormat } from "./classified-format";
|
||||
import { Classifier } from "./classifier";
|
||||
|
||||
/** A key for storing JavaScript objects (`{ an: "example" }`)
|
||||
* in a UserStateSubject.
|
||||
*/
|
||||
// FIXME: promote to class: `ObjectConfiguration<State, Secret, Disclosed>`.
|
||||
// The class receives `encryptor`, `prepareNext`, `adjust`, and `fix`
|
||||
// From `UserStateSubject`. `UserStateSubject` keeps `classify` and
|
||||
// `declassify`. The class should also include serialization
|
||||
// facilities (to be used in place of JSON.parse/stringify) in it's
|
||||
// options. Also allow swap between "classifier" and "classification"; the
|
||||
// latter is a list of properties/arguments to the specific classifier in-use.
|
||||
export type ObjectKey<State, Secret = State, Disclosed = Record<string, never>> = {
|
||||
target: "object";
|
||||
key: string;
|
||||
state: StateDefinition;
|
||||
classifier: Classifier<State, Disclosed, Secret>;
|
||||
format: "plain" | "classified";
|
||||
options: UserKeyDefinitionOptions<State>;
|
||||
};
|
||||
|
||||
export function isObjectKey(key: any): key is ObjectKey<unknown> {
|
||||
return key.target === "object" && "format" in key && "classifier" in key;
|
||||
}
|
||||
|
||||
export function toUserKeyDefinition<State, Secret, Disclosed>(
|
||||
key: ObjectKey<State, Secret, Disclosed>,
|
||||
) {
|
||||
if (key.format === "plain") {
|
||||
const plain = new UserKeyDefinition<State>(key.state, key.key, key.options);
|
||||
|
||||
return plain;
|
||||
} else if (key.format === "classified") {
|
||||
const classified = new UserKeyDefinition<ClassifiedFormat<void, Disclosed>>(
|
||||
key.state,
|
||||
key.key,
|
||||
{
|
||||
cleanupDelayMs: key.options.cleanupDelayMs,
|
||||
deserializer: (jsonValue) => jsonValue as ClassifiedFormat<void, Disclosed>,
|
||||
clearOn: key.options.clearOn,
|
||||
},
|
||||
);
|
||||
|
||||
return classified;
|
||||
} else {
|
||||
throw new Error(`unknown format: ${key.format}`);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DynamicStateConstraints, StateConstraints } from "../types";
|
||||
import { DynamicStateConstraints, StateConstraints, SubjectConstraints } from "../types";
|
||||
|
||||
/** A pattern for types that depend upon a dynamic set of constraints.
|
||||
*
|
||||
@@ -10,12 +10,12 @@ import { DynamicStateConstraints, StateConstraints } from "../types";
|
||||
* last-emitted constraints. If `constraints$` completes, the consumer should
|
||||
* continue using the last-emitted constraints.
|
||||
*/
|
||||
export type StateConstraintsDependency<State> = {
|
||||
export type SubjectConstraintsDependency<State> = {
|
||||
/** A stream that emits constraints when subscribed and when the
|
||||
* constraints change. The stream should not emit `null` or
|
||||
* `undefined`.
|
||||
*/
|
||||
constraints$: Observable<StateConstraints<State> | DynamicStateConstraints<State>>;
|
||||
constraints$: Observable<SubjectConstraints<State>>;
|
||||
};
|
||||
|
||||
/** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`.
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Simplify } from "type-fest";
|
||||
import { RequireExactlyOne, Simplify } from "type-fest";
|
||||
|
||||
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
|
||||
import {
|
||||
Dependencies,
|
||||
SingleUserDependency,
|
||||
SingleUserEncryptorDependency,
|
||||
WhenDependency,
|
||||
} from "../dependencies";
|
||||
|
||||
import { StateConstraintsDependency } from "./state-constraints-dependency";
|
||||
import { SubjectConstraintsDependency } from "./state-constraints-dependency";
|
||||
|
||||
/** dependencies accepted by the user state subject */
|
||||
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
||||
SingleUserDependency &
|
||||
RequireExactlyOne<
|
||||
SingleUserDependency & SingleUserEncryptorDependency,
|
||||
"singleUserEncryptor$" | "singleUserId$"
|
||||
> &
|
||||
Partial<WhenDependency> &
|
||||
Partial<Dependencies<Dependency>> &
|
||||
Partial<StateConstraintsDependency<State>> & {
|
||||
Partial<SubjectConstraintsDependency<State>> & {
|
||||
/** Compute the next stored value. If this is not set, values
|
||||
* provided to `next` unconditionally override state.
|
||||
* @param current the value stored in state
|
||||
|
||||
@@ -1,14 +1,50 @@
|
||||
import { BehaviorSubject, of, Subject } from "rxjs";
|
||||
|
||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec";
|
||||
import { UserBound } from "../dependencies";
|
||||
import { PrivateClassifier } from "../private-classifier";
|
||||
import { StateConstraints } from "../types";
|
||||
|
||||
import { ClassifiedFormat } from "./classified-format";
|
||||
import { ObjectKey } from "./object-key";
|
||||
import { UserEncryptor } from "./user-encryptor.abstraction";
|
||||
import { UserStateSubject } from "./user-state-subject";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
type TestType = { foo: string };
|
||||
const SomeKey = new UserKeyDefinition<TestType>(GENERATOR_DISK, "TestKey", {
|
||||
deserializer: (d) => d as TestType,
|
||||
clearOn: [],
|
||||
});
|
||||
|
||||
const SomeObjectKey = {
|
||||
target: "object",
|
||||
key: "TestObjectKey",
|
||||
state: GENERATOR_DISK,
|
||||
classifier: new PrivateClassifier(),
|
||||
format: "classified",
|
||||
options: {
|
||||
deserializer: (d) => d as TestType,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
} satisfies ObjectKey<TestType>;
|
||||
|
||||
const SomeEncryptor: UserEncryptor = {
|
||||
userId: SomeUser,
|
||||
|
||||
encrypt(secret) {
|
||||
const tmp: any = secret;
|
||||
return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any);
|
||||
},
|
||||
|
||||
decrypt(secret) {
|
||||
const tmp: any = JSON.parse(secret.encryptedString);
|
||||
return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any);
|
||||
},
|
||||
};
|
||||
|
||||
function fooMaxLength(maxLength: number): StateConstraints<TestType> {
|
||||
return Object.freeze({
|
||||
@@ -43,7 +79,11 @@ describe("UserStateSubject", () => {
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const when$ = new BehaviorSubject(true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||
singleUserId$,
|
||||
nextValue,
|
||||
when$,
|
||||
});
|
||||
|
||||
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
|
||||
subject.next({ foo: "next" });
|
||||
@@ -65,7 +105,11 @@ describe("UserStateSubject", () => {
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const when$ = new BehaviorSubject(true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||
singleUserId$,
|
||||
nextValue,
|
||||
when$,
|
||||
});
|
||||
|
||||
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
|
||||
subject.next({ foo: "next" });
|
||||
@@ -79,11 +123,35 @@ describe("UserStateSubject", () => {
|
||||
expect(nextValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores repeated singleUserEncryptor$ emissions", async () => {
|
||||
// this test looks for `nextValue` because a subscription isn't necessary for
|
||||
// the subject to update
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const singleUserEncryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor: null });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||
nextValue,
|
||||
singleUserEncryptor$,
|
||||
});
|
||||
|
||||
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
|
||||
subject.next({ foo: "next" });
|
||||
await awaitAsync();
|
||||
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
|
||||
await awaitAsync();
|
||||
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
|
||||
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
|
||||
await awaitAsync();
|
||||
|
||||
expect(nextValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("waits for constraints$", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.next(fooMaxLength(3));
|
||||
@@ -91,13 +159,28 @@ describe("UserStateSubject", () => {
|
||||
|
||||
expect(initResult).toEqual({ foo: "ini" });
|
||||
});
|
||||
|
||||
it("waits for singleUserEncryptor$", async () => {
|
||||
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
|
||||
SomeUser,
|
||||
{ id: null, secret: '{"foo":"init"}', disclosed: {} },
|
||||
);
|
||||
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
|
||||
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor });
|
||||
const [initResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(initResult).toEqual({ foo: "decrypt(init)" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("next", () => {
|
||||
it("emits the next value", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
const expected: TestType = { foo: "next" };
|
||||
|
||||
let actual: TestType = null;
|
||||
@@ -114,7 +197,7 @@ describe("UserStateSubject", () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
|
||||
let actual: TestType = null;
|
||||
subject.subscribe((value) => {
|
||||
@@ -132,7 +215,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const shouldUpdate = jest.fn(() => true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
@@ -147,7 +230,7 @@ describe("UserStateSubject", () => {
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const shouldUpdate = jest.fn(() => true);
|
||||
const dependencyValue = { bar: "dependency" };
|
||||
const subject = new UserStateSubject(state, {
|
||||
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||
singleUserId$,
|
||||
shouldUpdate,
|
||||
dependencies$: of(dependencyValue),
|
||||
@@ -165,7 +248,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const shouldUpdate = jest.fn(() => true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
|
||||
const expected: TestType = { foo: "next" };
|
||||
|
||||
let actual: TestType = null;
|
||||
@@ -183,7 +266,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const shouldUpdate = jest.fn(() => false);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
await awaitAsync();
|
||||
@@ -200,7 +283,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue });
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
@@ -215,7 +298,7 @@ describe("UserStateSubject", () => {
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const dependencyValue = { bar: "dependency" };
|
||||
const subject = new UserStateSubject(state, {
|
||||
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||
singleUserId$,
|
||||
nextValue,
|
||||
dependencies$: of(dependencyValue),
|
||||
@@ -236,7 +319,11 @@ describe("UserStateSubject", () => {
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const when$ = new BehaviorSubject(true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||
singleUserId$,
|
||||
nextValue,
|
||||
when$,
|
||||
});
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
@@ -253,7 +340,11 @@ describe("UserStateSubject", () => {
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const when$ = new BehaviorSubject(false);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||
singleUserId$,
|
||||
nextValue,
|
||||
when$,
|
||||
});
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
@@ -265,42 +356,52 @@ describe("UserStateSubject", () => {
|
||||
expect(nextValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits to evaluate nextValue until singleUserId$ emits", async () => {
|
||||
// this test looks for `nextValue` because a subscription isn't necessary for
|
||||
it("waits to evaluate `UserState.update` until singleUserId$ emits", async () => {
|
||||
// this test looks for `nextMock` because a subscription isn't necessary for
|
||||
// the subject to update.
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new Subject<UserId>();
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
|
||||
// precondition: subject doesn't update after `next`
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
await awaitAsync();
|
||||
expect(nextValue).not.toHaveBeenCalled();
|
||||
expect(state.nextMock).not.toHaveBeenCalled();
|
||||
|
||||
singleUserId$.next(SomeUser);
|
||||
await awaitAsync();
|
||||
|
||||
expect(nextValue).toHaveBeenCalled();
|
||||
expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" });
|
||||
});
|
||||
|
||||
it("applies constraints$ on init", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => {
|
||||
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
|
||||
SomeUser,
|
||||
{ id: null, secret: '{"foo":"init"}', disclosed: null },
|
||||
);
|
||||
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
|
||||
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
|
||||
|
||||
const [result] = await tracker.pauseUntilReceived(1);
|
||||
// precondition: subject doesn't update after `next`
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
await awaitAsync();
|
||||
expect(state.nextMock).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({ foo: "in" });
|
||||
singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor });
|
||||
await awaitAsync();
|
||||
|
||||
const encrypted = { foo: "encrypt(next)" };
|
||||
expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null });
|
||||
});
|
||||
|
||||
it("applies dynamic constraints", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
const expected: TestType = { foo: "next" };
|
||||
const emission = tracker.expectEmission();
|
||||
@@ -311,24 +412,11 @@ describe("UserStateSubject", () => {
|
||||
expect(actual).toEqual({ foo: "" });
|
||||
});
|
||||
|
||||
it("applies constraints$ on constraints$ emission", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.next(fooMaxLength(1));
|
||||
const [, result] = await tracker.pauseUntilReceived(2);
|
||||
|
||||
expect(result).toEqual({ foo: "i" });
|
||||
});
|
||||
|
||||
it("applies constraints$ on next", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
@@ -341,7 +429,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.next(fooMaxLength(3));
|
||||
@@ -355,13 +443,17 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const results: any[] = [];
|
||||
subject.subscribe((r) => {
|
||||
results.push(r);
|
||||
});
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
constraints$.next(fooMaxLength(3));
|
||||
await awaitAsync();
|
||||
// `init` is also waiting and is processed before `next`
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(2);
|
||||
const [, nextResult] = results;
|
||||
|
||||
expect(nextResult).toEqual({ foo: "nex" });
|
||||
});
|
||||
@@ -370,7 +462,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(3));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.error({ some: "error" });
|
||||
@@ -384,7 +476,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(3));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.complete();
|
||||
@@ -399,7 +491,7 @@ describe("UserStateSubject", () => {
|
||||
it("emits errors", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
const expected: TestType = { foo: "error" };
|
||||
|
||||
let actual: TestType = null;
|
||||
@@ -418,7 +510,7 @@ describe("UserStateSubject", () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
|
||||
let actual: TestType = null;
|
||||
subject.subscribe({
|
||||
@@ -437,7 +529,7 @@ describe("UserStateSubject", () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
|
||||
let shouldNotRun = false;
|
||||
subject.subscribe({
|
||||
@@ -457,7 +549,7 @@ describe("UserStateSubject", () => {
|
||||
it("emits completes", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
|
||||
let actual = false;
|
||||
subject.subscribe({
|
||||
@@ -475,7 +567,7 @@ describe("UserStateSubject", () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
|
||||
let shouldNotRun = false;
|
||||
subject.subscribe({
|
||||
@@ -496,7 +588,7 @@ describe("UserStateSubject", () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
|
||||
let timesRun = 0;
|
||||
subject.subscribe({
|
||||
@@ -513,11 +605,36 @@ describe("UserStateSubject", () => {
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
it("applies constraints$ on init", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
const [result] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(result).toEqual({ foo: "in" });
|
||||
});
|
||||
|
||||
it("applies constraints$ on constraints$ emission", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.next(fooMaxLength(1));
|
||||
const [, result] = await tracker.pauseUntilReceived(2);
|
||||
|
||||
expect(result).toEqual({ foo: "i" });
|
||||
});
|
||||
|
||||
it("completes when singleUserId$ completes", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
|
||||
let actual = false;
|
||||
subject.subscribe({
|
||||
@@ -531,12 +648,32 @@ describe("UserStateSubject", () => {
|
||||
expect(actual).toBeTruthy();
|
||||
});
|
||||
|
||||
it("completes when singleUserId$ completes", async () => {
|
||||
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
|
||||
SomeUser,
|
||||
{ id: null, secret: '{"foo":"init"}', disclosed: null },
|
||||
);
|
||||
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
|
||||
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
|
||||
|
||||
let actual = false;
|
||||
subject.subscribe({
|
||||
complete: () => {
|
||||
actual = true;
|
||||
},
|
||||
});
|
||||
singleUserEncryptor$.complete();
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toBeTruthy();
|
||||
});
|
||||
|
||||
it("completes when when$ completes", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const when$ = new BehaviorSubject(true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, when$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ });
|
||||
|
||||
let actual = false;
|
||||
subject.subscribe({
|
||||
@@ -557,7 +694,7 @@ describe("UserStateSubject", () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
const errorUserId = "error" as UserId;
|
||||
|
||||
let error = false;
|
||||
@@ -572,11 +709,32 @@ describe("UserStateSubject", () => {
|
||||
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId });
|
||||
});
|
||||
|
||||
it("errors when singleUserEncryptor$ changes", async () => {
|
||||
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
|
||||
SomeUser,
|
||||
{ id: null, secret: '{"foo":"init"}', disclosed: null },
|
||||
);
|
||||
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
|
||||
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
|
||||
const errorUserId = "error" as UserId;
|
||||
|
||||
let error = false;
|
||||
subject.subscribe({
|
||||
error: (e: unknown) => {
|
||||
error = e as any;
|
||||
},
|
||||
});
|
||||
singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor });
|
||||
await awaitAsync();
|
||||
|
||||
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId });
|
||||
});
|
||||
|
||||
it("errors when singleUserId$ errors", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
const expected = { error: "description" };
|
||||
|
||||
let actual = false;
|
||||
@@ -591,12 +749,31 @@ describe("UserStateSubject", () => {
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when singleUserEncryptor$ errors", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserEncryptor$ });
|
||||
const expected = { error: "description" };
|
||||
|
||||
let actual = false;
|
||||
subject.subscribe({
|
||||
error: (e: unknown) => {
|
||||
actual = e as any;
|
||||
},
|
||||
});
|
||||
singleUserEncryptor$.error(expected);
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when when$ errors", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const when$ = new BehaviorSubject(true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, when$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ });
|
||||
const expected = { error: "description" };
|
||||
|
||||
let actual = false;
|
||||
@@ -616,7 +793,7 @@ describe("UserStateSubject", () => {
|
||||
it("returns the userId to which the subject is bound", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new Subject<UserId>();
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
|
||||
expect(subject.userId).toEqual(SomeUser);
|
||||
});
|
||||
@@ -626,7 +803,7 @@ describe("UserStateSubject", () => {
|
||||
it("emits the next value with an empty constraint", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected: TestType = { foo: "next" };
|
||||
const emission = tracker.expectEmission();
|
||||
@@ -642,7 +819,7 @@ describe("UserStateSubject", () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
|
||||
subject.complete();
|
||||
@@ -657,7 +834,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected = fooMaxLength(1);
|
||||
const emission = tracker.expectEmission();
|
||||
@@ -673,7 +850,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected: TestType = { foo: "next" };
|
||||
const emission = tracker.expectEmission();
|
||||
@@ -690,7 +867,7 @@ describe("UserStateSubject", () => {
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const expected = fooMaxLength(2);
|
||||
const constraints$ = new BehaviorSubject(expected);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
@@ -705,7 +882,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected = fooMaxLength(3);
|
||||
constraints$.next(expected);
|
||||
@@ -722,7 +899,7 @@ describe("UserStateSubject", () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected = fooMaxLength(3);
|
||||
|
||||
@@ -740,7 +917,7 @@ describe("UserStateSubject", () => {
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const expected = fooMaxLength(3);
|
||||
const constraints$ = new BehaviorSubject(expected);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
|
||||
constraints$.error({ some: "error" });
|
||||
@@ -756,7 +933,7 @@ describe("UserStateSubject", () => {
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const expected = fooMaxLength(3);
|
||||
const constraints$ = new BehaviorSubject(expected);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
|
||||
constraints$.complete();
|
||||
|
||||
@@ -5,15 +5,10 @@ import {
|
||||
ReplaySubject,
|
||||
filter,
|
||||
map,
|
||||
Subject,
|
||||
takeUntil,
|
||||
pairwise,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
BehaviorSubject,
|
||||
race,
|
||||
ignoreElements,
|
||||
endWith,
|
||||
startWith,
|
||||
Observable,
|
||||
Subscription,
|
||||
@@ -22,16 +17,32 @@ import {
|
||||
combineLatestWith,
|
||||
catchError,
|
||||
EMPTY,
|
||||
concatMap,
|
||||
OperatorFunction,
|
||||
pipe,
|
||||
first,
|
||||
withLatestFrom,
|
||||
scan,
|
||||
skip,
|
||||
} from "rxjs";
|
||||
|
||||
import { SingleUserState } from "@bitwarden/common/platform/state";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SingleUserState, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { WithConstraints } from "../types";
|
||||
import { UserBound } from "../dependencies";
|
||||
import { anyComplete, ready, withLatestReady } from "../rx";
|
||||
import { Constraints, SubjectConstraints, WithConstraints } from "../types";
|
||||
|
||||
import { IdentityConstraint } from "./identity-state-constraint";
|
||||
import { ClassifiedFormat, isClassifiedFormat } from "./classified-format";
|
||||
import { unconstrained$ } from "./identity-state-constraint";
|
||||
import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key";
|
||||
import { isDynamic } from "./state-constraints-dependency";
|
||||
import { UserEncryptor } from "./user-encryptor.abstraction";
|
||||
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
|
||||
|
||||
type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State };
|
||||
|
||||
/**
|
||||
* Adapt a state provider to an rxjs subject.
|
||||
*
|
||||
@@ -44,14 +55,20 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"
|
||||
* @template State the state stored by the subject
|
||||
* @template Dependencies use-specific dependencies provided by the user.
|
||||
*/
|
||||
export class UserStateSubject<State extends object, Dependencies = null>
|
||||
export class UserStateSubject<
|
||||
State extends object,
|
||||
Secret = State,
|
||||
Disclosed = never,
|
||||
Dependencies = null,
|
||||
>
|
||||
extends Observable<State>
|
||||
implements SubjectLike<State>
|
||||
{
|
||||
/**
|
||||
* Instantiates the user state subject
|
||||
* @param state the backing store of the subject
|
||||
* @param dependencies tailor the subject's behavior for a particular
|
||||
* Instantiates the user state subject bound to a persistent backing store
|
||||
* @param key identifies the persistent backing store
|
||||
* @param getState creates a persistent backing store using a key
|
||||
* @param context tailor the subject's behavior for a particular
|
||||
* purpose.
|
||||
* @param dependencies.when$ blocks updates to the state subject until
|
||||
* this becomes true. When this occurs, only the last-received update
|
||||
@@ -61,93 +78,306 @@ export class UserStateSubject<State extends object, Dependencies = null>
|
||||
* is available.
|
||||
*/
|
||||
constructor(
|
||||
private state: SingleUserState<State>,
|
||||
private dependencies: UserStateSubjectDependencies<State, Dependencies>,
|
||||
private key: UserKeyDefinition<State> | ObjectKey<State, Secret, Disclosed>,
|
||||
getState: (key: UserKeyDefinition<unknown>) => SingleUserState<unknown>,
|
||||
private context: UserStateSubjectDependencies<State, Dependencies>,
|
||||
) {
|
||||
super();
|
||||
|
||||
if (isObjectKey(this.key)) {
|
||||
// classification and encryption only supported with `ObjectKey`
|
||||
this.objectKey = this.key;
|
||||
this.stateKey = toUserKeyDefinition(this.key);
|
||||
this.state = getState(this.stateKey);
|
||||
} else {
|
||||
// raw state access granted with `UserKeyDefinition`
|
||||
this.objectKey = null;
|
||||
this.stateKey = this.key as UserKeyDefinition<State>;
|
||||
this.state = getState(this.stateKey);
|
||||
}
|
||||
|
||||
// normalize dependencies
|
||||
const when$ = (this.dependencies.when$ ?? new BehaviorSubject(true)).pipe(
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const userIdAvailable$ = this.dependencies.singleUserId$.pipe(
|
||||
startWith(state.userId),
|
||||
pairwise(),
|
||||
map(([expectedUserId, actualUserId]) => {
|
||||
if (expectedUserId === actualUserId) {
|
||||
return true;
|
||||
} else {
|
||||
throw { expectedUserId, actualUserId };
|
||||
}
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const constraints$ = (
|
||||
this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint<State>())
|
||||
).pipe(
|
||||
// FIXME: this should probably log that an error occurred
|
||||
catchError(() => EMPTY),
|
||||
);
|
||||
const when$ = (this.context.when$ ?? new BehaviorSubject(true)).pipe(distinctUntilChanged());
|
||||
|
||||
// normalize input in case this `UserStateSubject` is not the only
|
||||
// observer of the backing store
|
||||
const input$ = combineLatest([this.input, constraints$]).pipe(
|
||||
map(([input, constraints]) => {
|
||||
const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints;
|
||||
const state = calibration.adjust(input);
|
||||
return state;
|
||||
}),
|
||||
);
|
||||
// manage dependencies through replay subjects since `UserStateSubject`
|
||||
// reads them in multiple places
|
||||
const encryptor$ = new ReplaySubject<UserEncryptor>(1);
|
||||
const { singleUserId$, singleUserEncryptor$ } = this.context;
|
||||
this.encryptor(singleUserEncryptor$ ?? singleUserId$).subscribe(encryptor$);
|
||||
|
||||
// when the output subscription completes, its last-emitted value
|
||||
// loops around to the input for finalization
|
||||
const finalize$ = this.pipe(
|
||||
last(),
|
||||
combineLatestWith(constraints$),
|
||||
map(([output, constraints]) => {
|
||||
const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints;
|
||||
const state = calibration.fix(output);
|
||||
return state;
|
||||
}),
|
||||
);
|
||||
const updates$ = concat(input$, finalize$);
|
||||
const constraints$ = new ReplaySubject<SubjectConstraints<State>>(1);
|
||||
(this.context.constraints$ ?? unconstrained$<State>())
|
||||
.pipe(
|
||||
// FIXME: this should probably log that an error occurred
|
||||
catchError(() => EMPTY),
|
||||
)
|
||||
.subscribe(constraints$);
|
||||
|
||||
// observe completion
|
||||
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true));
|
||||
const inputComplete$ = this.input.pipe(ignoreElements(), endWith(true));
|
||||
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true));
|
||||
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$);
|
||||
const dependencies$ = new ReplaySubject<Dependencies>(1);
|
||||
if (this.context.dependencies$) {
|
||||
this.context.dependencies$.subscribe(dependencies$);
|
||||
} else {
|
||||
dependencies$.next(null);
|
||||
}
|
||||
|
||||
// wire output before input so that output normalizes the current state
|
||||
// before any `next` value is processed
|
||||
this.outputSubscription = this.state.state$
|
||||
.pipe(
|
||||
combineLatestWith(constraints$),
|
||||
map(([rawState, constraints]) => {
|
||||
const calibration = isDynamic(constraints)
|
||||
? constraints.calibrate(rawState)
|
||||
: constraints;
|
||||
const state = calibration.adjust(rawState);
|
||||
return {
|
||||
constraints: calibration.constraints,
|
||||
state,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.pipe(this.declassify(encryptor$), this.adjust(combineLatestWith(constraints$)))
|
||||
.subscribe(this.output);
|
||||
this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$])
|
||||
|
||||
const last$ = new ReplaySubject<State>(1);
|
||||
this.output
|
||||
.pipe(
|
||||
filter(([_, when]) => when),
|
||||
map(([state]) => state),
|
||||
takeUntil(completion$),
|
||||
last(),
|
||||
map((o) => o.state),
|
||||
)
|
||||
.subscribe(last$);
|
||||
|
||||
// the update stream simulates the stateProvider's "shouldUpdate"
|
||||
// functionality & applies policy
|
||||
const updates$ = concat(
|
||||
this.input.pipe(
|
||||
this.when(when$),
|
||||
this.adjust(withLatestReady(constraints$)),
|
||||
this.prepareUpdate(this, dependencies$),
|
||||
),
|
||||
// when the output subscription completes, its last-emitted value
|
||||
// loops around to the input for finalization
|
||||
last$.pipe(this.fix(constraints$), this.prepareUpdate(last$, dependencies$)),
|
||||
);
|
||||
|
||||
// classification/encryption bound to the input subscription's lifetime
|
||||
// to ensure that `fix` has access to the encryptor key
|
||||
//
|
||||
// FIXME: this should probably timeout when a lock occurs
|
||||
this.inputSubscription = updates$
|
||||
.pipe(this.classify(encryptor$), takeUntil(anyComplete([when$, this.input, encryptor$])))
|
||||
.subscribe({
|
||||
next: (r) => this.onNext(r),
|
||||
next: (state) => this.onNext(state),
|
||||
error: (e: unknown) => this.onError(e),
|
||||
complete: () => this.onComplete(),
|
||||
});
|
||||
}
|
||||
|
||||
private stateKey: UserKeyDefinition<unknown>;
|
||||
private objectKey: ObjectKey<State, Secret, Disclosed>;
|
||||
|
||||
private encryptor(
|
||||
singleUserEncryptor$: Observable<UserBound<"encryptor", UserEncryptor> | UserId>,
|
||||
): Observable<UserEncryptor> {
|
||||
return singleUserEncryptor$.pipe(
|
||||
// normalize inputs
|
||||
map((maybe): UserBound<"encryptor", UserEncryptor> => {
|
||||
if (typeof maybe === "object" && "encryptor" in maybe) {
|
||||
return maybe;
|
||||
} else if (typeof maybe === "string") {
|
||||
return { encryptor: null, userId: maybe as UserId };
|
||||
} else {
|
||||
throw new Error(`Invalid encryptor input received for ${this.key.key}.`);
|
||||
}
|
||||
}),
|
||||
// fail the stream if the state desyncs from the bound userId
|
||||
startWith({ userId: this.state.userId, encryptor: null } as UserBound<
|
||||
"encryptor",
|
||||
UserEncryptor
|
||||
>),
|
||||
pairwise(),
|
||||
map(([expected, actual]) => {
|
||||
if (expected.userId === actual.userId) {
|
||||
return actual;
|
||||
} else {
|
||||
throw {
|
||||
expectedUserId: expected.userId,
|
||||
actualUserId: actual.userId,
|
||||
};
|
||||
}
|
||||
}),
|
||||
// reduce emissions to when encryptor changes
|
||||
distinctUntilChanged(),
|
||||
map(({ encryptor }) => encryptor),
|
||||
);
|
||||
}
|
||||
|
||||
private when(when$: Observable<boolean>): OperatorFunction<State, State> {
|
||||
return pipe(
|
||||
combineLatestWith(when$.pipe(distinctUntilChanged())),
|
||||
filter(([_, when]) => !!when),
|
||||
map(([input]) => input),
|
||||
);
|
||||
}
|
||||
|
||||
private prepareUpdate(
|
||||
init$: Observable<State>,
|
||||
dependencies$: Observable<Dependencies>,
|
||||
): OperatorFunction<Constrained<State>, State> {
|
||||
return (input$) =>
|
||||
concat(
|
||||
// `init$` becomes the accumulator for `scan`
|
||||
init$.pipe(
|
||||
first(),
|
||||
map((init) => [init, null] as const),
|
||||
),
|
||||
input$.pipe(
|
||||
map((constrained) => constrained.state),
|
||||
withLatestFrom(dependencies$),
|
||||
),
|
||||
).pipe(
|
||||
// scan only emits values that can cause updates
|
||||
scan(([prev], [pending, dependencies]) => {
|
||||
const shouldUpdate = this.context.shouldUpdate?.(prev, pending, dependencies) ?? true;
|
||||
if (shouldUpdate) {
|
||||
// actual update
|
||||
const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending;
|
||||
return [next, dependencies];
|
||||
} else {
|
||||
// false update
|
||||
return [prev, null];
|
||||
}
|
||||
}),
|
||||
// the first emission primes `scan`s aggregator
|
||||
skip(1),
|
||||
map(([state]) => state),
|
||||
|
||||
// clean up false updates
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
|
||||
private adjust(
|
||||
withConstraints: OperatorFunction<State, [State, SubjectConstraints<State>]>,
|
||||
): OperatorFunction<State, Constrained<State>> {
|
||||
return pipe(
|
||||
// how constraints are blended with incoming emissions varies:
|
||||
// * `output` needs to emit when constraints update
|
||||
// * `input` needs to wait until a message flows through the pipe
|
||||
withConstraints,
|
||||
map(([loadedState, constraints]) => {
|
||||
// bypass nulls
|
||||
if (!loadedState) {
|
||||
return {
|
||||
constraints: {} as Constraints<State>,
|
||||
state: null,
|
||||
} satisfies Constrained<State>;
|
||||
}
|
||||
|
||||
const calibration = isDynamic(constraints)
|
||||
? constraints.calibrate(loadedState)
|
||||
: constraints;
|
||||
const adjusted = calibration.adjust(loadedState);
|
||||
|
||||
return {
|
||||
constraints: calibration.constraints,
|
||||
state: adjusted,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private fix(
|
||||
constraints$: Observable<SubjectConstraints<State>>,
|
||||
): OperatorFunction<State, Constrained<State>> {
|
||||
return pipe(
|
||||
combineLatestWith(constraints$),
|
||||
map(([loadedState, constraints]) => {
|
||||
const calibration = isDynamic(constraints)
|
||||
? constraints.calibrate(loadedState)
|
||||
: constraints;
|
||||
const fixed = calibration.fix(loadedState);
|
||||
|
||||
return {
|
||||
constraints: calibration.constraints,
|
||||
state: fixed,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private declassify(encryptor$: Observable<UserEncryptor>): OperatorFunction<unknown, State> {
|
||||
// short-circuit if they key lacks encryption support
|
||||
if (!this.objectKey || this.objectKey.format === "plain") {
|
||||
return (input$) => input$ as Observable<State>;
|
||||
}
|
||||
|
||||
// if the key supports encryption, enable encryptor support
|
||||
if (this.objectKey && this.objectKey.format === "classified") {
|
||||
return pipe(
|
||||
combineLatestWith(encryptor$),
|
||||
concatMap(async ([input, encryptor]) => {
|
||||
// pass through null values
|
||||
if (input === null || input === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// fail fast if the format is incorrect
|
||||
if (!isClassifiedFormat(input)) {
|
||||
throw new Error(`Cannot declassify ${this.key.key}; unknown format.`);
|
||||
}
|
||||
|
||||
// decrypt classified data
|
||||
const { secret, disclosed } = input;
|
||||
const encrypted = EncString.fromJSON(secret);
|
||||
const decryptedSecret = await encryptor.decrypt<Secret>(encrypted);
|
||||
|
||||
// assemble into proper state
|
||||
const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret);
|
||||
const state = this.objectKey.options.deserializer(declassified);
|
||||
|
||||
return state;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unknown serialization format: ${this.objectKey.format}`);
|
||||
}
|
||||
|
||||
private classify(encryptor$: Observable<UserEncryptor>): OperatorFunction<State, unknown> {
|
||||
// short-circuit if they key lacks encryption support; `encryptor` is
|
||||
// readied to preserve `dependencies.singleUserId$` emission contract
|
||||
if (!this.objectKey || this.objectKey.format === "plain") {
|
||||
return pipe(
|
||||
ready(encryptor$),
|
||||
map((input) => input as unknown),
|
||||
);
|
||||
}
|
||||
|
||||
// if the key supports encryption, enable encryptor support
|
||||
if (this.objectKey && this.objectKey.format === "classified") {
|
||||
return pipe(
|
||||
withLatestReady(encryptor$),
|
||||
concatMap(async ([input, encryptor]) => {
|
||||
// fail fast if there's no value
|
||||
if (input === null || input === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// split data by classification level
|
||||
const serialized = JSON.parse(JSON.stringify(input));
|
||||
const classified = this.objectKey.classifier.classify(serialized);
|
||||
|
||||
// protect data
|
||||
const encrypted = await encryptor.encrypt(classified.secret);
|
||||
const secret = JSON.parse(JSON.stringify(encrypted));
|
||||
|
||||
// wrap result in classified format envelope for storage
|
||||
const envelope = {
|
||||
id: null as void,
|
||||
secret,
|
||||
disclosed: classified.disclosed,
|
||||
} satisfies ClassifiedFormat<void, Disclosed>;
|
||||
|
||||
// deliberate type erasure; the type is restored during `declassify`
|
||||
return envelope as unknown;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// FIXME: add "encrypted" format --> key contains encryption logic
|
||||
// CONSIDER: should "classified format" algorithm be embedded in subject keys...?
|
||||
|
||||
throw new Error(`unknown serialization format: ${this.objectKey.format}`);
|
||||
}
|
||||
|
||||
/** The userId to which the subject is bound.
|
||||
*/
|
||||
get userId() {
|
||||
@@ -177,7 +407,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
|
||||
// using subjects to ensure the right semantics are followed;
|
||||
// if greater efficiency becomes desirable, consider implementing
|
||||
// `SubjectLike` directly
|
||||
private input = new Subject<State>();
|
||||
private input = new ReplaySubject<State>(1);
|
||||
private state: SingleUserState<unknown>;
|
||||
private readonly output = new ReplaySubject<WithConstraints<State>>(1);
|
||||
|
||||
/** A stream containing settings and their last-applied constraints. */
|
||||
@@ -188,25 +419,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
|
||||
private inputSubscription: Unsubscribable;
|
||||
private outputSubscription: Unsubscribable;
|
||||
|
||||
private onNext(value: State) {
|
||||
const nextValue = this.dependencies.nextValue ?? ((_: State, next: State) => next);
|
||||
const shouldUpdate = this.dependencies.shouldUpdate ?? ((_: State) => true);
|
||||
|
||||
this.state
|
||||
.update(
|
||||
(state, dependencies) => {
|
||||
const next = nextValue(state, value, dependencies);
|
||||
return next;
|
||||
},
|
||||
{
|
||||
shouldUpdate(current, dependencies) {
|
||||
const update = shouldUpdate(current, value, dependencies);
|
||||
return update;
|
||||
},
|
||||
combineLatestWith: this.dependencies.dependencies$,
|
||||
},
|
||||
)
|
||||
.catch((e: any) => this.onError(e));
|
||||
private onNext(value: unknown) {
|
||||
this.state.update(() => value).catch((e: any) => this.onError(e));
|
||||
}
|
||||
|
||||
private onError(value: any) {
|
||||
@@ -232,8 +446,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
|
||||
private dispose() {
|
||||
if (!this.isDisposed) {
|
||||
// clean up internal subscriptions
|
||||
this.inputSubscription.unsubscribe();
|
||||
this.outputSubscription.unsubscribe();
|
||||
this.inputSubscription?.unsubscribe();
|
||||
this.outputSubscription?.unsubscribe();
|
||||
this.inputSubscription = null;
|
||||
this.outputSubscription = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Simplify } from "type-fest";
|
||||
|
||||
import { IntegrationId } from "./integration";
|
||||
|
||||
/** Constraints that are shared by all primitive field types */
|
||||
type PrimitiveConstraint = {
|
||||
/** `true` indicates the field is required; otherwise the field is optional */
|
||||
@@ -129,6 +131,8 @@ export type StateConstraints<State> = {
|
||||
fix: (state: State) => State;
|
||||
};
|
||||
|
||||
export type SubjectConstraints<T> = StateConstraints<T> | DynamicStateConstraints<T>;
|
||||
|
||||
/** Options that provide contextual information about the application state
|
||||
* when a generator is invoked.
|
||||
*/
|
||||
@@ -144,4 +148,7 @@ export type VaultItemRequest = {
|
||||
/** Options that provide contextual information about the application state
|
||||
* when a generator is invoked.
|
||||
*/
|
||||
export type GenerationRequest = Partial<VaultItemRequest>;
|
||||
export type GenerationRequest = Partial<VaultItemRequest> &
|
||||
Partial<{
|
||||
integration: IntegrationId | null;
|
||||
}>;
|
||||
|
||||
Reference in New Issue
Block a user