1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +00:00

[PM-16792] [PM-16822] Encapsulate encryptor and state provision within UserStateSubject (#13195)

This commit is contained in:
✨ Audrey ✨
2025-02-21 18:00:51 -05:00
committed by GitHub
parent 077e0f89cc
commit b4bfacf6e3
40 changed files with 1437 additions and 1362 deletions

View File

@@ -5,7 +5,7 @@
</ng-container> </ng-container>
</popup-header> </popup-header>
<bit-empty-credential-history *ngIf="!(hasHistory$ | async)" style="display: contents" /> <bit-empty-credential-history *ngIf="!(hasHistory$ | async)" style="display: contents" />
<bit-credential-generator-history *ngIf="hasHistory$ | async" /> <bit-credential-generator-history [account]="account$ | async" *ngIf="hasHistory$ | async" />
<popup-footer slot="footer"> <popup-footer slot="footer">
<button <button
[disabled]="!(hasHistory$ | async)" [disabled]="!(hasHistory$ | async)"

View File

@@ -1,14 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component } from "@angular/core"; import { Component, Input, OnChanges, SimpleChanges, OnInit, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ReplaySubject, Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs";
import { BehaviorSubject, distinctUntilChanged, firstValueFrom, map, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
SemanticLogger,
disabledSemanticLoggerProvider,
ifEnabledSemanticLoggerProvider,
} from "@bitwarden/common/tools/log";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { ButtonModule, ContainerComponent, DialogService } from "@bitwarden/components"; import { ButtonModule, DialogService } from "@bitwarden/components";
import { import {
CredentialGeneratorHistoryComponent as CredentialGeneratorHistoryToolsComponent, CredentialGeneratorHistoryComponent as CredentialGeneratorHistoryToolsComponent,
EmptyCredentialHistoryComponent, EmptyCredentialHistoryComponent,
@@ -27,7 +32,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
imports: [ imports: [
ButtonModule, ButtonModule,
CommonModule, CommonModule,
ContainerComponent,
JslibModule, JslibModule,
PopOutComponent, PopOutComponent,
PopupHeaderComponent, PopupHeaderComponent,
@@ -37,28 +41,65 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
PopupFooterComponent, PopupFooterComponent,
], ],
}) })
export class CredentialGeneratorHistoryComponent { export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, OnDestroy {
protected readonly hasHistory$ = new BehaviorSubject<boolean>(false); private readonly destroyed = new Subject<void>();
protected readonly userId$ = new BehaviorSubject<UserId>(null); protected readonly hasHistory$ = new ReplaySubject<boolean>(1);
protected readonly account$ = new ReplaySubject<Account>(1);
constructor( constructor(
private accountService: AccountService, private accountService: AccountService,
private history: GeneratorHistoryService, private history: GeneratorHistoryService,
private dialogService: DialogService, private dialogService: DialogService,
) { private logService: LogService,
this.accountService.activeAccount$ ) {}
.pipe(
takeUntilDestroyed(),
map(({ id }) => id),
distinctUntilChanged(),
)
.subscribe(this.userId$);
this.userId$ @Input()
account: Account | null;
/** Send structured debug logs from the credential generator component
* to the debugger console.
*
* @warning this may reveal sensitive information in plaintext.
*/
@Input()
debug: boolean = false;
// this `log` initializer is overridden in `ngOnInit`
private log: SemanticLogger = disabledSemanticLoggerProvider({});
async ngOnChanges(changes: SimpleChanges) {
const account = changes?.account;
if (account?.previousValue?.id !== account?.currentValue?.id) {
this.log.debug(
{
previousUserId: account?.previousValue?.id as UserId,
currentUserId: account?.currentValue?.id as UserId,
},
"account input change detected",
);
this.account$.next(account.currentValue ?? this.account);
}
}
async ngOnInit() {
this.log = ifEnabledSemanticLoggerProvider(this.debug, this.logService, {
type: "CredentialGeneratorComponent",
});
if (!this.account) {
this.account = await firstValueFrom(this.accountService.activeAccount$);
this.log.info(
{ userId: this.account.id },
"account not specified; using active account settings",
);
this.account$.next(this.account);
}
this.account$
.pipe( .pipe(
takeUntilDestroyed(), switchMap((account) => account.id && this.history.credentials$(account.id)),
switchMap((id) => id && this.history.credentials$(id)),
map((credentials) => credentials.length > 0), map((credentials) => credentials.length > 0),
takeUntil(this.destroyed),
) )
.subscribe(this.hasHistory$); .subscribe(this.hasHistory$);
} }
@@ -73,7 +114,14 @@ export class CredentialGeneratorHistoryComponent {
}); });
if (confirmed) { if (confirmed) {
await this.history.clear(await firstValueFrom(this.userId$)); await this.history.clear((await firstValueFrom(this.account$)).id);
} }
}; };
ngOnDestroy() {
this.destroyed.next();
this.destroyed.complete();
this.log.debug("component destroyed");
}
} }

View File

@@ -1,11 +1,7 @@
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction";
import { UserEncryptor } from "./cryptography/user-encryptor.abstraction";
/** error emitted when the `SingleUserDependency` changes Ids */ /** error emitted when the `SingleUserDependency` changes Ids */
export type UserChangedError = { export type UserChangedError = {
/** the userId pinned by the single user dependency */ /** the userId pinned by the single user dependency */
@@ -22,22 +18,21 @@ export type OrganizationChangedError = {
actualOrganizationId: OrganizationId; actualOrganizationId: OrganizationId;
}; };
/** A pattern for types that depend upon a dynamic policy stream and return /** A pattern for types that depend upon the lifetime of a fixed dependency.
* an observable. * The dependency's lifetime is tracked through the observable. The observable
* emits the dependency once it becomes available and completes when the
* dependency becomes unavailable.
* *
* Consumers of this dependency should emit when `policy$` * Consumers of this dependency should emit a `SequenceError` if the dependency emits
* emits, provided that the latest message materially * multiple times. When the dependency completes, the consumer should also
* changes the output of the consumer. If `policy$` emits * complete. When the dependency errors, the consumer should also error.
* an unrecoverable error, the consumer should continue using
* the last-emitted policy. If `policy$` completes, the consumer
* should continue using the last-emitted policy.
*/ */
export type PolicyDependency = { export type BoundDependency<Name extends string, T> = {
/** A stream that emits policies when subscribed and /** A stream that emits a dependency once it becomes available
* when the policy changes. The stream should not * and completes when the dependency becomes unavailable. The stream emits
* emit null or undefined. * only once per subscription and never emits null or undefined.
*/ */
policy$: Observable<Policy[]>; [K in `${Name}$`]: Observable<T>;
}; };
/** A pattern for types that depend upon a dynamic userid and return /** A pattern for types that depend upon a dynamic userid and return
@@ -72,26 +67,6 @@ export type OrganizationBound<K extends keyof any, T> = { [P in K]: T } & {
organizationId: OrganizationId; organizationId: OrganizationId;
}; };
/** A pattern for types that depend upon a fixed-key encryptor and return
* an observable.
*
* Consumers of this dependency should emit a `OrganizationChangedError` if
* the bound OrganizationId changes or if the encryptor changes. If
* `singleOrganizationEncryptor$` 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 `singleOrganizationEncryptor$` emits an unrecoverable error, the consumer
* should also emit the error.
*/
export type SingleOrganizationEncryptorDependency = {
/** A stream that emits an encryptor when subscribed and the org key
* is available, and completes when the org key is no longer available.
* The stream should not emit null or undefined.
*/
singleOrgEncryptor$: Observable<OrganizationBound<"encryptor", OrganizationEncryptor>>;
};
/** A pattern for types that depend upon a fixed-value organizationId and return /** A pattern for types that depend upon a fixed-value organizationId and return
* an observable. * an observable.
* *
@@ -112,26 +87,6 @@ export type SingleOrganizationDependency = {
singleOrganizationId$: Observable<UserBound<"organizationId", OrganizationId>>; singleOrganizationId$: Observable<UserBound<"organizationId", OrganizationId>>;
}; };
/** 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 /** A pattern for types that depend upon a fixed-value userid and return
* an observable. * an observable.
* *
@@ -182,22 +137,6 @@ export type WhenDependency = {
when$: Observable<boolean>; when$: Observable<boolean>;
}; };
/** A pattern for types that allow their managed settings to
* be overridden.
*
* Consumers of this dependency should emit when `settings$`
* change. If `settings$` completes, the consumer should also
* complete. If `settings$` errors, the consumer should also
* emit the error.
*/
export type SettingsDependency<Settings> = {
/** A stream that emits settings when settings become available
* and when they change. If the settings are not available, the
* stream should wait to emit until they become available.
*/
settings$: Observable<Settings>;
};
/** A pattern for types that accept an arbitrary dependency and /** A pattern for types that accept an arbitrary dependency and
* inject it into behavior-customizing functions. * inject it into behavior-customizing functions.
* *

View File

@@ -20,7 +20,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, {
message: "this is a debug message", message: "this is a debug message",
level: LogLevelType.Debug, level: "debug",
}); });
}); });
@@ -31,7 +31,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, {
content: { example: "this is content" }, content: { example: "this is content" },
level: LogLevelType.Debug, level: "debug",
}); });
}); });
@@ -43,7 +43,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
content: { example: "this is content" }, content: { example: "this is content" },
message: "this is a message", message: "this is a message",
level: LogLevelType.Info, level: "information",
}); });
}); });
}); });
@@ -56,7 +56,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
message: "this is an info message", message: "this is an info message",
level: LogLevelType.Info, level: "information",
}); });
}); });
@@ -67,7 +67,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
content: { example: "this is content" }, content: { example: "this is content" },
level: LogLevelType.Info, level: "information",
}); });
}); });
@@ -79,7 +79,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, {
content: { example: "this is content" }, content: { example: "this is content" },
message: "this is a message", message: "this is a message",
level: LogLevelType.Info, level: "information",
}); });
}); });
}); });
@@ -92,7 +92,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, {
message: "this is a warning message", message: "this is a warning message",
level: LogLevelType.Warning, level: "warning",
}); });
}); });
@@ -103,7 +103,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, {
content: { example: "this is content" }, content: { example: "this is content" },
level: LogLevelType.Warning, level: "warning",
}); });
}); });
@@ -115,7 +115,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, {
content: { example: "this is content" }, content: { example: "this is content" },
message: "this is a message", message: "this is a message",
level: LogLevelType.Warning, level: "warning",
}); });
}); });
}); });
@@ -128,7 +128,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
message: "this is an error message", message: "this is an error message",
level: LogLevelType.Error, level: "error",
}); });
}); });
@@ -139,7 +139,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
content: { example: "this is content" }, content: { example: "this is content" },
level: LogLevelType.Error, level: "error",
}); });
}); });
@@ -151,7 +151,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
content: { example: "this is content" }, content: { example: "this is content" },
message: "this is a message", message: "this is a message",
level: LogLevelType.Error, level: "error",
}); });
}); });
}); });
@@ -164,7 +164,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
message: "this is an error message", message: "this is an error message",
level: LogLevelType.Error, level: "error",
}); });
}); });
@@ -178,7 +178,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
content: { example: "this is content" }, content: { example: "this is content" },
message: "this is an error message", message: "this is an error message",
level: LogLevelType.Error, level: "error",
}); });
}); });
@@ -192,7 +192,7 @@ describe("DefaultSemanticLogger", () => {
expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, {
content: "this is content", content: "this is content",
message: "this is an error message", message: "this is an error message",
level: LogLevelType.Error, level: "error",
}); });
}); });
}); });

View File

@@ -52,7 +52,7 @@ export class DefaultSemanticLogger<Context extends object> implements SemanticLo
...this.context, ...this.context,
message, message,
content: content ?? undefined, content: content ?? undefined,
level, level: stringifyLevel(level),
}; };
if (typeof content === "string" && !message) { if (typeof content === "string" && !message) {
@@ -63,3 +63,18 @@ export class DefaultSemanticLogger<Context extends object> implements SemanticLo
this.logger.write(level, log); this.logger.write(level, log);
} }
} }
function stringifyLevel(level: LogLevelType) {
switch (level) {
case LogLevelType.Debug:
return "debug";
case LogLevelType.Info:
return "information";
case LogLevelType.Warning:
return "warning";
case LogLevelType.Error:
return "error";
default:
return `${level}`;
}
}

View File

@@ -28,3 +28,22 @@ export function disabledSemanticLoggerProvider<Context extends object>(
export function consoleSemanticLoggerProvider(logger: LogService): SemanticLogger { export function consoleSemanticLoggerProvider(logger: LogService): SemanticLogger {
return new DefaultSemanticLogger(logger, {}); return new DefaultSemanticLogger(logger, {});
} }
/** Instantiates a semantic logger that emits logs to the console.
* @param context a static payload that is cloned when the logger
* logs a message. The `messages`, `level`, and `content` fields
* are reserved for use by loggers.
* @param settings specializes how the semantic logger functions.
* If this is omitted, the logger suppresses debug messages.
*/
export function ifEnabledSemanticLoggerProvider<Context extends object>(
enable: boolean,
logger: LogService,
context: Jsonify<Context>,
) {
if (enable) {
return new DefaultSemanticLogger(logger, context);
} else {
return disabledSemanticLoggerProvider(context);
}
}

View File

@@ -1,2 +1,2 @@
export { disabledSemanticLoggerProvider, consoleSemanticLoggerProvider } from "./factory"; export * from "./factory";
export { SemanticLogger } from "./semantic-logger.abstraction"; export { SemanticLogger } from "./semantic-logger.abstraction";

View File

@@ -2,6 +2,7 @@
* include structuredClone in test environment. * include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts * @jest-environment ../../../../shared/test.environment.ts
*/ */
// @ts-strict-ignore this file explicitly tests what happens when types are ignored
import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs"; import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs";
import { awaitAsync, trackEmissions } from "../../spec"; import { awaitAsync, trackEmissions } from "../../spec";
@@ -14,6 +15,7 @@ import {
ready, ready,
reduceCollection, reduceCollection,
withLatestReady, withLatestReady,
pin,
} from "./rx"; } from "./rx";
describe("errorOnChange", () => { describe("errorOnChange", () => {
@@ -675,3 +677,72 @@ describe("on", () => {
expect(error).toEqual(expected); expect(error).toEqual(expected);
}); });
}); });
describe("pin", () => {
it("emits the first value", async () => {
const input = new Subject<unknown>();
const result: unknown[] = [];
input.pipe(pin()).subscribe((v) => result.push(v));
input.next(1);
expect(result).toEqual([1]);
});
it("filters repeated emissions", async () => {
const input = new Subject<unknown>();
const result: unknown[] = [];
input.pipe(pin({ distinct: (p, c) => p == c })).subscribe((v) => result.push(v));
input.next(1);
input.next(1);
expect(result).toEqual([1]);
});
it("errors if multiple emissions occur", async () => {
const input = new Subject<unknown>();
let error: any = null!;
input.pipe(pin()).subscribe({
error: (e: unknown) => {
error = e;
},
});
input.next(1);
input.next(1);
expect(error).toBeInstanceOf(Error);
expect(error.message).toMatch(/^unknown/);
});
it("names the pinned observables if multiple emissions occur", async () => {
const input = new Subject<unknown>();
let error: any = null!;
input.pipe(pin({ name: () => "example" })).subscribe({
error: (e: unknown) => {
error = e;
},
});
input.next(1);
input.next(1);
expect(error).toBeInstanceOf(Error);
expect(error.message).toMatch(/^example/);
});
it("errors if indistinct emissions occur", async () => {
const input = new Subject<unknown>();
let error: any = null!;
input
.pipe(pin({ distinct: (p, c) => p == c }))
.subscribe({ error: (e: unknown) => (error = e) });
input.next(1);
input.next(2);
expect(error).toBeInstanceOf(Error);
expect(error.message).toMatch(/^unknown/);
});
});

View File

@@ -19,6 +19,7 @@ import {
concatMap, concatMap,
startWith, startWith,
pairwise, pairwise,
MonoTypeOperatorFunction,
} from "rxjs"; } from "rxjs";
/** Returns its input. */ /** Returns its input. */
@@ -213,3 +214,27 @@ export function on<T>(watch$: Observable<any>) {
}), }),
); );
} }
/** Create an observable that emits the first value from the source and
* throws if the observable emits another value.
* @param options.name names the pin to make discovering failing observables easier
* @param options.distinct compares two emissions with each other to determine whether
* the second emission is a duplicate. When this is specified, duplicates are ignored.
* When this isn't specified, any emission after the first causes the pin to throw
* an error.
*/
export function pin<T>(options?: {
name?: () => string;
distinct?: (previous: T, current: T) => boolean;
}): MonoTypeOperatorFunction<T> {
return pipe(
options?.distinct ? distinctUntilChanged(options.distinct) : (i) => i,
map((value, index) => {
if (index > 0) {
throw new Error(`${options?.name?.() ?? "unknown"} observable should only emit one value.`);
} else {
return value;
}
}),
);
}

View File

@@ -10,9 +10,6 @@ import { Classifier } from "./classifier";
* when you are performing your own encryption and decryption. * when you are performing your own encryption and decryption.
* `classified` uses the `ClassifiedFormat` type as its format. * `classified` uses the `ClassifiedFormat` type as its format.
* `secret-state` uses `Array<ClassifiedFormat>` with a length of 1. * `secret-state` uses `Array<ClassifiedFormat>` with a length of 1.
* @remarks - CAUTION! If your on-disk data is not in a correct format,
* the storage system treats the data as corrupt and returns your initial
* value.
*/ */
export type ObjectStorageFormat = "plain" | "classified" | "secret-state"; export type ObjectStorageFormat = "plain" | "classified" | "secret-state";
@@ -27,19 +24,54 @@ export type ObjectStorageFormat = "plain" | "classified" | "secret-state";
// options. Also allow swap between "classifier" and "classification"; the // options. Also allow swap between "classifier" and "classification"; the
// latter is a list of properties/arguments to the specific classifier in-use. // latter is a list of properties/arguments to the specific classifier in-use.
export type ObjectKey<State, Secret = State, Disclosed = Record<string, never>> = { export type ObjectKey<State, Secret = State, Disclosed = Record<string, never>> = {
/** Type of data stored by this key; Object keys always use "object" targets.
* "object" - a singleton value.
* "list" - multiple values identified by their list index.
* "record" - multiple values identified by a uuid.
*/
target: "object"; target: "object";
/** Identifies the stored state */
key: string; key: string;
/** Defines the storage location and parameters for this state */
state: StateDefinition; state: StateDefinition;
/** Defines the visibility and encryption treatment for the stored state.
* Disclosed data is written as plain-text. Secret data is protected with
* the user key.
*/
classifier: Classifier<State, Disclosed, Secret>; classifier: Classifier<State, Disclosed, Secret>;
/** Specifies the format of data written to storage.
* @remarks - CAUTION! If your on-disk data is not in a correct format,
* the storage system treats the data as corrupt and returns your initial
* value.
*/
format: ObjectStorageFormat; format: ObjectStorageFormat;
/** customizes the behavior of the storage location */
options: UserKeyDefinitionOptions<State>; options: UserKeyDefinitionOptions<State>;
/** When this is defined, empty data is replaced with a copy of the initial data.
* This causes the state to always be defined from the perspective of the
* subject's consumer.
*/
initial?: State; initial?: State;
/** For encrypted outputs, determines how much padding is applied to
* encoded inputs. When this isn't specified, each frame is 32 bytes
* long.
*/
frame?: number;
}; };
/** Performs a type inference that identifies object keys. */
export function isObjectKey(key: any): key is ObjectKey<unknown> { export function isObjectKey(key: any): key is ObjectKey<unknown> {
return key.target === "object" && "format" in key && "classifier" in key; return key.target === "object" && "format" in key && "classifier" in key;
} }
/** Converts an object key to a plaform-compatible `UserKeyDefinition`. */
export function toUserKeyDefinition<State, Secret, Disclosed>( export function toUserKeyDefinition<State, Secret, Disclosed>(
key: ObjectKey<State, Secret, Disclosed>, key: ObjectKey<State, Secret, Disclosed>,
) { ) {

View File

@@ -1,20 +1,13 @@
import { RequireExactlyOne, Simplify } from "type-fest"; import { Simplify } from "type-fest";
import { import { Account } from "../../auth/abstractions/account.service";
Dependencies, import { Dependencies, BoundDependency, WhenDependency } from "../dependencies";
SingleUserDependency,
SingleUserEncryptorDependency,
WhenDependency,
} from "../dependencies";
import { SubjectConstraintsDependency } from "./state-constraints-dependency"; import { SubjectConstraintsDependency } from "./state-constraints-dependency";
/** dependencies accepted by the user state subject */ /** dependencies accepted by the user state subject */
export type UserStateSubjectDependencies<State, Dependency> = Simplify< export type UserStateSubjectDependencies<State, Dependency> = Simplify<
RequireExactlyOne< BoundDependency<"account", Account> &
SingleUserDependency & SingleUserEncryptorDependency,
"singleUserEncryptor$" | "singleUserId$"
> &
Partial<WhenDependency> & Partial<WhenDependency> &
Partial<Dependencies<Dependency>> & Partial<Dependencies<Dependency>> &
Partial<SubjectConstraintsDependency<State>> & { Partial<SubjectConstraintsDependency<State>> & {

View File

@@ -0,0 +1,17 @@
import { Jsonify } from "type-fest";
import { StateProvider } from "../../platform/state";
import { LegacyEncryptorProvider } from "../cryptography/legacy-encryptor-provider";
import { SemanticLogger } from "../log";
/** Aggregates user state subject dependencies */
export abstract class UserStateSubjectDependencyProvider {
/** Provides objects that encrypt and decrypt user and organization data */
abstract encryptor: LegacyEncryptorProvider;
/** Provides local object persistence */
abstract state: StateProvider;
/** Provides semantic logging */
abstract log: <Context extends object>(_context: Jsonify<Context>) => SemanticLogger;
}

View File

@@ -1,33 +1,61 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BehaviorSubject, of, Subject } from "rxjs"; import { BehaviorSubject, of, Subject } from "rxjs";
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec"; import {
awaitAsync,
FakeAccountService,
FakeStateProvider,
ObservableTracker,
} from "../../../spec";
import { Account } from "../../auth/abstractions/account.service";
import { GENERATOR_DISK, UserKeyDefinition } from "../../platform/state"; import { GENERATOR_DISK, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { LegacyEncryptorProvider } from "../cryptography/legacy-encryptor-provider";
import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { UserEncryptor } from "../cryptography/user-encryptor.abstraction";
import { UserBound } from "../dependencies"; import { disabledSemanticLoggerProvider } from "../log";
import { PrivateClassifier } from "../private-classifier"; import { PrivateClassifier } from "../private-classifier";
import { StateConstraints } from "../types"; import { StateConstraints } from "../types";
import { ClassifiedFormat } from "./classified-format";
import { ObjectKey } from "./object-key"; import { ObjectKey } from "./object-key";
import { UserStateSubject } from "./user-state-subject"; import { UserStateSubject } from "./user-state-subject";
const SomeUser = "some user" as UserId; const SomeUser = "some user" as UserId;
const SomeAccount = {
id: SomeUser,
email: "someone@example.com",
emailVerified: true,
name: "Someone",
};
const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount);
const SomeOtherAccount = {
id: "some other user" as UserId,
email: "someone@example.com",
emailVerified: true,
name: "Someone",
};
type TestType = { foo: string }; type TestType = { foo: string };
const SomeKey = new UserKeyDefinition<TestType>(GENERATOR_DISK, "TestKey", { const SomeKey = new UserKeyDefinition<TestType>(GENERATOR_DISK, "TestKey", {
deserializer: (d) => d as TestType, deserializer: (d) => d as TestType,
clearOn: [], clearOn: [],
}); });
const SomeObjectKeyDefinition = new UserKeyDefinition<unknown>(GENERATOR_DISK, "TestKey", {
deserializer: (d) => d as unknown,
clearOn: ["logout"],
});
const SomeObjectKey = { const SomeObjectKey = {
target: "object", target: "object",
key: "TestObjectKey", key: SomeObjectKeyDefinition.key,
state: GENERATOR_DISK, state: SomeObjectKeyDefinition.stateDefinition,
classifier: new PrivateClassifier(), classifier: new PrivateClassifier(),
format: "classified", format: "classified",
options: { options: {
deserializer: (d) => d as TestType, deserializer: (d) => d as TestType,
clearOn: ["logout"], clearOn: SomeObjectKeyDefinition.clearOn,
}, },
} satisfies ObjectKey<TestType>; } satisfies ObjectKey<TestType>;
@@ -45,6 +73,25 @@ const SomeEncryptor: UserEncryptor = {
}, },
}; };
const SomeAccountService = new FakeAccountService({
[SomeUser]: SomeAccount,
});
const SomeStateProvider = new FakeStateProvider(SomeAccountService);
const SomeProvider = {
encryptor: {
userEncryptor$: jest.fn(() => {
return new BehaviorSubject({ encryptor: SomeEncryptor, userId: SomeUser }).asObservable();
}),
organizationEncryptor$() {
throw new Error("`organizationEncryptor$` should never be invoked.");
},
} as LegacyEncryptorProvider,
state: SomeStateProvider,
log: disabledSemanticLoggerProvider,
};
function fooMaxLength(maxLength: number): StateConstraints<TestType> { function fooMaxLength(maxLength: number): StateConstraints<TestType> {
return Object.freeze({ return Object.freeze({
constraints: { foo: { maxLength } }, constraints: { foo: { maxLength } },
@@ -68,18 +115,21 @@ const DynamicFooMaxLength = Object.freeze({
}, },
}); });
const SomeKeySomeUserInitialValue = Object.freeze({ foo: "init" });
describe("UserStateSubject", () => { describe("UserStateSubject", () => {
beforeEach(async () => {
await SomeStateProvider.setUserState(SomeKey, SomeKeySomeUserInitialValue, SomeUser);
});
describe("dependencies", () => { describe("dependencies", () => {
it("ignores repeated when$ emissions", async () => { it("ignores repeated when$ emissions", async () => {
// this test looks for `nextValue` because a subscription isn't necessary for // this test looks for `nextValue` because a subscription isn't necessary for
// the subject to update // the subject to update
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(true); const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(SomeKey, () => state, { const subject = new UserStateSubject(SomeKey, SomeProvider, {
singleUserId$, account$: SomeAccount$,
nextValue, nextValue,
when$, when$,
}); });
@@ -96,90 +146,64 @@ describe("UserStateSubject", () => {
expect(nextValue).toHaveBeenCalledTimes(1); expect(nextValue).toHaveBeenCalledTimes(1);
}); });
it("ignores repeated singleUserId$ emissions", async () => { it("errors when account$ changes accounts", async () => {
// this test looks for `nextValue` because a subscription isn't necessary for const account$ = new BehaviorSubject<Account>(SomeAccount);
// the subject to update const subject = new UserStateSubject(SomeKey, SomeProvider, {
const initialValue: TestType = { foo: "init" }; account$,
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); });
const singleUserId$ = new BehaviorSubject(SomeUser); let error: any = null;
const nextValue = jest.fn((_, next) => next); subject.subscribe({
const when$ = new BehaviorSubject(true); error(e: unknown) {
const subject = new UserStateSubject(SomeKey, () => state, { error = e;
singleUserId$, },
nextValue,
when$,
}); });
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously account$.next(SomeOtherAccount);
subject.next({ foo: "next" });
await awaitAsync();
singleUserId$.next(SomeUser);
await awaitAsync();
singleUserId$.next(SomeUser);
singleUserId$.next(SomeUser);
await awaitAsync(); await awaitAsync();
expect(nextValue).toHaveBeenCalledTimes(1); expect(error).toBeInstanceOf(Error);
expect(error.message).toMatch(/UserStateSubject\(generator, TestKey\) \{ account\$ \}/);
}); });
it("ignores repeated singleUserEncryptor$ emissions", async () => { it("waits for account$", async () => {
// this test looks for `nextValue` because a subscription isn't necessary for await SomeStateProvider.setUserState(
// the subject to update SomeObjectKeyDefinition,
const initialValue: TestType = { foo: "init" }; { id: null, secret: '{"foo":"init"}', disclosed: {} } as unknown,
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); SomeUser,
const nextValue = jest.fn((_, next) => next); );
const singleUserEncryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor: null }); const account$ = new Subject<Account>();
const subject = new UserStateSubject(SomeKey, () => state, { const subject = new UserStateSubject(SomeObjectKey, SomeProvider, { account$ });
nextValue,
singleUserEncryptor$,
});
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously const results = [] as any[];
subject.next({ foo: "next" }); subject.subscribe((v) => results.push(v));
await awaitAsync(); // precondition: no immediate emission upon subscribe
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); expect(results).toEqual([]);
await awaitAsync();
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); account$.next(SomeAccount);
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
await awaitAsync(); await awaitAsync();
expect(nextValue).toHaveBeenCalledTimes(1); expect(results).toEqual([{ foo: "decrypt(init)" }]);
}); });
it("waits for constraints$", async () => { 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 constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
const tracker = new ObservableTracker(subject); account$: SomeAccount$,
constraints$,
});
const results = [] as any[];
subject.subscribe((v) => results.push(v));
constraints$.next(fooMaxLength(3)); constraints$.next(fooMaxLength(3));
const [initResult] = await tracker.pauseUntilReceived(1); await awaitAsync();
expect(initResult).toEqual({ foo: "ini" }); expect(results).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", () => { describe("next", () => {
it("emits the next value", async () => { it("emits the next value", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const subject = new UserStateSubject(SomeKey, SomeProvider, { account$: SomeAccount$ });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const expected: TestType = { foo: "next" }; const expected: TestType = { foo: "next" };
let actual: TestType = null; let actual: TestType = null;
@@ -193,44 +217,39 @@ describe("UserStateSubject", () => {
}); });
it("ceases emissions once complete", async () => { it("ceases emissions once complete", async () => {
const initialState = { foo: "init" }; const subject = new UserStateSubject(SomeKey, SomeProvider, { account$: SomeAccount$ });
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual: TestType = null; let actual: TestType = null;
subject.subscribe((value) => { subject.subscribe((value) => {
actual = value; actual = value;
}); });
await awaitAsync();
subject.complete(); subject.complete();
subject.next({ foo: "ignored" }); subject.next({ foo: "ignored" });
await awaitAsync(); await awaitAsync();
expect(actual).toEqual(initialState); expect(actual).toEqual(SomeKeySomeUserInitialValue);
}); });
it("evaluates shouldUpdate", async () => { it("evaluates shouldUpdate", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => true); const shouldUpdate = jest.fn(() => true);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
shouldUpdate,
});
const nextVal: TestType = { foo: "next" }; const nextVal: TestType = { foo: "next" };
subject.next(nextVal); subject.next(nextVal);
await awaitAsync(); await awaitAsync();
expect(shouldUpdate).toHaveBeenCalledWith(initialValue, nextVal, null); expect(shouldUpdate).toHaveBeenCalledWith(SomeKeySomeUserInitialValue, nextVal, null);
}); });
it("evaluates shouldUpdate with a dependency", async () => { it("evaluates shouldUpdate with a dependency", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => true); const shouldUpdate = jest.fn(() => true);
const dependencyValue = { bar: "dependency" }; const dependencyValue = { bar: "dependency" };
const subject = new UserStateSubject(SomeKey, () => state, { const subject = new UserStateSubject(SomeKey, SomeProvider, {
singleUserId$, account$: SomeAccount$,
shouldUpdate, shouldUpdate,
dependencies$: of(dependencyValue), dependencies$: of(dependencyValue),
}); });
@@ -239,15 +258,19 @@ describe("UserStateSubject", () => {
subject.next(nextVal); subject.next(nextVal);
await awaitAsync(); await awaitAsync();
expect(shouldUpdate).toHaveBeenCalledWith(initialValue, nextVal, dependencyValue); expect(shouldUpdate).toHaveBeenCalledWith(
SomeKeySomeUserInitialValue,
nextVal,
dependencyValue,
);
}); });
it("emits a value when shouldUpdate returns `true`", async () => { it("emits a value when shouldUpdate returns `true`", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => true); const shouldUpdate = jest.fn(() => true);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
shouldUpdate,
});
const expected: TestType = { foo: "next" }; const expected: TestType = { foo: "next" };
let actual: TestType = null; let actual: TestType = null;
@@ -261,11 +284,11 @@ describe("UserStateSubject", () => {
}); });
it("retains the current value when shouldUpdate returns `false`", async () => { it("retains the current value when shouldUpdate returns `false`", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => false); const shouldUpdate = jest.fn(() => false);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
shouldUpdate,
});
subject.next({ foo: "next" }); subject.next({ foo: "next" });
await awaitAsync(); await awaitAsync();
@@ -274,31 +297,28 @@ describe("UserStateSubject", () => {
actual = value; actual = value;
}); });
expect(actual).toEqual(initialValue); expect(actual).toEqual(SomeKeySomeUserInitialValue);
}); });
it("evaluates nextValue", async () => { it("evaluates nextValue", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
nextValue,
});
const nextVal: TestType = { foo: "next" }; const nextVal: TestType = { foo: "next" };
subject.next(nextVal); subject.next(nextVal);
await awaitAsync(); await awaitAsync();
expect(nextValue).toHaveBeenCalledWith(initialValue, nextVal, null); expect(nextValue).toHaveBeenCalledWith(SomeKeySomeUserInitialValue, nextVal, null);
}); });
it("evaluates nextValue with a dependency", async () => { it("evaluates nextValue with a dependency", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const dependencyValue = { bar: "dependency" }; const dependencyValue = { bar: "dependency" };
const subject = new UserStateSubject(SomeKey, () => state, { const subject = new UserStateSubject(SomeKey, SomeProvider, {
singleUserId$, account$: SomeAccount$,
nextValue, nextValue,
dependencies$: of(dependencyValue), dependencies$: of(dependencyValue),
}); });
@@ -307,19 +327,16 @@ describe("UserStateSubject", () => {
subject.next(nextVal); subject.next(nextVal);
await awaitAsync(); await awaitAsync();
expect(nextValue).toHaveBeenCalledWith(initialValue, nextVal, dependencyValue); expect(nextValue).toHaveBeenCalledWith(SomeKeySomeUserInitialValue, nextVal, dependencyValue);
}); });
it("evaluates nextValue when when$ is true", async () => { it("evaluates nextValue when when$ is true", async () => {
// this test looks for `nextValue` because a subscription isn't necessary for // this test looks for `nextValue` because a subscription isn't necessary for
// the subject to update // the subject to update
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(true); const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(SomeKey, () => state, { const subject = new UserStateSubject(SomeKey, SomeProvider, {
singleUserId$, account$: SomeAccount$,
nextValue, nextValue,
when$, when$,
}); });
@@ -334,13 +351,10 @@ describe("UserStateSubject", () => {
it("waits to evaluate nextValue until when$ is true", async () => { it("waits to evaluate nextValue until when$ is true", async () => {
// this test looks for `nextValue` because a subscription isn't necessary for // this test looks for `nextValue` because a subscription isn't necessary for
// the subject to update. // the subject to update.
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next); const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(false); const when$ = new BehaviorSubject(false);
const subject = new UserStateSubject(SomeKey, () => state, { const subject = new UserStateSubject(SomeKey, SomeProvider, {
singleUserId$, account$: SomeAccount$,
nextValue, nextValue,
when$, when$,
}); });
@@ -355,62 +369,31 @@ describe("UserStateSubject", () => {
expect(nextValue).toHaveBeenCalled(); expect(nextValue).toHaveBeenCalled();
}); });
it("waits to evaluate `UserState.update` until singleUserId$ emits", async () => { it("waits to evaluate `UserState.update` until account$ emits", async () => {
// this test looks for `nextMock` because a subscription isn't necessary for // this test looks for `nextValue` because a subscription isn't necessary for
// the subject to update. // the subject to update.
const initialValue: TestType = { foo: "init" }; const account$ = new Subject<Account>();
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const nextValue = jest.fn((_, pending) => pending);
const singleUserId$ = new Subject<UserId>(); const subject = new UserStateSubject(SomeKey, SomeProvider, { account$, nextValue });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
// precondition: subject doesn't update after `next` // precondition: subject doesn't update after `next`
const nextVal: TestType = { foo: "next" }; const nextVal: TestType = { foo: "next" };
subject.next(nextVal); subject.next(nextVal);
await awaitAsync(); await awaitAsync();
expect(state.nextMock).not.toHaveBeenCalled(); expect(nextValue).not.toHaveBeenCalled();
singleUserId$.next(SomeUser); account$.next(SomeAccount);
await awaitAsync(); await awaitAsync();
expect(state.nextMock).toHaveBeenCalledWith({ expect(nextValue).toHaveBeenCalledWith(SomeKeySomeUserInitialValue, { foo: "next" }, null);
foo: "next",
// FIXME: don't leak this detail into the test
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
});
});
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$ });
// precondition: subject doesn't update after `next`
const nextVal: TestType = { foo: "next" };
subject.next(nextVal);
await awaitAsync();
expect(state.nextMock).not.toHaveBeenCalled();
singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor });
await awaitAsync();
const encrypted = { foo: "encrypt(next)" };
expect(state.nextMock).toHaveBeenCalledWith({
id: null,
secret: encrypted,
disclosed: null,
// FIXME: don't leak this detail into the test
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
});
}); });
it("applies dynamic constraints", async () => { it("applies dynamic constraints", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(DynamicFooMaxLength); const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
const expected: TestType = { foo: "next" }; const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission(); const emission = tracker.expectEmission();
@@ -422,10 +405,11 @@ describe("UserStateSubject", () => {
}); });
it("applies constraints$ on next", async () => { 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 constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
subject.next({ foo: "next" }); subject.next({ foo: "next" });
@@ -435,10 +419,11 @@ describe("UserStateSubject", () => {
}); });
it("applies latest constraints$ on next", async () => { it("applies latest constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2)); const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(3)); constraints$.next(fooMaxLength(3));
@@ -449,10 +434,11 @@ describe("UserStateSubject", () => {
}); });
it("waits for constraints$", async () => { 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 constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const results: any[] = []; const results: any[] = [];
subject.subscribe((r) => { subject.subscribe((r) => {
results.push(r); results.push(r);
@@ -468,10 +454,11 @@ describe("UserStateSubject", () => {
}); });
it("uses the last-emitted value from constraints$ when constraints$ errors", async () => { it("uses the last-emitted value from constraints$ when constraints$ errors", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(3)); const constraints$ = new BehaviorSubject(fooMaxLength(3));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
constraints$.error({ some: "error" }); constraints$.error({ some: "error" });
@@ -482,10 +469,11 @@ describe("UserStateSubject", () => {
}); });
it("uses the last-emitted value from constraints$ when constraints$ completes", async () => { it("uses the last-emitted value from constraints$ when constraints$ completes", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(3)); const constraints$ = new BehaviorSubject(fooMaxLength(3));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
constraints$.complete(); constraints$.complete();
@@ -498,9 +486,7 @@ describe("UserStateSubject", () => {
describe("error", () => { describe("error", () => {
it("emits errors", async () => { it("emits errors", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const subject = new UserStateSubject(SomeKey, SomeProvider, { account$: SomeAccount$ });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const expected: TestType = { foo: "error" }; const expected: TestType = { foo: "error" };
let actual: TestType = null; let actual: TestType = null;
@@ -516,10 +502,7 @@ describe("UserStateSubject", () => {
}); });
it("ceases emissions once errored", async () => { it("ceases emissions once errored", async () => {
const initialState = { foo: "init" }; const subject = new UserStateSubject(SomeKey, SomeProvider, { account$: SomeAccount$ });
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual: TestType = null; let actual: TestType = null;
subject.subscribe({ subject.subscribe({
@@ -535,10 +518,7 @@ describe("UserStateSubject", () => {
}); });
it("ceases emissions once complete", async () => { it("ceases emissions once complete", async () => {
const initialState = { foo: "init" }; const subject = new UserStateSubject(SomeKey, SomeProvider, { account$: SomeAccount$ });
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let shouldNotRun = false; let shouldNotRun = false;
subject.subscribe({ subject.subscribe({
@@ -556,9 +536,7 @@ describe("UserStateSubject", () => {
describe("complete", () => { describe("complete", () => {
it("emits completes", async () => { it("emits completes", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const subject = new UserStateSubject(SomeKey, SomeProvider, { account$: SomeAccount$ });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual = false; let actual = false;
subject.subscribe({ subject.subscribe({
@@ -573,10 +551,7 @@ describe("UserStateSubject", () => {
}); });
it("ceases emissions once errored", async () => { it("ceases emissions once errored", async () => {
const initialState = { foo: "init" }; const subject = new UserStateSubject(SomeKey, SomeProvider, { account$: SomeAccount$ });
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let shouldNotRun = false; let shouldNotRun = false;
subject.subscribe({ subject.subscribe({
@@ -594,10 +569,7 @@ describe("UserStateSubject", () => {
}); });
it("ceases emissions once complete", async () => { it("ceases emissions once complete", async () => {
const initialState = { foo: "init" }; const subject = new UserStateSubject(SomeKey, SomeProvider, { account$: SomeAccount$ });
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let timesRun = 0; let timesRun = 0;
subject.subscribe({ subject.subscribe({
@@ -615,10 +587,11 @@ describe("UserStateSubject", () => {
describe("subscribe", () => { describe("subscribe", () => {
it("applies constraints$ on init", async () => { 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 constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
const [result] = await tracker.pauseUntilReceived(1); const [result] = await tracker.pauseUntilReceived(1);
@@ -627,10 +600,11 @@ describe("UserStateSubject", () => {
}); });
it("applies constraints$ on constraints$ emission", async () => { 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 constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject); const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(1)); constraints$.next(fooMaxLength(1));
@@ -639,11 +613,9 @@ describe("UserStateSubject", () => {
expect(result).toEqual({ foo: "i" }); expect(result).toEqual({ foo: "i" });
}); });
it("completes when singleUserId$ completes", async () => { it("completes when account$ completes", async () => {
const initialValue: TestType = { foo: "init" }; const account$ = new BehaviorSubject(SomeAccount);
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const subject = new UserStateSubject(SomeKey, SomeProvider, { account$ });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual = false; let actual = false;
subject.subscribe({ subject.subscribe({
@@ -651,38 +623,18 @@ describe("UserStateSubject", () => {
actual = true; actual = true;
}, },
}); });
singleUserId$.complete(); account$.complete();
await awaitAsync();
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(); await awaitAsync();
expect(actual).toBeTruthy(); expect(actual).toBeTruthy();
}); });
it("completes when when$ completes", async () => { 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 when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
when$,
});
let actual = false; let actual = false;
subject.subscribe({ subject.subscribe({
@@ -699,12 +651,9 @@ describe("UserStateSubject", () => {
// FIXME: add test for `this.state.catch` once `FakeSingleUserState` supports // FIXME: add test for `this.state.catch` once `FakeSingleUserState` supports
// simulated errors // simulated errors
it("errors when singleUserId$ changes", async () => { it("errors when account$ changes", async () => {
const initialValue: TestType = { foo: "init" }; const account$ = new BehaviorSubject(SomeAccount);
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue); const subject = new UserStateSubject(SomeKey, SomeProvider, { account$ });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const errorUserId = "error" as UserId;
let error = false; let error = false;
subject.subscribe({ subject.subscribe({
@@ -712,39 +661,15 @@ describe("UserStateSubject", () => {
error = e as any; error = e as any;
}, },
}); });
singleUserId$.next(errorUserId); account$.next(SomeOtherAccount);
await awaitAsync(); await awaitAsync();
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId }); expect(error).toBeInstanceOf(Error);
}); });
it("errors when singleUserEncryptor$ changes", async () => { it("errors when account$ errors", async () => {
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>( const account$ = new BehaviorSubject(SomeAccount);
SomeUser, const subject = new UserStateSubject(SomeKey, SomeProvider, { account$ });
{ 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: SomeUser, encryptor: SomeEncryptor });
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(SomeKey, () => state, { singleUserId$ });
const expected = { error: "description" }; const expected = { error: "description" };
let actual = false; let actual = false;
@@ -753,37 +678,18 @@ describe("UserStateSubject", () => {
actual = e as any; actual = e as any;
}, },
}); });
singleUserId$.error(expected); account$.error(expected);
await awaitAsync();
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(); await awaitAsync();
expect(actual).toEqual(expected); expect(actual).toEqual(expected);
}); });
it("errors when when$ errors", async () => { 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 when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
when$,
});
const expected = { error: "description" }; const expected = { error: "description" };
let actual = false; let actual = false;
@@ -799,21 +705,9 @@ describe("UserStateSubject", () => {
}); });
}); });
describe("userId", () => {
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(SomeKey, () => state, { singleUserId$ });
expect(subject.userId).toEqual(SomeUser);
});
});
describe("withConstraints$", () => { describe("withConstraints$", () => {
it("emits the next value with an empty constraint", async () => { it("emits the next value with an empty constraint", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" }); const subject = new UserStateSubject(SomeKey, SomeProvider, { account$: SomeAccount$ });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const expected: TestType = { foo: "next" }; const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission(); const emission = tracker.expectEmission();
@@ -826,25 +720,23 @@ describe("UserStateSubject", () => {
}); });
it("ceases emissions once the subject completes", async () => { it("ceases emissions once the subject completes", async () => {
const initialState = { foo: "init" }; const subject = new UserStateSubject(SomeKey, SomeProvider, { account$: SomeAccount$ });
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
subject.complete(); subject.complete();
subject.next({ foo: "ignored" }); subject.next({ foo: "ignored" });
const [result] = await tracker.pauseUntilReceived(1); const [result] = await tracker.pauseUntilReceived(1);
expect(result.state).toEqual(initialState); expect(result.state).toEqual(SomeKeySomeUserInitialValue);
expect(tracker.emissions.length).toEqual(1); expect(tracker.emissions.length).toEqual(1);
}); });
it("emits constraints$ on constraints$ emission", async () => { it("emits 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 constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(1); const expected = fooMaxLength(1);
const emission = tracker.expectEmission(); const emission = tracker.expectEmission();
@@ -857,10 +749,11 @@ describe("UserStateSubject", () => {
}); });
it("emits dynamic constraints", async () => { it("emits dynamic constraints", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(DynamicFooMaxLength); const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const expected: TestType = { foo: "next" }; const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission(); const emission = tracker.expectEmission();
@@ -873,11 +766,12 @@ describe("UserStateSubject", () => {
}); });
it("emits constraints$ on next", async () => { it("emits constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(2); const expected = fooMaxLength(2);
const constraints$ = new BehaviorSubject(expected); const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const emission = tracker.expectEmission(); const emission = tracker.expectEmission();
@@ -889,10 +783,11 @@ describe("UserStateSubject", () => {
}); });
it("emits the latest constraints$ on next", async () => { it("emits the latest constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2)); const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(3); const expected = fooMaxLength(3);
constraints$.next(expected); constraints$.next(expected);
@@ -906,10 +801,11 @@ describe("UserStateSubject", () => {
}); });
it("waits for constraints$", async () => { 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 constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(3); const expected = fooMaxLength(3);
@@ -923,11 +819,12 @@ describe("UserStateSubject", () => {
}); });
it("emits the last-emitted value from constraints$ when constraints$ errors", async () => { it("emits the last-emitted value from constraints$ when constraints$ errors", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(3); const expected = fooMaxLength(3);
const constraints$ = new BehaviorSubject(expected); const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
constraints$.error({ some: "error" }); constraints$.error({ some: "error" });
@@ -939,11 +836,12 @@ describe("UserStateSubject", () => {
}); });
it("emits the last-emitted value from constraints$ when constraints$ completes", async () => { it("emits the last-emitted value from constraints$ when constraints$ completes", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(3); const expected = fooMaxLength(3);
const constraints$ = new BehaviorSubject(expected); const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const subject = new UserStateSubject(SomeKey, SomeProvider, {
account$: SomeAccount$,
constraints$,
});
const tracker = new ObservableTracker(subject.withConstraints$); const tracker = new ObservableTracker(subject.withConstraints$);
constraints$.complete(); constraints$.complete();

View File

@@ -24,14 +24,17 @@ import {
withLatestFrom, withLatestFrom,
scan, scan,
skip, skip,
shareReplay,
tap,
switchMap,
} from "rxjs"; } from "rxjs";
import { Account } from "../../auth/abstractions/account.service";
import { EncString } from "../../platform/models/domain/enc-string"; import { EncString } from "../../platform/models/domain/enc-string";
import { SingleUserState, UserKeyDefinition } from "../../platform/state"; import { SingleUserState, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid";
import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { UserEncryptor } from "../cryptography/user-encryptor.abstraction";
import { UserBound } from "../dependencies"; import { SemanticLogger } from "../log";
import { anyComplete, errorOnChange, ready, withLatestReady } from "../rx"; import { anyComplete, pin, ready, withLatestReady } from "../rx";
import { Constraints, SubjectConstraints, WithConstraints } from "../types"; import { Constraints, SubjectConstraints, WithConstraints } from "../types";
import { ClassifiedFormat, isClassifiedFormat } from "./classified-format"; import { ClassifiedFormat, isClassifiedFormat } from "./classified-format";
@@ -39,6 +42,7 @@ import { unconstrained$ } from "./identity-state-constraint";
import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key"; import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key";
import { isDynamic } from "./state-constraints-dependency"; import { isDynamic } from "./state-constraints-dependency";
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"; import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
import { UserStateSubjectDependencyProvider } from "./user-state-subject-dependency-provider";
type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State }; type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State };
@@ -59,6 +63,9 @@ type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: St
// update b/c their IVs change. // update b/c their IVs change.
const ALWAYS_UPDATE_KLUDGE = "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$"; const ALWAYS_UPDATE_KLUDGE = "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$";
/** Default frame size for data packing */
const DEFAULT_FRAME_SIZE = 32;
/** /**
* Adapt a state provider to an rxjs subject. * Adapt a state provider to an rxjs subject.
* *
@@ -90,12 +97,12 @@ export class UserStateSubject<
* this becomes true. When this occurs, only the last-received update * this becomes true. When this occurs, only the last-received update
* is applied. The blocked update is kept in memory. It does not persist * is applied. The blocked update is kept in memory. It does not persist
* to disk. * to disk.
* @param dependencies.singleUserId$ writes block until the singleUserId$ * @param dependencies.account$ writes block until the account$
* is available. * is available.
*/ */
constructor( constructor(
private key: UserKeyDefinition<State> | ObjectKey<State, Secret, Disclosed>, private key: UserKeyDefinition<State> | ObjectKey<State, Secret, Disclosed>,
getState: (key: UserKeyDefinition<unknown>) => SingleUserState<unknown>, private providers: UserStateSubjectDependencyProvider,
private context: UserStateSubjectDependencies<State, Dependencies>, private context: UserStateSubjectDependencies<State, Dependencies>,
) { ) {
super(); super();
@@ -104,42 +111,60 @@ export class UserStateSubject<
// classification and encryption only supported with `ObjectKey` // classification and encryption only supported with `ObjectKey`
this.objectKey = this.key; this.objectKey = this.key;
this.stateKey = toUserKeyDefinition(this.key); this.stateKey = toUserKeyDefinition(this.key);
this.state = getState(this.stateKey);
} else { } else {
// raw state access granted with `UserKeyDefinition` // raw state access granted with `UserKeyDefinition`
this.objectKey = null; this.objectKey = null;
this.stateKey = this.key as UserKeyDefinition<State>; this.stateKey = this.key as UserKeyDefinition<State>;
this.state = getState(this.stateKey);
} }
this.log = this.providers.log({
contextId: this.contextId,
type: "UserStateSubject",
storage: {
state: this.stateKey.stateDefinition.name,
key: this.stateKey.key,
},
});
// normalize dependencies // normalize dependencies
const when$ = (this.context.when$ ?? new BehaviorSubject(true)).pipe(distinctUntilChanged()); const when$ = (this.context.when$ ?? new BehaviorSubject(true)).pipe(distinctUntilChanged());
const account$ = context.account$.pipe(
pin({
name: () => `${this.contextId} { account$ }`,
distinct(prev, current) {
return prev.id === current.id;
},
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
const encryptor$ = this.encryptor(account$);
const constraints$ = (this.context.constraints$ ?? unconstrained$<State>()).pipe(
catchError((e: unknown) => {
this.log.error(e as object, "constraints$ dependency failed; using last-known constraints");
return EMPTY;
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
const dependencies$ = (
this.context.dependencies$ ?? new BehaviorSubject<Dependencies>(null)
).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
// manage dependencies through replay subjects since `UserStateSubject` // load state once the account becomes available
// reads them in multiple places const userState$ = account$.pipe(
const encryptor$ = new ReplaySubject<UserEncryptor>(1); tap((account) => this.log.debug({ accountId: account.id }, "loading user state")),
const { singleUserId$, singleUserEncryptor$ } = this.context; map((account) => this.providers.state.getUser(account.id, this.stateKey)),
this.encryptor(singleUserEncryptor$ ?? singleUserId$).subscribe(encryptor$); shareReplay({ refCount: true, bufferSize: 1 }),
);
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$);
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 // wire output before input so that output normalizes the current state
// before any `next` value is processed // before any `next` value is processed
this.outputSubscription = this.state.state$ this.outputSubscription = userState$
.pipe(this.declassify(encryptor$), this.adjust(combineLatestWith(constraints$))) .pipe(
switchMap((userState) => userState.state$),
this.declassify(encryptor$),
this.adjust(combineLatestWith(constraints$)),
takeUntil(anyComplete(account$)),
)
.subscribe(this.output); .subscribe(this.output);
const last$ = new ReplaySubject<State>(1); const last$ = new ReplaySubject<State>(1);
@@ -168,45 +193,42 @@ export class UserStateSubject<
// //
// FIXME: this should probably timeout when a lock occurs // FIXME: this should probably timeout when a lock occurs
this.inputSubscription = updates$ this.inputSubscription = updates$
.pipe(this.classify(encryptor$), takeUntil(anyComplete([when$, this.input, encryptor$]))) .pipe(
this.classify(encryptor$),
withLatestFrom(userState$),
takeUntil(anyComplete([when$, this.input, encryptor$])),
)
.subscribe({ .subscribe({
next: (state) => this.onNext(state), next: ([input, state]) => this.onNext(input, state),
error: (e: unknown) => this.onError(e), error: (e: unknown) => this.onError(e),
complete: () => this.onComplete(), complete: () => this.onComplete(),
}); });
} }
private stateKey: UserKeyDefinition<unknown>; private get contextId() {
private objectKey: ObjectKey<State, Secret, Disclosed>; return `UserStateSubject(${this.stateKey.stateDefinition.name}, ${this.stateKey.key})`;
}
private encryptor( private readonly log: SemanticLogger;
singleUserEncryptor$: Observable<UserBound<"encryptor", UserEncryptor> | UserId>,
): Observable<UserEncryptor> { private readonly stateKey: UserKeyDefinition<unknown>;
return singleUserEncryptor$.pipe( private readonly objectKey: ObjectKey<State, Secret, Disclosed>;
// normalize inputs
map((maybe): UserBound<"encryptor", UserEncryptor> => { private encryptor(account$: Observable<Account>): Observable<UserEncryptor> {
if (typeof maybe === "object" && "encryptor" in maybe) { const singleUserId$ = account$.pipe(map((account) => account.id));
return maybe; const frameSize = this.objectKey?.frame ?? DEFAULT_FRAME_SIZE;
} else if (typeof maybe === "string") { const encryptor$ = this.providers.encryptor.userEncryptor$(frameSize, { singleUserId$ }).pipe(
return { encryptor: null, userId: maybe as UserId }; tap(() => this.log.debug("encryptor constructed")),
} else {
throw new Error(`Invalid encryptor input received for ${this.key.key}.`);
}
}),
// fail the stream if the state desyncs from the bound userId
errorOnChange(
({ userId }) => userId,
(expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }),
),
// reduce emissions to when encryptor changes
map(({ encryptor }) => encryptor), map(({ encryptor }) => encryptor),
distinctUntilChanged(), shareReplay({ refCount: true, bufferSize: 1 }),
); );
return encryptor$;
} }
private when(when$: Observable<boolean>): OperatorFunction<State, State> { private when(when$: Observable<boolean>): OperatorFunction<State, State> {
return pipe( return pipe(
combineLatestWith(when$.pipe(distinctUntilChanged())), combineLatestWith(when$.pipe(distinctUntilChanged())),
tap(([_, when]) => this.log.debug({ when }, "when status")),
filter(([_, when]) => !!when), filter(([_, when]) => !!when),
map(([input]) => input), map(([input]) => input),
); );
@@ -237,6 +259,7 @@ export class UserStateSubject<
return [next, dependencies]; return [next, dependencies];
} else { } else {
// false update // false update
this.log.debug("shouldUpdate prevented write");
return [prev, null]; return [prev, null];
} }
}), }),
@@ -258,20 +281,22 @@ export class UserStateSubject<
// * `input` needs to wait until a message flows through the pipe // * `input` needs to wait until a message flows through the pipe
withConstraints, withConstraints,
map(([loadedState, constraints]) => { map(([loadedState, constraints]) => {
// bypass nulls
if (!loadedState && !this.objectKey?.initial) { if (!loadedState && !this.objectKey?.initial) {
this.log.debug("no value; bypassing adjustment");
return { return {
constraints: {} as Constraints<State>, constraints: {} as Constraints<State>,
state: null, state: null,
} satisfies Constrained<State>; } satisfies Constrained<State>;
} }
this.log.debug("adjusting");
const unconstrained = loadedState ?? structuredClone(this.objectKey.initial); const unconstrained = loadedState ?? structuredClone(this.objectKey.initial);
const calibration = isDynamic(constraints) const calibration = isDynamic(constraints)
? constraints.calibrate(unconstrained) ? constraints.calibrate(unconstrained)
: constraints; : constraints;
const adjusted = calibration.adjust(unconstrained); const adjusted = calibration.adjust(unconstrained);
this.log.debug("adjusted");
return { return {
constraints: calibration.constraints, constraints: calibration.constraints,
state: adjusted, state: adjusted,
@@ -286,11 +311,14 @@ export class UserStateSubject<
return pipe( return pipe(
combineLatestWith(constraints$), combineLatestWith(constraints$),
map(([loadedState, constraints]) => { map(([loadedState, constraints]) => {
this.log.debug("fixing");
const calibration = isDynamic(constraints) const calibration = isDynamic(constraints)
? constraints.calibrate(loadedState) ? constraints.calibrate(loadedState)
: constraints; : constraints;
const fixed = calibration.fix(loadedState); const fixed = calibration.fix(loadedState);
this.log.debug("fixed");
return { return {
constraints: calibration.constraints, constraints: calibration.constraints,
state: fixed, state: fixed,
@@ -302,6 +330,7 @@ export class UserStateSubject<
private declassify(encryptor$: Observable<UserEncryptor>): OperatorFunction<unknown, State> { private declassify(encryptor$: Observable<UserEncryptor>): OperatorFunction<unknown, State> {
// short-circuit if they key lacks encryption support // short-circuit if they key lacks encryption support
if (!this.objectKey || this.objectKey.format === "plain") { if (!this.objectKey || this.objectKey.format === "plain") {
this.log.debug("key uses plain format; bypassing declassification");
return (input$) => input$ as Observable<State>; return (input$) => input$ as Observable<State>;
} }
@@ -312,9 +341,12 @@ export class UserStateSubject<
concatMap(async ([input, encryptor]) => { concatMap(async ([input, encryptor]) => {
// pass through null values // pass through null values
if (input === null || input === undefined) { if (input === null || input === undefined) {
this.log.debug("no value; bypassing declassification");
return null; return null;
} }
this.log.debug("declassifying");
// decrypt classified data // decrypt classified data
const { secret, disclosed } = input; const { secret, disclosed } = input;
const encrypted = EncString.fromJSON(secret); const encrypted = EncString.fromJSON(secret);
@@ -324,6 +356,7 @@ export class UserStateSubject<
const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret); const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret);
const state = this.objectKey.options.deserializer(declassified); const state = this.objectKey.options.deserializer(declassified);
this.log.debug("declassified");
return state; return state;
}), }),
); );
@@ -338,6 +371,7 @@ export class UserStateSubject<
if (this.objectKey && this.objectKey.format === "classified") { if (this.objectKey && this.objectKey.format === "classified") {
return map((input) => { return map((input) => {
if (!isClassifiedFormat(input)) { if (!isClassifiedFormat(input)) {
this.log.warn("classified data must be in classified format; dropping");
return null; return null;
} }
@@ -349,11 +383,13 @@ export class UserStateSubject<
if (this.objectKey && this.objectKey.format === "secret-state") { if (this.objectKey && this.objectKey.format === "secret-state") {
return map((input) => { return map((input) => {
if (!Array.isArray(input)) { if (!Array.isArray(input)) {
this.log.warn("secret-state requires array formatting; dropping");
return null; return null;
} }
const [unwrapped] = input; const [unwrapped] = input;
if (!isClassifiedFormat(unwrapped)) { if (!isClassifiedFormat(unwrapped)) {
this.log.warn("unwrapped secret-state must be in classified format; dropping");
return null; return null;
} }
@@ -361,13 +397,14 @@ export class UserStateSubject<
}); });
} }
throw new Error(`unsupported serialization format: ${this.objectKey.format}`); this.log.panic({ format: this.objectKey.format }, "unsupported serialization format");
} }
private classify(encryptor$: Observable<UserEncryptor>): OperatorFunction<State, unknown> { private classify(encryptor$: Observable<UserEncryptor>): OperatorFunction<State, unknown> {
// short-circuit if they key lacks encryption support; `encryptor` is // short-circuit if they key lacks encryption support; `encryptor` is
// readied to preserve `dependencies.singleUserId$` emission contract // readied to preserve `dependencies.singleUserId$` emission contract
if (!this.objectKey || this.objectKey.format === "plain") { if (!this.objectKey || this.objectKey.format === "plain") {
this.log.debug("key uses plain format; bypassing classification");
return pipe( return pipe(
ready(encryptor$), ready(encryptor$),
map((input) => input as unknown), map((input) => input as unknown),
@@ -380,9 +417,12 @@ export class UserStateSubject<
concatMap(async ([input, encryptor]) => { concatMap(async ([input, encryptor]) => {
// fail fast if there's no value // fail fast if there's no value
if (input === null || input === undefined) { if (input === null || input === undefined) {
this.log.debug("no value; bypassing classification");
return null; return null;
} }
this.log.debug("classifying");
// split data by classification level // split data by classification level
const serialized = JSON.parse(JSON.stringify(input)); const serialized = JSON.parse(JSON.stringify(input));
const classified = this.objectKey.classifier.classify(serialized); const classified = this.objectKey.classifier.classify(serialized);
@@ -398,6 +438,7 @@ export class UserStateSubject<
disclosed: classified.disclosed, disclosed: classified.disclosed,
} satisfies ClassifiedFormat<void, Disclosed>; } satisfies ClassifiedFormat<void, Disclosed>;
this.log.debug("classified");
// deliberate type erasure; the type is restored during `declassify` // deliberate type erasure; the type is restored during `declassify`
return envelope as ClassifiedFormat<unknown, unknown>; return envelope as ClassifiedFormat<unknown, unknown>;
}), }),
@@ -416,13 +457,7 @@ export class UserStateSubject<
return map((input) => [input] as unknown); return map((input) => [input] as unknown);
} }
throw new Error(`unsupported serialization format: ${this.objectKey.format}`); this.log.panic({ format: this.objectKey.format }, "unsupported serialization format");
}
/** The userId to which the subject is bound.
*/
get userId() {
return this.state.userId;
} }
next(value: State) { next(value: State) {
@@ -449,7 +484,6 @@ export class UserStateSubject<
// if greater efficiency becomes desirable, consider implementing // if greater efficiency becomes desirable, consider implementing
// `SubjectLike` directly // `SubjectLike` directly
private input = new ReplaySubject<State>(1); private input = new ReplaySubject<State>(1);
private state: SingleUserState<unknown>;
private readonly output = new ReplaySubject<WithConstraints<State>>(1); private readonly output = new ReplaySubject<WithConstraints<State>>(1);
/** A stream containing settings and their last-applied constraints. */ /** A stream containing settings and their last-applied constraints. */
@@ -462,9 +496,11 @@ export class UserStateSubject<
private counter = 0; private counter = 0;
private onNext(value: unknown) { private onNext(value: unknown, state: SingleUserState<unknown>) {
this.state state
.update(() => { .update(() => {
this.log.debug("updating");
if (typeof value === "object") { if (typeof value === "object") {
// related: ALWAYS_UPDATE_KLUDGE FIXME // related: ALWAYS_UPDATE_KLUDGE FIXME
const counter = this.counter++; const counter = this.counter++;
@@ -472,13 +508,17 @@ export class UserStateSubject<
this.counter = 0; this.counter = 0;
} }
const kludge = value as any; const kludge = { ...value } as any;
kludge[ALWAYS_UPDATE_KLUDGE] = counter; kludge[ALWAYS_UPDATE_KLUDGE] = counter;
} }
this.log.debug("updated");
return value; return value;
}) })
.catch((e: any) => this.onError(e)); .catch((e: any) => {
this.log.error(e as object, "updating failed");
this.onError(e);
});
} }
private onError(value: any) { private onError(value: any) {
@@ -503,6 +543,8 @@ export class UserStateSubject<
private dispose() { private dispose() {
if (!this.isDisposed) { if (!this.isDisposed) {
this.log.debug("disposing");
// clean up internal subscriptions // clean up internal subscriptions
this.inputSubscription?.unsubscribe(); this.inputSubscription?.unsubscribe();
this.outputSubscription?.unsubscribe(); this.outputSubscription?.unsubscribe();
@@ -511,6 +553,8 @@ export class UserStateSubject<
// drop input to ensure its value is removed from memory // drop input to ensure its value is removed from memory
this.input = null; this.input = null;
this.log.debug("disposed");
} }
} }
} }

View File

@@ -43,6 +43,7 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { pin } from "@bitwarden/common/tools/rx";
import { import {
AsyncActionsModule, AsyncActionsModule,
BitSubmitDirective, BitSubmitDirective,
@@ -220,8 +221,18 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
// Wire up the password generation for the password-protected export // Wire up the password generation for the password-protected export
const account$ = this.accountService.activeAccount$.pipe(
pin({
name() {
return "active export account";
},
distinct(previous, current) {
return previous.id === current.id;
},
}),
);
this.generatorService this.generatorService
.generate$(Generators.password, { on$: this.onGenerate$ }) .generate$(Generators.password, { on$: this.onGenerate$, account$ })
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((generated) => { .subscribe((generated) => {
this.exportForm.patchValue({ this.exportForm.patchValue({

View File

@@ -1,25 +1,31 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import {
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, map, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { import {
CatchallGenerationOptions, CatchallGenerationOptions,
CredentialGeneratorService, CredentialGeneratorService,
Generators, Generators,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { completeOnAccountSwitch } from "./util";
/** Options group for catchall emails */ /** Options group for catchall emails */
@Component({ @Component({
selector: "tools-catchall-settings", selector: "tools-catchall-settings",
templateUrl: "catchall-settings.component.html", templateUrl: "catchall-settings.component.html",
}) })
export class CatchallSettingsComponent implements OnInit, OnDestroy { export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges {
/** Instantiates the component /** Instantiates the component
* @param accountService queries user availability * @param accountService queries user availability
* @param generatorService settings and policy logic * @param generatorService settings and policy logic
@@ -28,15 +34,14 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy {
constructor( constructor(
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private accountService: AccountService,
) {} ) {}
/** Binds the component to a specific user's settings. /** Binds the component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/ */
@Input() @Input({ required: true })
userId: UserId | null; account: Account;
private account$ = new ReplaySubject<Account>(1);
/** Emits settings updates and completes if the settings become unavailable. /** Emits settings updates and completes if the settings become unavailable.
* @remarks this does not emit the initial settings. If you would like * @remarks this does not emit the initial settings. If you would like
@@ -51,9 +56,16 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy {
catchallDomain: [Generators.catchall.settings.initial.catchallDomain], catchallDomain: [Generators.catchall.settings.initial.catchallDomain],
}); });
async ngOnChanges(changes: SimpleChanges) {
if ("account" in changes && changes.account) {
this.account$.next(this.account);
}
}
async ngOnInit() { async ngOnInit() {
const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.catchall, {
const settings = await this.generatorService.settings(Generators.catchall, { singleUserId$ }); account$: this.account$,
});
settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => {
this.settings.patchValue(s, { emitEvent: false }); this.settings.patchValue(s, { emitEvent: false });
@@ -77,21 +89,9 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy {
this.saveSettings.next(site); this.saveSettings.next(site);
} }
private singleUserId$() {
// FIXME: this branch should probably scan for the user and make sure
// the account is unlocked
if (this.userId) {
return new BehaviorSubject(this.userId as UserId).asObservable();
}
return this.accountService.activeAccount$.pipe(
completeOnAccountSwitch(),
takeUntil(this.destroyed$),
);
}
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.account$.complete();
this.destroyed$.next(); this.destroyed$.next();
this.destroyed$.complete(); this.destroyed$.complete();
} }

View File

@@ -2,7 +2,7 @@
<span bitDialogTitle>{{ "generatorHistory" | i18n }}</span> <span bitDialogTitle>{{ "generatorHistory" | i18n }}</span>
<ng-container bitDialogContent> <ng-container bitDialogContent>
<bit-empty-credential-history *ngIf="!(hasHistory$ | async)" style="display: contents" /> <bit-empty-credential-history *ngIf="!(hasHistory$ | async)" style="display: contents" />
<bit-credential-generator-history *ngIf="hasHistory$ | async" /> <bit-credential-generator-history [account]="account$ | async" *ngIf="hasHistory$ | async" />
</ng-container> </ng-container>
<ng-container bitDialogFooter> <ng-container bitDialogFooter>
<button <button

View File

@@ -1,12 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component } from "@angular/core"; import { Component, Input, OnChanges, SimpleChanges, OnInit, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import {
import { BehaviorSubject, distinctUntilChanged, firstValueFrom, map, switchMap } from "rxjs"; BehaviorSubject,
ReplaySubject,
Subject,
firstValueFrom,
map,
switchMap,
takeUntil,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
SemanticLogger,
disabledSemanticLoggerProvider,
ifEnabledSemanticLoggerProvider,
} from "@bitwarden/common/tools/log";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
import { GeneratorHistoryService } from "@bitwarden/generator-history"; import { GeneratorHistoryService } from "@bitwarden/generator-history";
@@ -26,28 +39,66 @@ import { EmptyCredentialHistoryComponent } from "./empty-credential-history.comp
EmptyCredentialHistoryComponent, EmptyCredentialHistoryComponent,
], ],
}) })
export class CredentialGeneratorHistoryDialogComponent { export class CredentialGeneratorHistoryDialogComponent implements OnChanges, OnInit, OnDestroy {
private readonly destroyed = new Subject<void>();
protected readonly hasHistory$ = new BehaviorSubject<boolean>(false); protected readonly hasHistory$ = new BehaviorSubject<boolean>(false);
protected readonly userId$ = new BehaviorSubject<UserId>(null);
constructor( constructor(
private accountService: AccountService, private accountService: AccountService,
private history: GeneratorHistoryService, private history: GeneratorHistoryService,
private dialogService: DialogService, private dialogService: DialogService,
) { private logService: LogService,
this.accountService.activeAccount$ ) {}
.pipe(
takeUntilDestroyed(),
map(({ id }) => id),
distinctUntilChanged(),
)
.subscribe(this.userId$);
this.userId$ @Input()
account: Account | null;
protected account$ = new ReplaySubject<Account>(1);
/** Send structured debug logs from the credential generator component
* to the debugger console.
*
* @warning this may reveal sensitive information in plaintext.
*/
@Input()
debug: boolean = false;
// this `log` initializer is overridden in `ngOnInit`
private log: SemanticLogger = disabledSemanticLoggerProvider({});
ngOnChanges(changes: SimpleChanges) {
const account = changes?.account;
if (account?.previousValue?.id !== account?.currentValue?.id) {
this.log.debug(
{
previousUserId: account?.previousValue?.id as UserId,
currentUserId: account?.currentValue?.id as UserId,
},
"account input change detected",
);
this.account$.next(account.currentValue ?? this.account);
}
}
async ngOnInit() {
this.log = ifEnabledSemanticLoggerProvider(this.debug, this.logService, {
type: "CredentialGeneratorComponent",
});
if (!this.account) {
this.account = await firstValueFrom(this.accountService.activeAccount$);
this.log.info(
{ userId: this.account.id },
"account not specified; using active account settings",
);
this.account$.next(this.account);
}
this.account$
.pipe( .pipe(
takeUntilDestroyed(), switchMap((account) => account.id && this.history.credentials$(account.id)),
switchMap((id) => id && this.history.credentials$(id)),
map((credentials) => credentials.length > 0), map((credentials) => credentials.length > 0),
takeUntil(this.destroyed),
) )
.subscribe(this.hasHistory$); .subscribe(this.hasHistory$);
} }
@@ -63,7 +114,14 @@ export class CredentialGeneratorHistoryDialogComponent {
}); });
if (confirmed) { if (confirmed) {
await this.history.clear(await firstValueFrom(this.userId$)); await this.history.clear((await firstValueFrom(this.account$)).id);
} }
} }
ngOnDestroy() {
this.destroyed.next();
this.destroyed.complete();
this.log.debug("component destroyed");
}
} }

View File

@@ -1,21 +1,23 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component } from "@angular/core"; import { Component, Input, OnChanges, SimpleChanges, OnInit, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { BehaviorSubject, ReplaySubject, Subject, map, switchMap, takeUntil, tap } from "rxjs";
import { RouterLink } from "@angular/router";
import { BehaviorSubject, distinctUntilChanged, map, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
SemanticLogger,
disabledSemanticLoggerProvider,
ifEnabledSemanticLoggerProvider,
} from "@bitwarden/common/tools/log";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { import {
ColorPasswordModule, ColorPasswordModule,
IconButtonModule, IconButtonModule,
ItemModule, ItemModule,
NoItemsModule, NoItemsModule,
SectionComponent,
SectionHeaderComponent,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { CredentialGeneratorService } from "@bitwarden/generator-core"; import { CredentialGeneratorService } from "@bitwarden/generator-core";
import { GeneratedCredential, GeneratorHistoryService } from "@bitwarden/generator-history"; import { GeneratedCredential, GeneratorHistoryService } from "@bitwarden/generator-history";
@@ -32,35 +34,61 @@ import { GeneratorModule } from "./generator.module";
IconButtonModule, IconButtonModule,
NoItemsModule, NoItemsModule,
JslibModule, JslibModule,
RouterLink,
ItemModule, ItemModule,
SectionComponent,
SectionHeaderComponent,
GeneratorModule, GeneratorModule,
], ],
}) })
export class CredentialGeneratorHistoryComponent { export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, OnDestroy {
protected readonly userId$ = new BehaviorSubject<UserId>(null); private readonly destroyed = new Subject<void>();
protected readonly credentials$ = new BehaviorSubject<GeneratedCredential[]>([]); protected readonly credentials$ = new BehaviorSubject<GeneratedCredential[]>([]);
constructor( constructor(
private accountService: AccountService,
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private history: GeneratorHistoryService, private history: GeneratorHistoryService,
) { private logService: LogService,
this.accountService.activeAccount$ ) {}
.pipe(
takeUntilDestroyed(),
map(({ id }) => id),
distinctUntilChanged(),
)
.subscribe(this.userId$);
this.userId$ @Input({ required: true })
account: Account;
protected account$ = new ReplaySubject<Account>(1);
/** Send structured debug logs from the credential generator component
* to the debugger console.
*
* @warning this may reveal sensitive information in plaintext.
*/
@Input()
debug: boolean = false;
// this `log` initializer is overridden in `ngOnInit`
private log: SemanticLogger = disabledSemanticLoggerProvider({});
async ngOnChanges(changes: SimpleChanges) {
const account = changes?.account;
if (account?.previousValue?.id !== account?.currentValue?.id) {
this.log.debug(
{
previousUserId: account?.previousValue?.id as UserId,
currentUserId: account?.currentValue?.id as UserId,
},
"account input change detected",
);
this.account$.next(account.currentValue ?? this.account);
}
}
ngOnInit() {
this.log = ifEnabledSemanticLoggerProvider(this.debug, this.logService, {
type: "CredentialGeneratorComponent",
});
this.account$
.pipe( .pipe(
takeUntilDestroyed(), tap((account) => this.log.info({ accountId: account.id }, "loading credential history")),
switchMap((id) => id && this.history.credentials$(id)), switchMap((account) => this.history.credentials$(account.id)),
map((credentials) => credentials.filter((c) => (c.credential ?? "") !== "")), map((credentials) => credentials.filter((c) => (c.credential ?? "") !== "")),
takeUntil(this.destroyed),
) )
.subscribe(this.credentials$); .subscribe(this.credentials$);
} }
@@ -74,4 +102,11 @@ export class CredentialGeneratorHistoryComponent {
const info = this.generatorService.algorithm(credential.category); const info = this.generatorService.algorithm(credential.category);
return info.credentialType; return info.credentialType;
} }
ngOnDestroy() {
this.destroyed.next();
this.destroyed.complete();
this.log.debug("component destroyed");
}
} }

View File

@@ -41,13 +41,13 @@
<tools-password-settings <tools-password-settings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(showAlgorithm$ | async)?.id === 'password'" *ngIf="(showAlgorithm$ | async)?.id === 'password'"
[userId]="userId$ | async" [account]="account$ | async"
(onUpdated)="generate('password settings')" (onUpdated)="generate('password settings')"
/> />
<tools-passphrase-settings <tools-passphrase-settings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(showAlgorithm$ | async)?.id === 'passphrase'" *ngIf="(showAlgorithm$ | async)?.id === 'passphrase'"
[userId]="userId$ | async" [account]="account$ | async"
(onUpdated)="generate('passphrase settings')" (onUpdated)="generate('passphrase settings')"
/> />
<bit-section *ngIf="(category$ | async) !== 'password'"> <bit-section *ngIf="(category$ | async) !== 'password'">
@@ -83,22 +83,22 @@
</form> </form>
<tools-catchall-settings <tools-catchall-settings
*ngIf="(showAlgorithm$ | async)?.id === 'catchall'" *ngIf="(showAlgorithm$ | async)?.id === 'catchall'"
[userId]="userId$ | async" [account]="account$ | async"
(onUpdated)="generate('catchall settings')" (onUpdated)="generate('catchall settings')"
/> />
<tools-forwarder-settings <tools-forwarder-settings
*ngIf="!!(forwarderId$ | async)" *ngIf="!!(forwarderId$ | async)"
[account]="account$ | async"
[forwarder]="forwarderId$ | async" [forwarder]="forwarderId$ | async"
[userId]="this.userId$ | async"
/> />
<tools-subaddress-settings <tools-subaddress-settings
*ngIf="(showAlgorithm$ | async)?.id === 'subaddress'" *ngIf="(showAlgorithm$ | async)?.id === 'subaddress'"
[userId]="userId$ | async" [account]="account$ | async"
(onUpdated)="generate('subaddress settings')" (onUpdated)="generate('subaddress settings')"
/> />
<tools-username-settings <tools-username-settings
*ngIf="(showAlgorithm$ | async)?.id === 'username'" *ngIf="(showAlgorithm$ | async)?.id === 'username'"
[userId]="userId$ | async" [account]="account$ | async"
(onUpdated)="generate('username settings')" (onUpdated)="generate('username settings')"
/> />
</bit-card> </bit-card>

View File

@@ -1,7 +1,17 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { LiveAnnouncer } from "@angular/cdk/a11y"; import { LiveAnnouncer } from "@angular/cdk/a11y";
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import {
Component,
EventEmitter,
Input,
NgZone,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { import {
BehaviorSubject, BehaviorSubject,
@@ -10,6 +20,7 @@ import {
combineLatestWith, combineLatestWith,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
firstValueFrom,
map, map,
ReplaySubject, ReplaySubject,
Subject, Subject,
@@ -18,10 +29,15 @@ import {
withLatestFrom, withLatestFrom,
} from "rxjs"; } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { IntegrationId } from "@bitwarden/common/tools/integration"; import { IntegrationId } from "@bitwarden/common/tools/integration";
import {
SemanticLogger,
disabledSemanticLoggerProvider,
ifEnabledSemanticLoggerProvider,
} from "@bitwarden/common/tools/log";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { ToastService, Option } from "@bitwarden/components"; import { ToastService, Option } from "@bitwarden/components";
import { import {
@@ -52,7 +68,9 @@ const NONE_SELECTED = "none";
selector: "tools-credential-generator", selector: "tools-credential-generator",
templateUrl: "credential-generator.component.html", templateUrl: "credential-generator.component.html",
}) })
export class CredentialGeneratorComponent implements OnInit, OnDestroy { export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestroy {
private readonly destroyed = new Subject<void>();
constructor( constructor(
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private generatorHistoryService: GeneratorHistoryService, private generatorHistoryService: GeneratorHistoryService,
@@ -69,7 +87,34 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
* the form binds to the active user * the form binds to the active user
*/ */
@Input() @Input()
userId: UserId | null; account: Account | null;
/** Send structured debug logs from the credential generator component
* to the debugger console.
*
* @warning this may reveal sensitive information in plaintext.
*/
@Input()
debug: boolean = false;
// this `log` initializer is overridden in `ngOnInit`
private log: SemanticLogger = disabledSemanticLoggerProvider({});
protected account$ = new ReplaySubject<Account>(1);
async ngOnChanges(changes: SimpleChanges) {
const account = changes?.account;
if (account?.previousValue?.id !== account?.currentValue?.id) {
this.log.debug(
{
previousUserId: account?.previousValue?.id as UserId,
currentUserId: account?.currentValue?.id as UserId,
},
"account input change detected",
);
this.account$.next(account.currentValue ?? this.account);
}
}
/** /**
* The website associated with the credential generation request. * The website associated with the credential generation request.
@@ -103,20 +148,21 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
}); });
async ngOnInit() { async ngOnInit() {
if (this.userId) { this.log = ifEnabledSemanticLoggerProvider(this.debug, this.logService, {
this.userId$.next(this.userId); type: "CredentialGeneratorComponent",
} else { });
this.accountService.activeAccount$
.pipe( if (!this.account) {
map((acct) => acct.id), this.account = await firstValueFrom(this.accountService.activeAccount$);
distinctUntilChanged(), this.log.info(
takeUntil(this.destroyed), { userId: this.account.id },
) "account not specified; using active account settings",
.subscribe(this.userId$); );
this.account$.next(this.account);
} }
this.generatorService this.generatorService
.algorithms$(["email", "username"], { userId$: this.userId$ }) .algorithms$(["email", "username"], { account$: this.account$ })
.pipe( .pipe(
map((algorithms) => { map((algorithms) => {
const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id));
@@ -137,7 +183,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
}); });
this.generatorService this.generatorService
.algorithms$("password", { userId$: this.userId$ }) .algorithms$("password", { account$: this.account$ })
.pipe( .pipe(
map((algorithms) => { map((algorithms) => {
const options = this.toOptions(algorithms); const options = this.toOptions(algorithms);
@@ -194,12 +240,14 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
// continue with origin stream // continue with origin stream
return generator; return generator;
}), }),
withLatestFrom(this.userId$, this.algorithm$), withLatestFrom(this.account$, this.algorithm$),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([generated, userId, algorithm]) => { .subscribe(([generated, account, algorithm]) => {
this.log.debug({ source: generated.source }, "credential generated");
this.generatorHistoryService this.generatorHistoryService
.track(userId, generated.credential, generated.category, generated.generationDate) .track(account.id, generated.credential, generated.category, generated.generationDate)
.catch((e: unknown) => { .catch((e: unknown) => {
this.logService.error(e); this.logService.error(e);
}); });
@@ -274,6 +322,8 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([showForwarder, forwarderId]) => { .subscribe(([showForwarder, forwarderId]) => {
this.log.debug({ forwarderId, showForwarder }, "forwarder visibility updated");
// update subjects within the angular zone so that the // update subjects within the angular zone so that the
// template bindings refresh immediately // template bindings refresh immediately
this.zone.run(() => { this.zone.run(() => {
@@ -297,6 +347,8 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe((algorithm) => { .subscribe((algorithm) => {
this.log.debug(algorithm, "algorithm selected");
// update subjects within the angular zone so that the // update subjects within the angular zone so that the
// template bindings refresh immediately // template bindings refresh immediately
this.zone.run(() => { this.zone.run(() => {
@@ -305,7 +357,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
}); });
// assume the last-selected generator algorithm is the user's preferred one // assume the last-selected generator algorithm is the user's preferred one
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); const preferences = await this.generatorService.preferences({ account$: this.account$ });
this.algorithm$ this.algorithm$
.pipe( .pipe(
filter((algorithm) => !!algorithm), filter((algorithm) => !!algorithm),
@@ -313,19 +365,21 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([algorithm, preference]) => { .subscribe(([algorithm, preference]) => {
function setPreference(category: CredentialCategory) { function setPreference(category: CredentialCategory, log: SemanticLogger) {
const p = preference[category]; const p = preference[category];
p.algorithm = algorithm.id; p.algorithm = algorithm.id;
p.updated = new Date(); p.updated = new Date();
log.info({ algorithm, category }, "algorithm preferences updated");
} }
// `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference` // `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference`
if (isEmailAlgorithm(algorithm.id)) { if (isEmailAlgorithm(algorithm.id)) {
setPreference("email"); setPreference("email", this.log);
} else if (isUsernameAlgorithm(algorithm.id)) { } else if (isUsernameAlgorithm(algorithm.id)) {
setPreference("username"); setPreference("username", this.log);
} else if (isPasswordAlgorithm(algorithm.id)) { } else if (isPasswordAlgorithm(algorithm.id)) {
setPreference("password"); setPreference("password", this.log);
} else { } else {
return; return;
} }
@@ -396,25 +450,33 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => {
this.zone.run(() => { this.zone.run(() => {
if (!a || a.onlyOnRequest) { if (!a || a.onlyOnRequest) {
this.log.debug("autogeneration disabled; clearing generated credential");
this.generatedCredential$.next(null); this.generatedCredential$.next(null);
} else { } else {
this.generate("autogenerate").catch((e: unknown) => this.logService.error(e)); this.log.debug("autogeneration enabled");
this.generate("autogenerate").catch((e: unknown) => {
this.log.error(e as object, "a failure occurred during autogeneration");
});
} }
}); });
}); });
this.log.debug("component initialized");
} }
private announce(message: string) { private announce(message: string) {
this.ariaLive.announce(message).catch((e) => this.logService.error(e)); this.ariaLive.announce(message).catch((e) => this.logService.error(e));
} }
private typeToGenerator$(type: CredentialAlgorithm) { private typeToGenerator$(algorithm: CredentialAlgorithm) {
const dependencies = { const dependencies = {
on$: this.generate$, on$: this.generate$,
userId$: this.userId$, account$: this.account$,
}; };
switch (type) { this.log.debug({ algorithm }, "constructing generation stream");
switch (algorithm) {
case "catchall": case "catchall":
return this.generatorService.generate$(Generators.catchall, dependencies); return this.generatorService.generate$(Generators.catchall, dependencies);
@@ -431,14 +493,14 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
return this.generatorService.generate$(Generators.passphrase, dependencies); return this.generatorService.generate$(Generators.passphrase, dependencies);
} }
if (isForwarderIntegration(type)) { if (isForwarderIntegration(algorithm)) {
const forwarder = getForwarderConfiguration(type.forwarder); const forwarder = getForwarderConfiguration(algorithm.forwarder);
const configuration = toCredentialGeneratorConfiguration(forwarder); const configuration = toCredentialGeneratorConfiguration(forwarder);
const generator = this.generatorService.generate$(configuration, dependencies); const generator = this.generatorService.generate$(configuration, dependencies);
return generator; return generator;
} }
throw new Error(`Invalid generator type: "${type}"`); this.log.panic({ algorithm }, `Invalid generator type: "${algorithm}"`);
} }
/** Lists the top-level credential types supported by the component. /** Lists the top-level credential types supported by the component.
@@ -506,9 +568,6 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
map((generated) => generated?.credential ?? "-"), map((generated) => generated?.credential ?? "-"),
); );
/** Emits when the userId changes */
protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Identifies generator requests that were requested by the user */ /** Identifies generator requests that were requested by the user */
protected readonly USER_REQUEST = "user request"; protected readonly USER_REQUEST = "user request";
@@ -516,11 +575,13 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
private readonly generate$ = new Subject<GenerateRequest>(); private readonly generate$ = new Subject<GenerateRequest>();
/** Request a new value from the generator /** Request a new value from the generator
* @param requestor a label used to trace generation request * @param source a label used to trace generation request
* origin in the debugger. * origin in the debugger.
*/ */
protected async generate(requestor: string) { protected async generate(source: string) {
this.generate$.next({ source: requestor, website: this.website }); const request = { source, website: this.website };
this.log.debug(request, "generation requested");
this.generate$.next(request);
} }
private toOptions(algorithms: AlgorithmInfo[]) { private toOptions(algorithms: AlgorithmInfo[]) {
@@ -532,7 +593,6 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
return options; return options;
} }
private readonly destroyed = new Subject<void>();
ngOnDestroy() { ngOnDestroy() {
this.destroyed.next(); this.destroyed.next();
this.destroyed.complete(); this.destroyed.complete();
@@ -543,5 +603,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
// finalize component bindings // finalize component bindings
this.onGenerated.complete(); this.onGenerated.complete();
this.log.debug("component destroyed");
} }
} }

View File

@@ -11,21 +11,10 @@ import {
SimpleChanges, SimpleChanges,
} from "@angular/core"; } from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { import { map, ReplaySubject, skip, Subject, switchAll, takeUntil, withLatestFrom } from "rxjs";
BehaviorSubject,
concatMap,
map,
ReplaySubject,
skip,
Subject,
switchAll,
takeUntil,
withLatestFrom,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { IntegrationId } from "@bitwarden/common/tools/integration"; import { IntegrationId } from "@bitwarden/common/tools/integration";
import { UserId } from "@bitwarden/common/types/guid";
import { import {
CredentialGeneratorConfiguration, CredentialGeneratorConfiguration,
CredentialGeneratorService, CredentialGeneratorService,
@@ -34,8 +23,6 @@ import {
toCredentialGeneratorConfiguration, toCredentialGeneratorConfiguration,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { completeOnAccountSwitch } from "./util";
const Controls = Object.freeze({ const Controls = Object.freeze({
domain: "domain", domain: "domain",
token: "token", token: "token",
@@ -56,15 +43,14 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
constructor( constructor(
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private accountService: AccountService,
) {} ) {}
/** Binds the component to a specific user's settings. /** Binds the component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/ */
@Input() @Input({ required: true })
userId: UserId | null; account: Account;
protected account$ = new ReplaySubject<Account>(1);
@Input({ required: true }) @Input({ required: true })
forwarder: IntegrationId; forwarder: IntegrationId;
@@ -87,8 +73,6 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
private forwarderId$ = new ReplaySubject<IntegrationId>(1); private forwarderId$ = new ReplaySubject<IntegrationId>(1);
async ngOnInit() { async ngOnInit() {
const singleUserId$ = this.singleUserId$();
const forwarder$ = new ReplaySubject<CredentialGeneratorConfiguration<any, NoPolicy>>(1); const forwarder$ = new ReplaySubject<CredentialGeneratorConfiguration<any, NoPolicy>>(1);
this.forwarderId$ this.forwarderId$
.pipe( .pipe(
@@ -108,12 +92,12 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
forwarder$.next(forwarder); forwarder$.next(forwarder);
}); });
const settings$$ = forwarder$.pipe( const settings$ = forwarder$.pipe(
concatMap((forwarder) => this.generatorService.settings(forwarder, { singleUserId$ })), map((forwarder) => this.generatorService.settings(forwarder, { account$: this.account$ })),
); );
// bind settings to the reactive form // bind settings to the reactive form
settings$$.pipe(switchAll(), takeUntil(this.destroyed$)).subscribe((settings) => { settings$.pipe(switchAll(), takeUntil(this.destroyed$)).subscribe((settings) => {
// skips reactive event emissions to break a subscription cycle // skips reactive event emissions to break a subscription cycle
this.settings.patchValue(settings as any, { emitEvent: false }); this.settings.patchValue(settings as any, { emitEvent: false });
}); });
@@ -131,7 +115,7 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
}); });
// the first emission is the current value; subsequent emissions are updates // the first emission is the current value; subsequent emissions are updates
settings$$ settings$
.pipe( .pipe(
map((settings$) => settings$.pipe(skip(1))), map((settings$) => settings$.pipe(skip(1))),
switchAll(), switchAll(),
@@ -141,7 +125,7 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
// now that outputs are set up, connect inputs // now that outputs are set up, connect inputs
this.saveSettings this.saveSettings
.pipe(withLatestFrom(this.settings.valueChanges, settings$$), takeUntil(this.destroyed$)) .pipe(withLatestFrom(this.settings.valueChanges, settings$), takeUntil(this.destroyed$))
.subscribe(([, value, settings]) => { .subscribe(([, value, settings]) => {
settings.next(value); settings.next(value);
}); });
@@ -152,30 +136,21 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
this.saveSettings.next(site); this.saveSettings.next(site);
} }
ngOnChanges(changes: SimpleChanges): void { async ngOnChanges(changes: SimpleChanges) {
this.refresh$.complete(); this.refresh$.complete();
if ("forwarder" in changes) { if ("forwarder" in changes) {
this.forwarderId$.next(this.forwarder); this.forwarderId$.next(this.forwarder);
} }
if ("account" in changes) {
this.account$.next(this.account);
}
} }
protected displayDomain: boolean; protected displayDomain: boolean;
protected displayToken: boolean; protected displayToken: boolean;
protected displayBaseUrl: boolean; protected displayBaseUrl: boolean;
private singleUserId$() {
// FIXME: this branch should probably scan for the user and make sure
// the account is unlocked
if (this.userId) {
return new BehaviorSubject(this.userId as UserId).asObservable();
}
return this.accountService.activeAccount$.pipe(
completeOnAccountSwitch(),
takeUntil(this.destroyed$),
);
}
private readonly refresh$ = new Subject<void>(); private readonly refresh$ = new Subject<void>();
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();

View File

@@ -5,12 +5,13 @@ import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state"; import { StateProvider } from "@bitwarden/common/platform/state";
import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider";
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log";
import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
import { import {
createRandomizer, createRandomizer,
CredentialGeneratorService, CredentialGeneratorService,
@@ -34,17 +35,25 @@ export const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
useClass: KeyServiceLegacyEncryptorProvider, useClass: KeyServiceLegacyEncryptorProvider,
deps: [EncryptService, KeyService], deps: [EncryptService, KeyService],
}), }),
safeProvider({
provide: UserStateSubjectDependencyProvider,
useFactory: (encryptor: LegacyEncryptorProvider, state: StateProvider) =>
Object.freeze({
encryptor,
state,
log: disabledSemanticLoggerProvider,
}),
deps: [LegacyEncryptorProvider, StateProvider],
}),
safeProvider({ safeProvider({
provide: CredentialGeneratorService, provide: CredentialGeneratorService,
useClass: CredentialGeneratorService, useClass: CredentialGeneratorService,
deps: [ deps: [
RANDOMIZER, RANDOMIZER,
StateProvider,
PolicyService, PolicyService,
ApiService, ApiService,
I18nService, I18nService,
LegacyEncryptorProvider, UserStateSubjectDependencyProvider,
AccountService,
], ],
}), }),
], ],

View File

@@ -1,29 +1,27 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { import {
BehaviorSubject, OnInit,
skip, Input,
takeUntil, Output,
Subject, EventEmitter,
map, Component,
withLatestFrom, OnDestroy,
ReplaySubject, SimpleChanges,
} from "rxjs"; OnChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { skip, takeUntil, Subject, map, withLatestFrom, ReplaySubject } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { import {
Generators, Generators,
CredentialGeneratorService, CredentialGeneratorService,
PassphraseGenerationOptions, PassphraseGenerationOptions,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { completeOnAccountSwitch } from "./util";
const Controls = Object.freeze({ const Controls = Object.freeze({
numWords: "numWords", numWords: "numWords",
includeNumber: "includeNumber", includeNumber: "includeNumber",
@@ -36,9 +34,8 @@ const Controls = Object.freeze({
selector: "tools-passphrase-settings", selector: "tools-passphrase-settings",
templateUrl: "passphrase-settings.component.html", templateUrl: "passphrase-settings.component.html",
}) })
export class PassphraseSettingsComponent implements OnInit, OnDestroy { export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the component /** Instantiates the component
* @param accountService queries user availability
* @param generatorService settings and policy logic * @param generatorService settings and policy logic
* @param i18nService localize hints * @param i18nService localize hints
* @param formBuilder reactive form controls * @param formBuilder reactive form controls
@@ -47,15 +44,20 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private i18nService: I18nService, private i18nService: I18nService,
private accountService: AccountService,
) {} ) {}
/** Binds the component to a specific user's settings. /** Binds the component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/ */
@Input() @Input({ required: true })
userId: UserId | null; account: Account;
protected account$ = new ReplaySubject<Account>(1);
async ngOnChanges(changes: SimpleChanges) {
if ("account" in changes && changes.account) {
this.account$.next(this.account);
}
}
/** When `true`, an options header is displayed by the component. Otherwise, the header is hidden. */ /** When `true`, an options header is displayed by the component. Otherwise, the header is hidden. */
@Input() @Input()
@@ -80,8 +82,9 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
}); });
async ngOnInit() { async ngOnInit() {
const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.passphrase, {
const settings = await this.generatorService.settings(Generators.passphrase, { singleUserId$ }); account$: this.account$,
});
// skips reactive event emissions to break a subscription cycle // skips reactive event emissions to break a subscription cycle
settings.withConstraints$ settings.withConstraints$
@@ -108,7 +111,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
// explain policy & disable policy-overridden fields // explain policy & disable policy-overridden fields
this.generatorService this.generatorService
.policy$(Generators.passphrase, { userId$: singleUserId$ }) .policy$(Generators.passphrase, { account$: this.account$ })
.pipe(takeUntil(this.destroyed$)) .pipe(takeUntil(this.destroyed$))
.subscribe(({ constraints }) => { .subscribe(({ constraints }) => {
this.wordSeparatorMaxLength = constraints.wordSeparator.maxLength; this.wordSeparatorMaxLength = constraints.wordSeparator.maxLength;
@@ -152,19 +155,6 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
} }
} }
private singleUserId$() {
// FIXME: this branch should probably scan for the user and make sure
// the account is unlocked
if (this.userId) {
return new BehaviorSubject(this.userId as UserId).asObservable();
}
return this.accountService.activeAccount$.pipe(
completeOnAccountSwitch(),
takeUntil(this.destroyed$),
);
}
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed$.next(); this.destroyed$.next();

View File

@@ -39,14 +39,14 @@
<tools-password-settings <tools-password-settings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === 'password'" *ngIf="(algorithm$ | async)?.id === 'password'"
[userId]="this.userId$ | async" [account]="account$ | async"
[disableMargin]="disableMargin" [disableMargin]="disableMargin"
(onUpdated)="generate('password settings')" (onUpdated)="generate('password settings')"
/> />
<tools-passphrase-settings <tools-passphrase-settings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === 'passphrase'" *ngIf="(algorithm$ | async)?.id === 'passphrase'"
[userId]="this.userId$ | async" [account]="account$ | async"
(onUpdated)="generate('passphrase settings')" (onUpdated)="generate('passphrase settings')"
[disableMargin]="disableMargin" [disableMargin]="disableMargin"
/> />

View File

@@ -2,12 +2,23 @@
// @ts-strict-ignore // @ts-strict-ignore
import { LiveAnnouncer } from "@angular/cdk/a11y"; import { LiveAnnouncer } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import {
Component,
EventEmitter,
Input,
NgZone,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { import {
BehaviorSubject, BehaviorSubject,
catchError, catchError,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
firstValueFrom,
map, map,
ReplaySubject, ReplaySubject,
Subject, Subject,
@@ -16,8 +27,13 @@ import {
withLatestFrom, withLatestFrom,
} from "rxjs"; } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
SemanticLogger,
disabledSemanticLoggerProvider,
ifEnabledSemanticLoggerProvider,
} from "@bitwarden/common/tools/log";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { ToastService, Option } from "@bitwarden/components"; import { ToastService, Option } from "@bitwarden/components";
import { import {
@@ -29,6 +45,7 @@ import {
AlgorithmInfo, AlgorithmInfo,
isSameAlgorithm, isSameAlgorithm,
GenerateRequest, GenerateRequest,
CredentialCategories,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { GeneratorHistoryService } from "@bitwarden/generator-history"; import { GeneratorHistoryService } from "@bitwarden/generator-history";
@@ -37,7 +54,7 @@ import { GeneratorHistoryService } from "@bitwarden/generator-history";
selector: "tools-password-generator", selector: "tools-password-generator",
templateUrl: "password-generator.component.html", templateUrl: "password-generator.component.html",
}) })
export class PasswordGeneratorComponent implements OnInit, OnDestroy { export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy {
constructor( constructor(
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private generatorHistoryService: GeneratorHistoryService, private generatorHistoryService: GeneratorHistoryService,
@@ -48,12 +65,38 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
private ariaLive: LiveAnnouncer, private ariaLive: LiveAnnouncer,
) {} ) {}
/** Binds the component to a specific user's settings. /** Binds the component to a specific user's settings. When this input is not provided,
* When this input is not provided, the form binds to the active * the form binds to the active user
* user
*/ */
@Input() @Input()
userId: UserId | null; account: Account | null;
protected account$ = new ReplaySubject<Account>(1);
/** Send structured debug logs from the credential generator component
* to the debugger console.
*
* @warning this may reveal sensitive information in plaintext.
*/
@Input()
debug: boolean = false;
// this `log` initializer is overridden in `ngOnInit`
private log: SemanticLogger = disabledSemanticLoggerProvider({});
async ngOnChanges(changes: SimpleChanges) {
const account = changes?.account;
if (account?.previousValue?.id !== account?.currentValue?.id) {
this.log.debug(
{
previousUserId: account?.previousValue?.id as UserId,
currentUserId: account?.currentValue?.id as UserId,
},
"account input change detected",
);
this.account$.next(this.account);
}
}
/** Removes bottom margin, passed to downstream components */ /** Removes bottom margin, passed to downstream components */
@Input({ transform: coerceBooleanProperty }) disableMargin = false; @Input({ transform: coerceBooleanProperty }) disableMargin = false;
@@ -64,9 +107,6 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
/** Emits the last generated value. */ /** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>(""); protected readonly value$ = new BehaviorSubject<string>("");
/** Emits when the userId changes */
protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Emits when a new credential is requested */ /** Emits when a new credential is requested */
private readonly generate$ = new Subject<GenerateRequest>(); private readonly generate$ = new Subject<GenerateRequest>();
@@ -77,8 +117,10 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
* @param requestor a label used to trace generation request * @param requestor a label used to trace generation request
* origin in the debugger. * origin in the debugger.
*/ */
protected async generate(requestor: string) { protected async generate(source: string) {
this.generate$.next({ source: requestor }); this.log.debug({ source }, "generation requested");
this.generate$.next({ source });
} }
/** Tracks changes to the selected credential type /** Tracks changes to the selected credential type
@@ -102,20 +144,21 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
readonly onAlgorithm = new EventEmitter<AlgorithmInfo>(); readonly onAlgorithm = new EventEmitter<AlgorithmInfo>();
async ngOnInit() { async ngOnInit() {
if (this.userId) { this.log = ifEnabledSemanticLoggerProvider(this.debug, this.logService, {
this.userId$.next(this.userId); type: "UsernameGeneratorComponent",
} else { });
this.accountService.activeAccount$
.pipe( if (!this.account) {
map((acct) => acct.id), this.account = await firstValueFrom(this.accountService.activeAccount$);
distinctUntilChanged(), this.log.info(
takeUntil(this.destroyed), { userId: this.account.id },
) "account not specified; using active account settings",
.subscribe(this.userId$); );
this.account$.next(this.account);
} }
this.generatorService this.generatorService
.algorithms$("password", { userId$: this.userId$ }) .algorithms$("password", { account$: this.account$ })
.pipe( .pipe(
map((algorithms) => this.toOptions(algorithms)), map((algorithms) => this.toOptions(algorithms)),
takeUntil(this.destroyed), takeUntil(this.destroyed),
@@ -141,12 +184,14 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
// continue with origin stream // continue with origin stream
return generator; return generator;
}), }),
withLatestFrom(this.userId$, this.algorithm$), withLatestFrom(this.account$, this.algorithm$),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([generated, userId, algorithm]) => { .subscribe(([generated, account, algorithm]) => {
this.log.debug({ source: generated.source }, "credential generated");
this.generatorHistoryService this.generatorHistoryService
.track(userId, generated.credential, generated.category, generated.generationDate) .track(account.id, generated.credential, generated.category, generated.generationDate)
.catch((e: unknown) => { .catch((e: unknown) => {
this.logService.error(e); this.logService.error(e);
}); });
@@ -164,7 +209,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
}); });
// assume the last-visible generator algorithm is the user's preferred one // assume the last-visible generator algorithm is the user's preferred one
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); const preferences = await this.generatorService.preferences({ account$: this.account$ });
this.credentialType$ this.credentialType$
.pipe( .pipe(
filter((type) => !!type), filter((type) => !!type),
@@ -173,6 +218,10 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
) )
.subscribe(([algorithm, preference]) => { .subscribe(([algorithm, preference]) => {
if (isPasswordAlgorithm(algorithm)) { if (isPasswordAlgorithm(algorithm)) {
this.log.info(
{ algorithm, category: CredentialCategories.password },
"algorithm preferences updated",
);
preference.password.algorithm = algorithm; preference.password.algorithm = algorithm;
preference.password.updated = new Date(); preference.password.updated = new Date();
} else { } else {
@@ -190,6 +239,8 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe((algorithm) => { .subscribe((algorithm) => {
this.log.debug(algorithm, "algorithm selected");
// update navigation // update navigation
this.onCredentialTypeChanged(algorithm.id); this.onCredentialTypeChanged(algorithm.id);
@@ -205,32 +256,40 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => {
this.zone.run(() => { this.zone.run(() => {
if (!a || a.onlyOnRequest) { if (!a || a.onlyOnRequest) {
this.log.debug("autogeneration disabled; clearing generated credential");
this.value$.next("-"); this.value$.next("-");
} else { } else {
this.generate("autogenerate").catch((e: unknown) => this.logService.error(e)); this.log.debug("autogeneration enabled");
this.generate("autogenerate").catch((e: unknown) => {
this.log.error(e as object, "a failure occurred during autogeneration");
});
} }
}); });
}); });
this.log.debug("component initialized");
} }
private announce(message: string) { private announce(message: string) {
this.ariaLive.announce(message).catch((e) => this.logService.error(e)); this.ariaLive.announce(message).catch((e) => this.logService.error(e));
} }
private typeToGenerator$(type: CredentialAlgorithm) { private typeToGenerator$(algorithm: CredentialAlgorithm) {
const dependencies = { const dependencies = {
on$: this.generate$, on$: this.generate$,
userId$: this.userId$, account$: this.account$,
}; };
switch (type) { this.log.debug({ algorithm }, "constructing generation stream");
switch (algorithm) {
case "password": case "password":
return this.generatorService.generate$(Generators.password, dependencies); return this.generatorService.generate$(Generators.password, dependencies);
case "passphrase": case "passphrase":
return this.generatorService.generate$(Generators.passphrase, dependencies); return this.generatorService.generate$(Generators.passphrase, dependencies);
default: default:
throw new Error(`Invalid generator type: "${type}"`); this.log.panic({ algorithm }, `Invalid generator type: "${algorithm}"`);
} }
} }

View File

@@ -1,31 +1,27 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { import {
BehaviorSubject, OnInit,
takeUntil, Input,
Subject, Output,
map, EventEmitter,
filter, Component,
tap, OnDestroy,
skip, SimpleChanges,
ReplaySubject, OnChanges,
withLatestFrom, } from "@angular/core";
} from "rxjs"; import { FormBuilder } from "@angular/forms";
import { takeUntil, Subject, map, filter, tap, skip, ReplaySubject, withLatestFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { import {
Generators, Generators,
CredentialGeneratorService, CredentialGeneratorService,
PasswordGenerationOptions, PasswordGenerationOptions,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { completeOnAccountSwitch } from "./util";
const Controls = Object.freeze({ const Controls = Object.freeze({
length: "length", length: "length",
uppercase: "uppercase", uppercase: "uppercase",
@@ -42,9 +38,8 @@ const Controls = Object.freeze({
selector: "tools-password-settings", selector: "tools-password-settings",
templateUrl: "password-settings.component.html", templateUrl: "password-settings.component.html",
}) })
export class PasswordSettingsComponent implements OnInit, OnDestroy { export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the component /** Instantiates the component
* @param accountService queries user availability
* @param generatorService settings and policy logic * @param generatorService settings and policy logic
* @param i18nService localize hints * @param i18nService localize hints
* @param formBuilder reactive form controls * @param formBuilder reactive form controls
@@ -53,15 +48,20 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private i18nService: I18nService, private i18nService: I18nService,
private accountService: AccountService,
) {} ) {}
/** Binds the password component to a specific user's settings. /** Binds the component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/ */
@Input() @Input({ required: true })
userId: UserId | null; account: Account;
protected account$ = new ReplaySubject<Account>(1);
async ngOnChanges(changes: SimpleChanges) {
if ("account" in changes && changes.account) {
this.account$.next(this.account);
}
}
/** When `true`, an options header is displayed by the component. Otherwise, the header is hidden. */ /** When `true`, an options header is displayed by the component. Otherwise, the header is hidden. */
@Input() @Input()
@@ -110,8 +110,9 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
} }
async ngOnInit() { async ngOnInit() {
const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.password, {
const settings = await this.generatorService.settings(Generators.password, { singleUserId$ }); account$: this.account$,
});
// bind settings to the UI // bind settings to the UI
settings.withConstraints$ settings.withConstraints$
@@ -145,7 +146,7 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
// explain policy & disable policy-overridden fields // explain policy & disable policy-overridden fields
this.generatorService this.generatorService
.policy$(Generators.password, { userId$: singleUserId$ }) .policy$(Generators.password, { account$: this.account$ })
.pipe(takeUntil(this.destroyed$)) .pipe(takeUntil(this.destroyed$))
.subscribe(({ constraints }) => { .subscribe(({ constraints }) => {
this.policyInEffect = constraints.policyInEffect; this.policyInEffect = constraints.policyInEffect;
@@ -243,19 +244,6 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
} }
} }
private singleUserId$() {
// FIXME: this branch should probably scan for the user and make sure
// the account is unlocked
if (this.userId) {
return new BehaviorSubject(this.userId as UserId).asObservable();
}
return this.accountService.activeAccount$.pipe(
completeOnAccountSwitch(),
takeUntil(this.destroyed$),
);
}
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed$.next(); this.destroyed$.next();

View File

@@ -1,25 +1,31 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import {
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, map, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { import {
CredentialGeneratorService, CredentialGeneratorService,
Generators, Generators,
SubaddressGenerationOptions, SubaddressGenerationOptions,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { completeOnAccountSwitch } from "./util";
/** Options group for plus-addressed emails */ /** Options group for plus-addressed emails */
@Component({ @Component({
selector: "tools-subaddress-settings", selector: "tools-subaddress-settings",
templateUrl: "subaddress-settings.component.html", templateUrl: "subaddress-settings.component.html",
}) })
export class SubaddressSettingsComponent implements OnInit, OnDestroy { export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the component /** Instantiates the component
* @param accountService queries user availability * @param accountService queries user availability
* @param generatorService settings and policy logic * @param generatorService settings and policy logic
@@ -32,11 +38,17 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy {
) {} ) {}
/** Binds the component to a specific user's settings. /** Binds the component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/ */
@Input() @Input({ required: true })
userId: UserId | null; account: Account;
protected account$ = new ReplaySubject<Account>(1);
async ngOnChanges(changes: SimpleChanges) {
if ("account" in changes && changes.account) {
this.account$.next(this.account);
}
}
/** Emits settings updates and completes if the settings become unavailable. /** Emits settings updates and completes if the settings become unavailable.
* @remarks this does not emit the initial settings. If you would like * @remarks this does not emit the initial settings. If you would like
@@ -52,8 +64,9 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy {
}); });
async ngOnInit() { async ngOnInit() {
const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.subaddress, {
const settings = await this.generatorService.settings(Generators.subaddress, { singleUserId$ }); account$: this.account$,
});
settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => {
this.settings.patchValue(s, { emitEvent: false }); this.settings.patchValue(s, { emitEvent: false });
@@ -76,19 +89,6 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy {
this.saveSettings.next(site); this.saveSettings.next(site);
} }
private singleUserId$() {
// FIXME: this branch should probably scan for the user and make sure
// the account is unlocked
if (this.userId) {
return new BehaviorSubject(this.userId as UserId).asObservable();
}
return this.accountService.activeAccount$.pipe(
completeOnAccountSwitch(),
takeUntil(this.destroyed$),
);
}
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed$.next(); this.destroyed$.next();

View File

@@ -60,22 +60,22 @@
</form> </form>
<tools-catchall-settings <tools-catchall-settings
*ngIf="(algorithm$ | async)?.id === 'catchall'" *ngIf="(algorithm$ | async)?.id === 'catchall'"
[userId]="this.userId$ | async" [account]="account$ | async"
(onUpdated)="generate('catchall settings')" (onUpdated)="generate('catchall settings')"
/> />
<tools-forwarder-settings <tools-forwarder-settings
*ngIf="!!(forwarderId$ | async)" *ngIf="!!(forwarderId$ | async)"
[forwarder]="forwarderId$ | async" [forwarder]="forwarderId$ | async"
[userId]="this.userId$ | async" [account]="account$ | async"
/> />
<tools-subaddress-settings <tools-subaddress-settings
*ngIf="(algorithm$ | async)?.id === 'subaddress'" *ngIf="(algorithm$ | async)?.id === 'subaddress'"
[userId]="this.userId$ | async" [account]="account$ | async"
(onUpdated)="generate('subaddress settings')" (onUpdated)="generate('subaddress settings')"
/> />
<tools-username-settings <tools-username-settings
*ngIf="(algorithm$ | async)?.id === 'username'" *ngIf="(algorithm$ | async)?.id === 'username'"
[userId]="this.userId$ | async" [account]="account$ | async"
(onUpdated)="generate('username settings')" (onUpdated)="generate('username settings')"
/> />
</bit-card> </bit-card>

View File

@@ -2,7 +2,17 @@
// @ts-strict-ignore // @ts-strict-ignore
import { LiveAnnouncer } from "@angular/cdk/a11y"; import { LiveAnnouncer } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import {
Component,
EventEmitter,
Input,
NgZone,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { import {
BehaviorSubject, BehaviorSubject,
@@ -11,6 +21,7 @@ import {
combineLatestWith, combineLatestWith,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
firstValueFrom,
map, map,
ReplaySubject, ReplaySubject,
Subject, Subject,
@@ -19,15 +30,21 @@ import {
withLatestFrom, withLatestFrom,
} from "rxjs"; } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { IntegrationId } from "@bitwarden/common/tools/integration"; import { IntegrationId } from "@bitwarden/common/tools/integration";
import {
SemanticLogger,
disabledSemanticLoggerProvider,
ifEnabledSemanticLoggerProvider,
} from "@bitwarden/common/tools/log";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { ToastService, Option } from "@bitwarden/components"; import { ToastService, Option } from "@bitwarden/components";
import { import {
AlgorithmInfo, AlgorithmInfo,
CredentialAlgorithm, CredentialAlgorithm,
CredentialCategories,
CredentialGeneratorService, CredentialGeneratorService,
GenerateRequest, GenerateRequest,
GeneratedCredential, GeneratedCredential,
@@ -51,7 +68,7 @@ const NONE_SELECTED = "none";
selector: "tools-username-generator", selector: "tools-username-generator",
templateUrl: "username-generator.component.html", templateUrl: "username-generator.component.html",
}) })
export class UsernameGeneratorComponent implements OnInit, OnDestroy { export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the username generator /** Instantiates the username generator
* @param generatorService generates credentials; stores preferences * @param generatorService generates credentials; stores preferences
* @param i18nService localizes generator algorithm descriptions * @param i18nService localizes generator algorithm descriptions
@@ -75,7 +92,34 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
* the form binds to the active user * the form binds to the active user
*/ */
@Input() @Input()
userId: UserId | null; account: Account | null;
protected account$ = new ReplaySubject<Account>(1);
/** Send structured debug logs from the credential generator component
* to the debugger console.
*
* @warning this may reveal sensitive information in plaintext.
*/
@Input()
debug: boolean = false;
// this `log` initializer is overridden in `ngOnInit`
private log: SemanticLogger = disabledSemanticLoggerProvider({});
async ngOnChanges(changes: SimpleChanges) {
const account = changes?.account;
if (account?.previousValue?.id !== account?.currentValue?.id) {
this.log.debug(
{
previousUserId: account?.previousValue?.id as UserId,
currentUserId: account?.currentValue?.id as UserId,
},
"account input change detected",
);
this.account$.next(this.account);
}
}
/** /**
* The website associated with the credential generation request. * The website associated with the credential generation request.
@@ -104,20 +148,21 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
}); });
async ngOnInit() { async ngOnInit() {
if (this.userId) { this.log = ifEnabledSemanticLoggerProvider(this.debug, this.logService, {
this.userId$.next(this.userId); type: "UsernameGeneratorComponent",
} else { });
this.accountService.activeAccount$
.pipe( if (!this.account) {
map((acct) => acct.id), this.account = await firstValueFrom(this.accountService.activeAccount$);
distinctUntilChanged(), this.log.info(
takeUntil(this.destroyed), { userId: this.account.id },
) "account not specified; using active account settings",
.subscribe(this.userId$); );
this.account$.next(this.account);
} }
this.generatorService this.generatorService
.algorithms$(["email", "username"], { userId$: this.userId$ }) .algorithms$(["email", "username"], { account$: this.account$ })
.pipe( .pipe(
map((algorithms) => { map((algorithms) => {
const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id));
@@ -169,12 +214,14 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
// continue with origin stream // continue with origin stream
return generator; return generator;
}), }),
withLatestFrom(this.userId$, this.algorithm$), withLatestFrom(this.account$, this.algorithm$),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([generated, userId, algorithm]) => { .subscribe(([generated, account, algorithm]) => {
this.log.debug({ source: generated.source }, "credential generated");
this.generatorHistoryService this.generatorHistoryService
.track(userId, generated.credential, generated.category, generated.generationDate) .track(account.id, generated.credential, generated.category, generated.generationDate)
.catch((e: unknown) => { .catch((e: unknown) => {
this.logService.error(e); this.logService.error(e);
}); });
@@ -237,6 +284,8 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([showForwarder, forwarderId]) => { .subscribe(([showForwarder, forwarderId]) => {
this.log.debug({ forwarderId, showForwarder }, "forwarder visibility updated");
// update subjects within the angular zone so that the // update subjects within the angular zone so that the
// template bindings refresh immediately // template bindings refresh immediately
this.zone.run(() => { this.zone.run(() => {
@@ -260,6 +309,8 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe((algorithm) => { .subscribe((algorithm) => {
this.log.debug(algorithm, "algorithm selected");
// update subjects within the angular zone so that the // update subjects within the angular zone so that the
// template bindings refresh immediately // template bindings refresh immediately
this.zone.run(() => { this.zone.run(() => {
@@ -269,7 +320,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
}); });
// assume the last-visible generator algorithm is the user's preferred one // assume the last-visible generator algorithm is the user's preferred one
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); const preferences = await this.generatorService.preferences({ account$: this.account$ });
this.algorithm$ this.algorithm$
.pipe( .pipe(
filter((algorithm) => !!algorithm), filter((algorithm) => !!algorithm),
@@ -278,9 +329,17 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
) )
.subscribe(([algorithm, preference]) => { .subscribe(([algorithm, preference]) => {
if (isEmailAlgorithm(algorithm.id)) { if (isEmailAlgorithm(algorithm.id)) {
this.log.info(
{ algorithm, category: CredentialCategories.email },
"algorithm preferences updated",
);
preference.email.algorithm = algorithm.id; preference.email.algorithm = algorithm.id;
preference.email.updated = new Date(); preference.email.updated = new Date();
} else if (isUsernameAlgorithm(algorithm.id)) { } else if (isUsernameAlgorithm(algorithm.id)) {
this.log.info(
{ algorithm, category: CredentialCategories.username },
"algorithm preferences updated",
);
preference.username.algorithm = algorithm.id; preference.username.algorithm = algorithm.id;
preference.username.updated = new Date(); preference.username.updated = new Date();
} else { } else {
@@ -339,21 +398,30 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => {
this.zone.run(() => { this.zone.run(() => {
if (!a || a.onlyOnRequest) { if (!a || a.onlyOnRequest) {
this.log.debug("autogeneration disabled; clearing generated credential");
this.value$.next("-"); this.value$.next("-");
} else { } else {
this.generate("autogenerate").catch((e: unknown) => this.logService.error(e)); this.log.debug("autogeneration enabled");
this.generate("autogenerate").catch((e: unknown) => {
this.log.error(e as object, "a failure occurred during autogeneration");
});
} }
}); });
}); });
this.log.debug("component initialized");
} }
private typeToGenerator$(type: CredentialAlgorithm) { private typeToGenerator$(algorithm: CredentialAlgorithm) {
const dependencies = { const dependencies = {
on$: this.generate$, on$: this.generate$,
userId$: this.userId$, account$: this.account$,
}; };
switch (type) { this.log.debug({ algorithm }, "constructing generation stream");
switch (algorithm) {
case "catchall": case "catchall":
return this.generatorService.generate$(Generators.catchall, dependencies); return this.generatorService.generate$(Generators.catchall, dependencies);
@@ -364,13 +432,13 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
return this.generatorService.generate$(Generators.username, dependencies); return this.generatorService.generate$(Generators.username, dependencies);
} }
if (isForwarderIntegration(type)) { if (isForwarderIntegration(algorithm)) {
const forwarder = getForwarderConfiguration(type.forwarder); const forwarder = getForwarderConfiguration(algorithm.forwarder);
const configuration = toCredentialGeneratorConfiguration(forwarder); const configuration = toCredentialGeneratorConfiguration(forwarder);
return this.generatorService.generate$(configuration, dependencies); return this.generatorService.generate$(configuration, dependencies);
} }
throw new Error(`Invalid generator type: "${type}"`); this.log.panic({ algorithm }, `Invalid generator type: "${algorithm}"`);
} }
private announce(message: string) { private announce(message: string) {
@@ -398,9 +466,6 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
/** Emits the last generated value. */ /** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>(""); protected readonly value$ = new BehaviorSubject<string>("");
/** Emits when the userId changes */
protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Emits when a new credential is requested */ /** Emits when a new credential is requested */
private readonly generate$ = new Subject<GenerateRequest>(); private readonly generate$ = new Subject<GenerateRequest>();
@@ -437,11 +502,13 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
protected readonly USER_REQUEST = "user request"; protected readonly USER_REQUEST = "user request";
/** Request a new value from the generator /** Request a new value from the generator
* @param requestor a label used to trace generation request * @param source a label used to trace generation request
* origin in the debugger. * origin in the debugger.
*/ */
protected async generate(requestor: string) { protected async generate(source: string) {
this.generate$.next({ source: requestor, website: this.website }); const request = { source, website: this.website };
this.log.debug(request, "generation requested");
this.generate$.next(request);
} }
private toOptions(algorithms: AlgorithmInfo[]) { private toOptions(algorithms: AlgorithmInfo[]) {

View File

@@ -1,25 +1,31 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import {
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, map, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { import {
CredentialGeneratorService, CredentialGeneratorService,
EffUsernameGenerationOptions, EffUsernameGenerationOptions,
Generators, Generators,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { completeOnAccountSwitch } from "./util";
/** Options group for usernames */ /** Options group for usernames */
@Component({ @Component({
selector: "tools-username-settings", selector: "tools-username-settings",
templateUrl: "username-settings.component.html", templateUrl: "username-settings.component.html",
}) })
export class UsernameSettingsComponent implements OnInit, OnDestroy { export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy {
/** Instantiates the component /** Instantiates the component
* @param accountService queries user availability * @param accountService queries user availability
* @param generatorService settings and policy logic * @param generatorService settings and policy logic
@@ -28,15 +34,20 @@ export class UsernameSettingsComponent implements OnInit, OnDestroy {
constructor( constructor(
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private generatorService: CredentialGeneratorService, private generatorService: CredentialGeneratorService,
private accountService: AccountService,
) {} ) {}
/** Binds the component to a specific user's settings. /** Binds the component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/ */
@Input() @Input({ required: true })
userId: UserId | null; account: Account;
protected account$ = new ReplaySubject<Account>(1);
async ngOnChanges(changes: SimpleChanges) {
if ("account" in changes) {
this.account$.next(this.account);
}
}
/** Emits settings updates and completes if the settings become unavailable. /** Emits settings updates and completes if the settings become unavailable.
* @remarks this does not emit the initial settings. If you would like * @remarks this does not emit the initial settings. If you would like
@@ -53,8 +64,9 @@ export class UsernameSettingsComponent implements OnInit, OnDestroy {
}); });
async ngOnInit() { async ngOnInit() {
const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.username, {
const settings = await this.generatorService.settings(Generators.username, { singleUserId$ }); account$: this.account$,
});
settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => {
this.settings.patchValue(s, { emitEvent: false }); this.settings.patchValue(s, { emitEvent: false });
@@ -77,19 +89,6 @@ export class UsernameSettingsComponent implements OnInit, OnDestroy {
this.saveSettings.next(site); this.saveSettings.next(site);
} }
private singleUserId$() {
// FIXME: this branch should probably scan for the user and make sure
// the account is unlocked
if (this.userId) {
return new BehaviorSubject(this.userId as UserId).asObservable();
}
return this.accountService.activeAccount$.pipe(
completeOnAccountSwitch(),
takeUntil(this.destroyed$),
);
}
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed$.next(); this.destroyed$.next();

View File

@@ -67,6 +67,7 @@ const forwarder = Object.freeze({
key: "addyIoForwarder", key: "addyIoForwarder",
target: "object", target: "object",
format: "secret-state", format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<AddyIoSettings>(), classifier: new PrivateClassifier<AddyIoSettings>(),
state: GENERATOR_DISK, state: GENERATOR_DISK,
initial: defaultSettings, initial: defaultSettings,

View File

@@ -56,6 +56,7 @@ const forwarder = Object.freeze({
key: "duckDuckGoForwarder", key: "duckDuckGoForwarder",
target: "object", target: "object",
format: "secret-state", format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<DuckDuckGoSettings>(), classifier: new PrivateClassifier<DuckDuckGoSettings>(),
state: GENERATOR_DISK, state: GENERATOR_DISK,
initial: defaultSettings, initial: defaultSettings,

View File

@@ -126,6 +126,7 @@ const forwarder = Object.freeze({
key: "fastmailForwarder", key: "fastmailForwarder",
target: "object", target: "object",
format: "secret-state", format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<FastmailSettings>(), classifier: new PrivateClassifier<FastmailSettings>(),
state: GENERATOR_DISK, state: GENERATOR_DISK,
initial: defaultSettings, initial: defaultSettings,

View File

@@ -60,6 +60,7 @@ const forwarder = Object.freeze({
key: "firefoxRelayForwarder", key: "firefoxRelayForwarder",
target: "object", target: "object",
format: "secret-state", format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<FirefoxRelaySettings>(), classifier: new PrivateClassifier<FirefoxRelaySettings>(),
state: GENERATOR_DISK, state: GENERATOR_DISK,
initial: defaultSettings, initial: defaultSettings,

View File

@@ -63,6 +63,7 @@ const forwarder = Object.freeze({
key: "forwardEmailForwarder", key: "forwardEmailForwarder",
target: "object", target: "object",
format: "secret-state", format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<ForwardEmailSettings>(), classifier: new PrivateClassifier<ForwardEmailSettings>(),
state: GENERATOR_DISK, state: GENERATOR_DISK,
initial: defaultSettings, initial: defaultSettings,

View File

@@ -66,6 +66,7 @@ const forwarder = Object.freeze({
key: "simpleLoginForwarder", key: "simpleLoginForwarder",
target: "object", target: "object",
format: "secret-state", format: "secret-state",
frame: 512,
classifier: new PrivateClassifier<SimpleLoginSettings>(), classifier: new PrivateClassifier<SimpleLoginSettings>(),
state: GENERATOR_DISK, state: GENERATOR_DISK,
initial: defaultSettings, initial: defaultSettings,

View File

@@ -1,7 +1,7 @@
// FIXME: remove ts-strict-ignore once `FakeAccountService` implements ts strict support // FIXME: remove ts-strict-ignore once `FakeAccountService` implements ts strict support
// @ts-strict-ignore // @ts-strict-ignore
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, Subject } from "rxjs"; import { BehaviorSubject, firstValueFrom, map, Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction"; import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction";
import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log";
import { StateConstraints } from "@bitwarden/common/tools/types"; import { StateConstraints } from "@bitwarden/common/tools/types";
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
@@ -164,11 +165,13 @@ const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId; const AnotherUser = "SomeOtherUser" as UserId;
const accounts = { const accounts = {
[SomeUser]: { [SomeUser]: {
id: SomeUser,
name: "some user", name: "some user",
email: "some.user@example.com", email: "some.user@example.com",
emailVerified: true, emailVerified: true,
}, },
[AnotherUser]: { [AnotherUser]: {
id: AnotherUser,
name: "some other user", name: "some other user",
email: "some.other.user@example.com", email: "some.other.user@example.com",
emailVerified: true, emailVerified: true,
@@ -187,16 +190,26 @@ const i18nService = mock<I18nService>();
const apiService = mock<ApiService>(); const apiService = mock<ApiService>();
const encryptor = mock<UserEncryptor>(); const encryptor = mock<UserEncryptor>();
const encryptorProvider = mock<LegacyEncryptorProvider>(); const encryptorProvider = mock<LegacyEncryptorProvider>({
userEncryptor$(_, dependencies) {
return dependencies.singleUserId$.pipe(map((userId) => ({ userId, encryptor })));
},
});
const account$ = new BehaviorSubject(accounts[SomeUser]);
const providers = {
encryptor: encryptorProvider,
state: stateProvider,
log: disabledSemanticLoggerProvider,
};
describe("CredentialGeneratorService", () => { describe("CredentialGeneratorService", () => {
beforeEach(async () => { beforeEach(async () => {
await accountService.switchAccount(SomeUser); await accountService.switchAccount(SomeUser);
policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable()); policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable());
i18nService.t.mockImplementation((key) => key); i18nService.t.mockImplementation((key: string) => key);
apiService.fetch.mockImplementation(() => Promise.resolve(mock<Response>())); apiService.fetch.mockImplementation(() => Promise.resolve(mock<Response>()));
const encryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor });
encryptorProvider.userEncryptor$.mockReturnValue(encryptor$);
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@@ -205,18 +218,16 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const on$ = new Subject<GenerateRequest>(); const on$ = new Subject<GenerateRequest>();
let complete = false; let complete = false;
// confirm no emission during subscription // confirm no emission during subscription
generator.generate$(SomeConfiguration, { on$ }).subscribe({ generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({
complete: () => { complete: () => {
complete = true; complete = true;
}, },
@@ -232,15 +243,15 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, settings, SomeUser); await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const on$ = new BehaviorSubject<GenerateRequest>({ source: "some source" }); const on$ = new BehaviorSubject<GenerateRequest>({ source: "some source" });
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ })); const generated = new ObservableTracker(
generator.generate$(SomeConfiguration, { on$, account$ }),
);
const result = await generated.expectEmission(); const result = await generated.expectEmission();
@@ -252,49 +263,21 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, settings, SomeUser); await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const on$ = new BehaviorSubject({ website: "some website" }); const on$ = new BehaviorSubject({ website: "some website" });
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ })); const generated = new ObservableTracker(
generator.generate$(SomeConfiguration, { on$, account$ }),
);
const result = await generated.expectEmission(); const result = await generated.expectEmission();
expect(result.website).toEqual("some website"); expect(result.website).toEqual("some website");
}); });
it("uses the active user's settings", async () => {
const someSettings = { foo: "some value" };
const anotherSettings = { foo: "another value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const on$ = new BehaviorSubject({});
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ }));
await accountService.switchAccount(AnotherUser);
on$.next({});
await generated.pauseUntilReceived(2);
generated.unsubscribe();
expect(generated.emissions).toEqual([
new GeneratedCredential("some value", SomeAlgorithm, SomeTime),
new GeneratedCredential("another value", SomeAlgorithm, SomeTime),
]);
});
// FIXME: test these when the fake state provider can create the required emissions // FIXME: test these when the fake state provider can create the required emissions
it.todo("errors when the settings error"); it.todo("errors when the settings error");
it.todo("completes when the settings complete"); it.todo("completes when the settings complete");
@@ -304,17 +287,15 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable();
const on$ = new Subject<GenerateRequest>(); const on$ = new Subject<GenerateRequest>();
const generated = new ObservableTracker( const generated = new ObservableTracker(
generator.generate$(SomeConfiguration, { on$, userId$ }), generator.generate$(SomeConfiguration, { on$, account$ }),
); );
on$.next({}); on$.next({});
@@ -327,23 +308,21 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const on$ = new Subject<GenerateRequest>(); const on$ = new Subject<GenerateRequest>();
const userId$ = new BehaviorSubject(SomeUser); const account$ = new BehaviorSubject(accounts[SomeUser]);
let error = null; let error = null;
generator.generate$(SomeConfiguration, { on$, userId$ }).subscribe({ generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({
error: (e: unknown) => { error: (e: unknown) => {
error = e; error = e;
}, },
}); });
userId$.error({ some: "error" }); account$.error({ some: "error" });
await awaitAsync(); await awaitAsync();
expect(error).toEqual({ some: "error" }); expect(error).toEqual({ some: "error" });
@@ -353,23 +332,21 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const on$ = new Subject<GenerateRequest>(); const on$ = new Subject<GenerateRequest>();
const userId$ = new BehaviorSubject(SomeUser); const account$ = new BehaviorSubject(accounts[SomeUser]);
let completed = false; let completed = false;
generator.generate$(SomeConfiguration, { on$, userId$ }).subscribe({ generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({
complete: () => { complete: () => {
completed = true; completed = true;
}, },
}); });
userId$.complete(); account$.complete();
await awaitAsync(); await awaitAsync();
expect(completed).toBeTruthy(); expect(completed).toBeTruthy();
@@ -380,19 +357,17 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const on$ = new Subject<GenerateRequest>(); const on$ = new Subject<GenerateRequest>();
const results: any[] = []; const results: any[] = [];
// confirm no emission during subscription // confirm no emission during subscription
const sub = generator const sub = generator
.generate$(SomeConfiguration, { on$ }) .generate$(SomeConfiguration, { on$, account$ })
.subscribe((result) => results.push(result)); .subscribe((result) => results.push(result));
await awaitAsync(); await awaitAsync();
expect(results.length).toEqual(0); expect(results.length).toEqual(0);
@@ -422,18 +397,16 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const on$ = new Subject<GenerateRequest>(); const on$ = new Subject<GenerateRequest>();
let error: any = null; let error: any = null;
// confirm no emission during subscription // confirm no emission during subscription
generator.generate$(SomeConfiguration, { on$ }).subscribe({ generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({
error: (e: unknown) => { error: (e: unknown) => {
error = e; error = e;
}, },
@@ -452,12 +425,10 @@ describe("CredentialGeneratorService", () => {
it("outputs password generation metadata", () => { it("outputs password generation metadata", () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = generator.algorithms("password"); const result = generator.algorithms("password");
@@ -473,12 +444,10 @@ describe("CredentialGeneratorService", () => {
it("outputs username generation metadata", () => { it("outputs username generation metadata", () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = generator.algorithms("username"); const result = generator.algorithms("username");
@@ -493,12 +462,10 @@ describe("CredentialGeneratorService", () => {
it("outputs email generation metadata", () => { it("outputs email generation metadata", () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = generator.algorithms("email"); const result = generator.algorithms("email");
@@ -514,12 +481,10 @@ describe("CredentialGeneratorService", () => {
it("combines metadata across categories", () => { it("combines metadata across categories", () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = generator.algorithms(["username", "email"]); const result = generator.algorithms(["username", "email"]);
@@ -539,15 +504,13 @@ describe("CredentialGeneratorService", () => {
it("returns password metadata", async () => { it("returns password metadata", async () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = await firstValueFrom(generator.algorithms$("password")); const result = await firstValueFrom(generator.algorithms$("password", { account$ }));
expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy();
expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy(); expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy();
@@ -556,15 +519,13 @@ describe("CredentialGeneratorService", () => {
it("returns username metadata", async () => { it("returns username metadata", async () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = await firstValueFrom(generator.algorithms$("username")); const result = await firstValueFrom(generator.algorithms$("username", { account$ }));
expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
}); });
@@ -572,15 +533,13 @@ describe("CredentialGeneratorService", () => {
it("returns email metadata", async () => { it("returns email metadata", async () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = await firstValueFrom(generator.algorithms$("email")); const result = await firstValueFrom(generator.algorithms$("email", { account$ }));
expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy();
@@ -589,15 +548,15 @@ describe("CredentialGeneratorService", () => {
it("returns username and email metadata", async () => { it("returns username and email metadata", async () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = await firstValueFrom(generator.algorithms$(["username", "email"])); const result = await firstValueFrom(
generator.algorithms$(["username", "email"], { account$ }),
);
expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
@@ -611,15 +570,13 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValue(policy$); policyService.getAll$.mockReturnValue(policy$);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = await firstValueFrom(generator.algorithms$(["password"])); const result = await firstValueFrom(generator.algorithms$(["password"], { account$ }));
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy();
@@ -627,26 +584,20 @@ describe("CredentialGeneratorService", () => {
}); });
it("follows changes to the active user", async () => { it("follows changes to the active user", async () => {
// initialize local account service and state provider because this test is sensitive const account$ = new BehaviorSubject(accounts[SomeUser]);
// to some shared data in `FakeAccountService`.
const accountService = new FakeAccountService(accounts);
const stateProvider = new FakeStateProvider(accountService);
await accountService.switchAccount(SomeUser);
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy]));
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const results: any = []; const results: any = [];
const sub = generator.algorithms$("password").subscribe((r) => results.push(r)); const sub = generator.algorithms$("password", { account$ }).subscribe((r) => results.push(r));
await accountService.switchAccount(AnotherUser); account$.next(accounts[AnotherUser]);
await awaitAsync(); await awaitAsync();
sub.unsubscribe(); sub.unsubscribe();
@@ -673,16 +624,14 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable();
const result = await firstValueFrom(generator.algorithms$("password", { userId$ })); const result = await firstValueFrom(generator.algorithms$("password", { account$ }));
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser);
expect(result.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); expect(result.some((a: any) => a.id === Generators.password.id)).toBeTruthy();
@@ -694,19 +643,17 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy]));
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const account = new BehaviorSubject(accounts[SomeUser]);
const userId$ = userId.asObservable(); const account$ = account.asObservable();
const results: any = []; const results: any = [];
const sub = generator.algorithms$("password", { userId$ }).subscribe((r) => results.push(r)); const sub = generator.algorithms$("password", { account$ }).subscribe((r) => results.push(r));
userId.next(AnotherUser); account.next(accounts[AnotherUser]);
await awaitAsync(); await awaitAsync();
sub.unsubscribe(); sub.unsubscribe();
@@ -724,23 +671,21 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const account = new BehaviorSubject(accounts[SomeUser]);
const userId$ = userId.asObservable(); const account$ = account.asObservable();
let error = null; let error = null;
generator.algorithms$("password", { userId$ }).subscribe({ generator.algorithms$("password", { account$ }).subscribe({
error: (e: unknown) => { error: (e: unknown) => {
error = e; error = e;
}, },
}); });
userId.error({ some: "error" }); account.error({ some: "error" });
await awaitAsync(); await awaitAsync();
expect(error).toEqual({ some: "error" }); expect(error).toEqual({ some: "error" });
@@ -750,23 +695,21 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const account = new BehaviorSubject(accounts[SomeUser]);
const userId$ = userId.asObservable(); const account$ = account.asObservable();
let completed = false; let completed = false;
generator.algorithms$("password", { userId$ }).subscribe({ generator.algorithms$("password", { account$ }).subscribe({
complete: () => { complete: () => {
completed = true; completed = true;
}, },
}); });
userId.complete(); account.complete();
await awaitAsync(); await awaitAsync();
expect(completed).toBeTruthy(); expect(completed).toBeTruthy();
@@ -776,26 +719,24 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const account = new BehaviorSubject(accounts[SomeUser]);
const userId$ = userId.asObservable(); const account$ = account.asObservable();
let count = 0; let count = 0;
const sub = generator.algorithms$("password", { userId$ }).subscribe({ const sub = generator.algorithms$("password", { account$ }).subscribe({
next: () => { next: () => {
count++; count++;
}, },
}); });
await awaitAsync(); await awaitAsync();
userId.next(SomeUser); account.next(accounts[SomeUser]);
await awaitAsync(); await awaitAsync();
userId.next(SomeUser); account.next(accounts[SomeUser]);
await awaitAsync(); await awaitAsync();
sub.unsubscribe(); sub.unsubscribe();
@@ -808,15 +749,13 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = await firstValueFrom(generator.settings$(SomeConfiguration)); const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ }));
expect(result).toEqual(SomeConfiguration.settings.initial); expect(result).toEqual(SomeConfiguration.settings.initial);
}); });
@@ -826,15 +765,13 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, settings, SomeUser); await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = await firstValueFrom(generator.settings$(SomeConfiguration)); const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ }));
expect(result).toEqual(settings); expect(result).toEqual(settings);
}); });
@@ -846,121 +783,54 @@ describe("CredentialGeneratorService", () => {
policyService.getAll$.mockReturnValue(policy$); policyService.getAll$.mockReturnValue(policy$);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const result = await firstValueFrom(generator.settings$(SomeConfiguration)); const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ }));
expect(result).toEqual({ foo: "adjusted(value)" }); expect(result).toEqual({ foo: "adjusted(value)" });
}); });
it("follows changes to the active user", async () => {
// initialize local account service and state provider because this test is sensitive
// to some shared data in `FakeAccountService`.
const accountService = new FakeAccountService(accounts);
const stateProvider = new FakeStateProvider(accountService);
await accountService.switchAccount(SomeUser);
const someSettings = { foo: "value" };
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const results: any = [];
const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r));
await accountService.switchAccount(AnotherUser);
await awaitAsync();
sub.unsubscribe();
const [someResult, anotherResult] = results;
expect(someResult).toEqual(someSettings);
expect(anotherResult).toEqual(anotherSettings);
});
it("reads an arbitrary user's settings", async () => { it("reads an arbitrary user's settings", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const anotherSettings = { foo: "another" }; const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable();
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ })); const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ }));
expect(result).toEqual(anotherSettings); expect(result).toEqual(anotherSettings);
}); });
it("follows changes to the arbitrary user", async () => {
const someSettings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const results: any = [];
const sub = generator
.settings$(SomeConfiguration, { userId$ })
.subscribe((r) => results.push(r));
userId.next(AnotherUser);
await awaitAsync();
sub.unsubscribe();
const [someResult, anotherResult] = results;
expect(someResult).toEqual(someSettings);
expect(anotherResult).toEqual(anotherSettings);
});
it("errors when the arbitrary user's stream errors", async () => { it("errors when the arbitrary user's stream errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const account = new BehaviorSubject(accounts[SomeUser]);
const userId$ = userId.asObservable(); const account$ = account.asObservable();
let error = null; let error = null;
generator.settings$(SomeConfiguration, { userId$ }).subscribe({ generator.settings$(SomeConfiguration, { account$ }).subscribe({
error: (e: unknown) => { error: (e: unknown) => {
error = e; error = e;
}, },
}); });
userId.error({ some: "error" }); account.error({ some: "error" });
await awaitAsync(); await awaitAsync();
expect(error).toEqual({ some: "error" }); expect(error).toEqual({ some: "error" });
@@ -970,72 +840,37 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const account = new BehaviorSubject(accounts[SomeUser]);
const userId$ = userId.asObservable(); const account$ = account.asObservable();
let completed = false; let completed = false;
generator.settings$(SomeConfiguration, { userId$ }).subscribe({ generator.settings$(SomeConfiguration, { account$ }).subscribe({
complete: () => { complete: () => {
completed = true; completed = true;
}, },
}); });
userId.complete(); account.complete();
await awaitAsync(); await awaitAsync();
expect(completed).toBeTruthy(); expect(completed).toBeTruthy();
}); });
it("ignores repeated arbitrary user emissions", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
let count = 0;
const sub = generator.settings$(SomeConfiguration, { userId$ }).subscribe({
next: () => {
count++;
},
});
await awaitAsync();
userId.next(SomeUser);
await awaitAsync();
userId.next(SomeUser);
await awaitAsync();
sub.unsubscribe();
expect(count).toEqual(1);
});
}); });
describe("settings", () => { describe("settings", () => {
it("writes to the user's state", async () => { it("writes to the user's state", async () => {
const singleUserId$ = new BehaviorSubject(SomeUser).asObservable();
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); const subject = generator.settings(SomeConfiguration, { account$ });
subject.next({ foo: "next value" }); subject.next({ foo: "next value" });
await awaitAsync(); await awaitAsync();
@@ -1043,52 +878,22 @@ describe("CredentialGeneratorService", () => {
expect(result).toEqual({ expect(result).toEqual({
foo: "next value", foo: "next value",
// FIXME: don't leak this detail into the test
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
}); });
}); });
it("waits for the user to become available", async () => {
const singleUserId = new BehaviorSubject(null);
const singleUserId$ = singleUserId.asObservable();
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
let completed = false;
const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => {
completed = true;
return settings;
});
await awaitAsync();
expect(completed).toBeFalsy();
singleUserId.next(SomeUser);
const result = await promise;
expect(result.userId).toEqual(SomeUser);
});
}); });
describe("policy$", () => { describe("policy$", () => {
it("creates constraints without policy in effect when there is no policy", async () => { it("creates constraints without policy in effect when there is no policy", async () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId$ = new BehaviorSubject(SomeUser).asObservable(); const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); const result = await firstValueFrom(generator.policy$(SomeConfiguration, { account$ }));
expect(result.constraints.policyInEffect).toBeFalsy(); expect(result.constraints.policyInEffect).toBeFalsy();
}); });
@@ -1096,18 +901,16 @@ describe("CredentialGeneratorService", () => {
it("creates constraints with policy in effect when there is a policy", async () => { it("creates constraints with policy in effect when there is a policy", async () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId$ = new BehaviorSubject(SomeUser).asObservable(); const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
const policy$ = new BehaviorSubject([somePolicy]); const policy$ = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValue(policy$); policyService.getAll$.mockReturnValue(policy$);
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); const result = await firstValueFrom(generator.policy$(SomeConfiguration, { account$ }));
expect(result.constraints.policyInEffect).toBeTruthy(); expect(result.constraints.policyInEffect).toBeTruthy();
}); });
@@ -1115,20 +918,18 @@ describe("CredentialGeneratorService", () => {
it("follows policy emissions", async () => { it("follows policy emissions", async () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const account = new BehaviorSubject(accounts[SomeUser]);
const userId$ = userId.asObservable(); const account$ = account.asObservable();
const somePolicySubject = new BehaviorSubject([somePolicy]); const somePolicySubject = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable()); policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable());
const emissions: GeneratorConstraints<SomeSettings>[] = []; const emissions: GeneratorConstraints<SomeSettings>[] = [];
const sub = generator const sub = generator
.policy$(SomeConfiguration, { userId$ }) .policy$(SomeConfiguration, { account$ })
.subscribe((policy) => emissions.push(policy)); .subscribe((policy) => emissions.push(policy));
// swap the active policy for an inactive policy // swap the active policy for an inactive policy
@@ -1144,25 +945,23 @@ describe("CredentialGeneratorService", () => {
it("follows user emissions", async () => { it("follows user emissions", async () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const account = new BehaviorSubject(accounts[SomeUser]);
const userId$ = userId.asObservable(); const account$ = account.asObservable();
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
const anotherPolicy$ = new BehaviorSubject([]).asObservable(); const anotherPolicy$ = new BehaviorSubject([]).asObservable();
policyService.getAll$.mockReturnValueOnce(somePolicy$).mockReturnValueOnce(anotherPolicy$); policyService.getAll$.mockReturnValueOnce(somePolicy$).mockReturnValueOnce(anotherPolicy$);
const emissions: GeneratorConstraints<SomeSettings>[] = []; const emissions: GeneratorConstraints<SomeSettings>[] = [];
const sub = generator const sub = generator
.policy$(SomeConfiguration, { userId$ }) .policy$(SomeConfiguration, { account$ })
.subscribe((policy) => emissions.push(policy)); .subscribe((policy) => emissions.push(policy));
// swapping the user invokes the return for `anotherPolicy$` // swapping the user invokes the return for `anotherPolicy$`
userId.next(AnotherUser); account.next(accounts[AnotherUser]);
await awaitAsync(); await awaitAsync();
sub.unsubscribe(); sub.unsubscribe();
const [someResult, anotherResult] = emissions; const [someResult, anotherResult] = emissions;
@@ -1174,24 +973,22 @@ describe("CredentialGeneratorService", () => {
it("errors when the user errors", async () => { it("errors when the user errors", async () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const account = new BehaviorSubject(accounts[SomeUser]);
const userId$ = userId.asObservable(); const account$ = account.asObservable();
const expectedError = { some: "error" }; const expectedError = { some: "error" };
let actualError: any = null; let actualError: any = null;
generator.policy$(SomeConfiguration, { userId$ }).subscribe({ generator.policy$(SomeConfiguration, { account$ }).subscribe({
error: (e: unknown) => { error: (e: unknown) => {
actualError = e; actualError = e;
}, },
}); });
userId.error(expectedError); account.error(expectedError);
await awaitAsync(); await awaitAsync();
expect(actualError).toEqual(expectedError); expect(actualError).toEqual(expectedError);
@@ -1200,23 +997,21 @@ describe("CredentialGeneratorService", () => {
it("completes when the user completes", async () => { it("completes when the user completes", async () => {
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
randomizer, randomizer,
stateProvider,
policyService, policyService,
apiService, apiService,
i18nService, i18nService,
encryptorProvider, providers,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const account = new BehaviorSubject(accounts[SomeUser]);
const userId$ = userId.asObservable(); const account$ = account.asObservable();
let completed = false; let completed = false;
generator.policy$(SomeConfiguration, { userId$ }).subscribe({ generator.policy$(SomeConfiguration, { account$ }).subscribe({
complete: () => { complete: () => {
completed = true; completed = true;
}, },
}); });
userId.complete(); account.complete();
await awaitAsync(); await awaitAsync();
expect(completed).toBeTruthy(); expect(completed).toBeTruthy();

View File

@@ -1,39 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { import { concatMap, distinctUntilChanged, map, Observable, switchMap, takeUntil } from "rxjs";
BehaviorSubject,
concatMap,
distinctUntilChanged,
endWith,
filter,
firstValueFrom,
ignoreElements,
map,
Observable,
ReplaySubject,
switchMap,
takeUntil,
withLatestFrom,
} from "rxjs";
import { Simplify } from "type-fest"; import { Simplify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state"; import { BoundDependency, OnDependency } from "@bitwarden/common/tools/dependencies";
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
import {
OnDependency,
SingleUserDependency,
UserDependency,
} from "@bitwarden/common/tools/dependencies";
import { IntegrationMetadata } from "@bitwarden/common/tools/integration"; import { IntegrationMetadata } from "@bitwarden/common/tools/integration";
import { RestClient } from "@bitwarden/common/tools/integration/rpc"; import { RestClient } from "@bitwarden/common/tools/integration/rpc";
import { anyComplete, withLatestReady } from "@bitwarden/common/tools/rx"; import { anyComplete, withLatestReady } from "@bitwarden/common/tools/rx";
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
import { UserId } from "@bitwarden/common/types/guid"; import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
import { Randomizer } from "../abstractions"; import { Randomizer } from "../abstractions";
import { import {
@@ -63,23 +43,17 @@ import { GeneratorConstraints } from "../types/generator-constraints";
import { PREFERENCES } from "./credential-preferences"; import { PREFERENCES } from "./credential-preferences";
type Policy$Dependencies = UserDependency; type Generate$Dependencies = Simplify<
type Settings$Dependencies = Partial<UserDependency>; OnDependency<GenerateRequest> & BoundDependency<"account", Account>
type Generate$Dependencies = Simplify<OnDependency<GenerateRequest> & Partial<UserDependency>>; >;
type Algorithms$Dependencies = Partial<UserDependency>;
const OPTIONS_FRAME_SIZE = 512;
export class CredentialGeneratorService { export class CredentialGeneratorService {
constructor( constructor(
private readonly randomizer: Randomizer, private readonly randomizer: Randomizer,
private readonly stateProvider: StateProvider,
private readonly policyService: PolicyService, private readonly policyService: PolicyService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly encryptorProvider: LegacyEncryptorProvider, private readonly providers: UserStateSubjectDependencyProvider,
private readonly accountService: AccountService,
) {} ) {}
private getDependencyProvider(): GeneratorDependencyProvider { private getDependencyProvider(): GeneratorDependencyProvider {
@@ -116,41 +90,34 @@ export class CredentialGeneratorService {
/** Emits metadata concerning the provided generation algorithms /** Emits metadata concerning the provided generation algorithms
* @param category the category or categories of interest * @param category the category or categories of interest
* @param dependences.userId$ when provided, the algorithms are filter to only * @param dependences.account$ algorithms are filtered to only
* those matching the provided user's policy. Otherwise, emits the algorithms * those matching the provided account's policy.
* available to the active user.
* @returns An observable that emits algorithm metadata. * @returns An observable that emits algorithm metadata.
*/ */
algorithms$( algorithms$(
category: CredentialCategory, category: CredentialCategory,
dependencies?: Algorithms$Dependencies, dependencies: BoundDependency<"account", Account>,
): Observable<AlgorithmInfo[]>; ): Observable<AlgorithmInfo[]>;
algorithms$( algorithms$(
category: CredentialCategory[], category: CredentialCategory[],
dependencies?: Algorithms$Dependencies, dependencies: BoundDependency<"account", Account>,
): Observable<AlgorithmInfo[]>; ): Observable<AlgorithmInfo[]>;
algorithms$( algorithms$(
category: CredentialCategory | CredentialCategory[], category: CredentialCategory | CredentialCategory[],
dependencies?: Algorithms$Dependencies, dependencies: BoundDependency<"account", Account>,
) { ) {
// any cast required here because TypeScript fails to bind `category` // any cast required here because TypeScript fails to bind `category`
// to the union-typed overload of `algorithms`. // to the union-typed overload of `algorithms`.
const algorithms = this.algorithms(category as any); const algorithms = this.algorithms(category as any);
// fall back to default bindings
const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$;
// monitor completion
const completion$ = userId$.pipe(ignoreElements(), endWith(true));
// apply policy // apply policy
const algorithms$ = userId$.pipe( const algorithms$ = dependencies.account$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
switchMap((userId) => { switchMap((account) => {
// complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely const policies$ = this.policyService.getAll$(PolicyType.PasswordGenerator, account.id).pipe(
const policies$ = this.policyService.getAll$(PolicyType.PasswordGenerator, userId).pipe(
map((p) => new Set(availableAlgorithms(p))), map((p) => new Set(availableAlgorithms(p))),
takeUntil(completion$), // complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely
takeUntil(anyComplete(dependencies.account$)),
); );
return policies$; return policies$;
}), }),
@@ -234,140 +201,89 @@ export class CredentialGeneratorService {
/** Get the settings for the provided configuration /** Get the settings for the provided configuration
* @param configuration determines which generator's settings are loaded * @param configuration determines which generator's settings are loaded
* @param dependencies.userId$ identifies the user to which the settings are bound. * @param dependencies.account$ identifies the account to which the settings are bound.
* If this parameter is not provided, the observable follows the active user and
* may not complete.
* @returns an observable that emits settings * @returns an observable that emits settings
* @remarks the observable enforces policies on the settings * @remarks the observable enforces policies on the settings
*/ */
settings$<Settings extends object, Policy>( settings$<Settings extends object, Policy>(
configuration: Configuration<Settings, Policy>, configuration: Configuration<Settings, Policy>,
dependencies?: Settings$Dependencies, dependencies: BoundDependency<"account", Account>,
) { ) {
const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$; const constraints$ = this.policy$(configuration, dependencies);
const constraints$ = this.policy$(configuration, { userId$ });
const settings$ = userId$.pipe( const settings = new UserStateSubject(configuration.settings.account, this.providers, {
filter((userId) => !!userId), constraints$,
distinctUntilChanged(), account$: dependencies.account$,
switchMap((userId) => { });
const singleUserId$ = new BehaviorSubject(userId);
const singleUserEncryptor$ = this.encryptorProvider.userEncryptor$(OPTIONS_FRAME_SIZE, {
singleUserId$,
});
const state$ = new UserStateSubject( const settings$ = settings.pipe(
configuration.settings.account,
(key) => this.stateProvider.getUser(userId, key),
{ constraints$, singleUserEncryptor$ },
);
return state$;
}),
map((settings) => settings ?? structuredClone(configuration.settings.initial)), map((settings) => settings ?? structuredClone(configuration.settings.initial)),
takeUntil(anyComplete(userId$)),
); );
return settings$; return settings$;
} }
/** Get a subject bound to credential generator preferences. /** Get a subject bound to credential generator preferences.
* @param dependencies.singleUserId$ identifies the user to which the preferences are bound * @param dependencies.account$ identifies the account to which the preferences are bound
* @returns a promise that resolves with the subject once `dependencies.singleUserId$` * @returns a subject bound to the user's preferences
* becomes available.
* @remarks Preferences determine which algorithms are used when generating a * @remarks Preferences determine which algorithms are used when generating a
* credential from a credential category (e.g. `PassX` or `Username`). Preferences * credential from a credential category (e.g. `PassX` or `Username`). Preferences
* should not be used to hold navigation history. Use @bitwarden/generator-navigation * should not be used to hold navigation history. Use @bitwarden/generator-navigation
* instead. * instead.
*/ */
async preferences( preferences(
dependencies: SingleUserDependency, dependencies: BoundDependency<"account", Account>,
): Promise<UserStateSubject<CredentialPreference>> { ): UserStateSubject<CredentialPreference> {
const singleUserId$ = new ReplaySubject<UserId>(1);
dependencies.singleUserId$
.pipe(
filter((userId) => !!userId),
distinctUntilChanged(),
)
.subscribe(singleUserId$);
const singleUserEncryptor$ = this.encryptorProvider.userEncryptor$(OPTIONS_FRAME_SIZE, {
singleUserId$,
});
const userId = await firstValueFrom(singleUserId$);
// FIXME: enforce policy // FIXME: enforce policy
const subject = new UserStateSubject( const subject = new UserStateSubject(PREFERENCES, this.providers, dependencies);
PREFERENCES,
(key) => this.stateProvider.getUser(userId, key),
{ singleUserEncryptor$ },
);
return subject; return subject;
} }
/** Get a subject bound to a specific user's settings /** Get a subject bound to a specific user's settings
* @param configuration determines which generator's settings are loaded * @param configuration determines which generator's settings are loaded
* @param dependencies.singleUserId$ identifies the user to which the settings are bound * @param dependencies.account$ identifies the account to which the settings are bound
* @returns a promise that resolves with the subject once * @returns a subject bound to the requested user's generator settings
* `dependencies.singleUserId$` becomes available.
* @remarks the subject enforces policy for the settings * @remarks the subject enforces policy for the settings
*/ */
async settings<Settings extends object, Policy>( settings<Settings extends object, Policy>(
configuration: Readonly<Configuration<Settings, Policy>>, configuration: Readonly<Configuration<Settings, Policy>>,
dependencies: SingleUserDependency, dependencies: BoundDependency<"account", Account>,
) { ) {
const singleUserId$ = new ReplaySubject<UserId>(1); const constraints$ = this.policy$(configuration, dependencies);
dependencies.singleUserId$
.pipe( const subject = new UserStateSubject(configuration.settings.account, this.providers, {
filter((userId) => !!userId), constraints$,
distinctUntilChanged(), account$: dependencies.account$,
)
.subscribe(singleUserId$);
const singleUserEncryptor$ = this.encryptorProvider.userEncryptor$(OPTIONS_FRAME_SIZE, {
singleUserId$,
}); });
const userId = await firstValueFrom(singleUserId$);
const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ });
const subject = new UserStateSubject(
configuration.settings.account,
(key) => this.stateProvider.getUser(userId, key),
{ constraints$, singleUserEncryptor$ },
);
return subject; return subject;
} }
/** Get the policy constraints for the provided configuration /** Get the policy constraints for the provided configuration
* @param dependencies.userId$ determines which user's policy is loaded * @param dependencies.account$ determines which user's policy is loaded
* @returns an observable that emits the policy once `dependencies.userId$` * @returns an observable that emits the policy once `dependencies.account$`
* and the policy become available. * and the policy become available.
*/ */
policy$<Settings, Policy>( policy$<Settings, Policy>(
configuration: Configuration<Settings, Policy>, configuration: Configuration<Settings, Policy>,
dependencies: Policy$Dependencies, dependencies: BoundDependency<"account", Account>,
): Observable<GeneratorConstraints<Settings>> { ): Observable<GeneratorConstraints<Settings>> {
const email$ = dependencies.userId$.pipe( const constraints$ = dependencies.account$.pipe(
distinctUntilChanged(), map((account) => {
withLatestFrom(this.accountService.accounts$), if (account.emailVerified) {
filter((accounts) => !!accounts), return { userId: account.id, email: account.email };
map(([userId, accounts]) => {
if (userId in accounts) {
return { userId, email: accounts[userId].email };
} }
return { userId, email: null }; return { userId: account.id, email: null };
}), }),
);
const constraints$ = email$.pipe(
switchMap(({ userId, email }) => { switchMap(({ userId, email }) => {
// complete policy emissions otherwise `switchMap` holds `policies$` open indefinitely // complete policy emissions otherwise `switchMap` holds `policies$` open indefinitely
const policies$ = this.policyService const policies$ = this.policyService
.getAll$(configuration.policy.type, userId) .getAll$(configuration.policy.type, userId)
.pipe( .pipe(
mapPolicyToConstraints(configuration.policy, email), mapPolicyToConstraints(configuration.policy, email),
takeUntil(anyComplete(email$)), takeUntil(anyComplete(dependencies.account$)),
); );
return policies$; return policies$;
}), }),

View File

@@ -12,6 +12,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { pin } from "@bitwarden/common/tools/rx";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { import {
@@ -122,8 +123,11 @@ export class SendOptionsComponent implements OnInit {
generatePassword = async () => { generatePassword = async () => {
const on$ = new BehaviorSubject<GenerateRequest>({ source: "send" }); const on$ = new BehaviorSubject<GenerateRequest>({ source: "send" });
const account$ = this.accountService.activeAccount$.pipe(
pin({ name: () => "send-options.component", distinct: (p, c) => p.id === c.id }),
);
const generatedCredential = await firstValueFrom( const generatedCredential = await firstValueFrom(
this.generatorService.generate$(Generators.password, { on$ }), this.generatorService.generate$(Generators.password, { on$, account$ }),
); );
this.sendOptionsForm.patchValue({ this.sendOptionsForm.patchValue({