From 0ef36d4ca12ad16a21732e88f0028d2e64ba39ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Thu, 6 Mar 2025 09:47:59 -0500 Subject: [PATCH] partial unit test implementation --- .../tools/log/semantic-logger.abstraction.ts | 1 + .../generator/core/src/metadata/index.ts | 8 +- .../generator-metadata-provider.spec.ts | 247 +++++++++++++++++- .../services/generator-metadata-provider.ts | 186 +++++++------ 4 files changed, 336 insertions(+), 106 deletions(-) diff --git a/libs/common/src/tools/log/semantic-logger.abstraction.ts b/libs/common/src/tools/log/semantic-logger.abstraction.ts index 196d1f3f12c..51aaa917378 100644 --- a/libs/common/src/tools/log/semantic-logger.abstraction.ts +++ b/libs/common/src/tools/log/semantic-logger.abstraction.ts @@ -9,6 +9,7 @@ export interface SemanticLogger { */ debug(message: string): void; + // FIXME: replace Jsonify parameter with structural logging schema type /** Logs the content at debug priority. * Debug messages are used for diagnostics, and are typically disabled * in production builds. diff --git a/libs/tools/generator/core/src/metadata/index.ts b/libs/tools/generator/core/src/metadata/index.ts index 5255ddceb87..0c6a54364f7 100644 --- a/libs/tools/generator/core/src/metadata/index.ts +++ b/libs/tools/generator/core/src/metadata/index.ts @@ -1,12 +1,14 @@ -import { AlgorithmsByType as ABT } from "./data"; +import { Algorithm as AlgorithmData, AlgorithmsByType as AlgorithmsByTypeData, Type as TypeData } from "./data"; import { CredentialType, CredentialAlgorithm } from "./type"; // `CredentialAlgorithm` is defined in terms of `ABT`; supplying // type information in the barrel file breaks a circular dependency. /** Credential generation algorithms grouped by purpose. */ -export const AlgorithmsByType: Record> = ABT; +export const AlgorithmsByType: Record> = AlgorithmsByTypeData; +export const Algorithms: ReadonlyArray = Object.freeze(Object.values(AlgorithmData)); +export const Types: ReadonlyArray = Object.freeze(Object.values(TypeData)); -export { Profile, Type } from "./data"; +export { Profile, Type, Algorithm } from "./data"; export { toForwarderMetadata } from "./email/forwarder"; export { GeneratorMetadata } from "./generator-metadata"; export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata"; diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts b/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts index bab5ff244f7..f080cce655c 100644 --- a/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts +++ b/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts @@ -3,18 +3,25 @@ import { BehaviorSubject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec"; import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction"; +import { ExtensionMetadata, ExtensionSite, Site, SiteId, SiteMetadata } from "@bitwarden/common/tools/extension" import { ExtensionService } from "@bitwarden/common/tools/extension/extension.service"; +import { Bitwarden } from "@bitwarden/common/tools/extension/vendor/bitwarden"; import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; import { UserId } from "@bitwarden/common/types/guid"; +import { FakeAccountService, FakeStateProvider } from "../../../../../common/spec"; +import { Algorithm, AlgorithmsByType, Profile, Type, Types } from "../metadata"; +import password from "../metadata/password/random-password"; + import { GeneratorMetadataProvider } from "./generator-metadata-provider"; + const SomeUser = "some user" as UserId; const SomeAccount = { id: SomeUser, @@ -59,35 +66,257 @@ const SystemProvider = { log: disabledSemanticLoggerProvider, } as UserStateSubjectDependencyProvider; +const SomeSiteId: SiteId = Site.forwarder; + +const SomeSite: SiteMetadata = Object.freeze({ + id: SomeSiteId, + availableFields: [], +}); + const ApplicationProvider = { /** Policy configured by the administrative console */ policy: mock(), /** Client extension metadata and profile access */ - extension: mock(), + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map()) + }), /** Event monitoring and diagnostic interfaces */ log: disabledSemanticLoggerProvider, -} - -describe("GeneratorMetadatProvider", () => { - describe("algorithms", () => { +} as SystemServiceProvider; +describe("GeneratorMetadataProvider", () => { + beforeEach(() => { + jest.resetAllMocks(); }); - describe("available$", () => { + describe("metadata", () => { + it("returns algorithm metadata", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [password]); + const metadata = provider.metadata(password.id); + + expect(metadata).toEqual(password); + }); + + it("returns forwarder metadata", async () => { + const extensionMetadata : ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [] + } + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])) + }) + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + const metadata = provider.metadata({ forwarder: Bitwarden.id }); + + expect(metadata.id).toEqual({ forwarder: Bitwarden.id }); + }); + + it("panics when metadata not found", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + expect(() => provider.metadata("not found" as any)).toThrow("metadata not found"); + }); + + it("panics when an extension not found", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + expect(() => provider.metadata({ forwarder: "not found" as any })).toThrow("extension not found"); + }); + }); + + describe("types", () => { + it("returns the credential types", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.types(); + + expect(result).toEqual(expect.arrayContaining(Types)); + }); + }); + + describe("algorithms", () => { + it("returns the password category's algorithms", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ category: Type.password }); + + expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.password])); + }); + + it("returns the username category's algorithms", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ category: Type.username }); + + expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.username])); + }); + + it("returns the email category's algorithms", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ category: Type.email }); + + expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.email])); + }); + + it("includes forwarder vendors in the email category's algorithms", () => { + const extensionMetadata : ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [] + } + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])) + }) + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + const result = provider.algorithms({ category: Type.email }); + + expect(result).toEqual(expect.arrayContaining([{ forwarder: Bitwarden.id }])) + }); + + it.each([ + [Algorithm.catchall], + [Algorithm.passphrase], + [Algorithm.password], + [Algorithm.plusAddress], + [Algorithm.username], + ])("returns explicit algorithms (=%p)", (algorithm) => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ algorithm }); + + expect(result).toEqual([algorithm]); + }); + + it("returns explicit forwarders", () => { + const extensionMetadata : ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [] + } + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])) + }) + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + const result = provider.algorithms({ algorithm: { forwarder: Bitwarden.id } }); + + expect(result).toEqual(expect.arrayContaining([{ forwarder: Bitwarden.id }])) + }); + + it("returns an empty array when the algorithm is invalid", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + // `any` cast required because this test subverts the type system + const result = provider.algorithms({ algorithm: "an invalid algorithm" as any }); + + expect(result).toEqual([]); + }); + + it("returns an empty array when the forwarder is invalid", () => { + const extensionMetadata : ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [] + } + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])) + }) + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + // `any` cast required because this test subverts the type system + const result = provider.algorithms({ algorithm: { forwarder: "an invalid forwarder" as any } }); + + expect(result).toEqual([]) + }); + + it("panics when neither an algorithm nor a category is specified", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + // `any` cast required because this test subverts the type system + expect(() => provider.algorithms({} as any)).toThrow('algorithm or category required'); + }); + }); + + describe("preference$", () => { + it("", async () => { + + }); + + it("", async () => { + + }); + + it("", async () => { + + }); + + it("", async () => { + + }); + + it("", async () => { + + }); + + it("", async () => { + + }); }); describe("algorithm$", () => { + it("", async () => { + }); + + it("", async () => { + + }); + + it("", async () => { + + }); + + it("", async () => { + + }); + + it("", async () => { + + }); + + it("", async () => { + + }); }); describe("preferences", () => { it("returns a user state subject", () => { - const metadata = new GeneratorMetadataProvider(SystemProvider, null, null); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); - const subject = metadata.preferences({ account$: SomeAccount$ }); + const subject = provider.preferences({ account$: SomeAccount$ }); expect(subject).toBeInstanceOf(UserStateSubject); }); diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.ts b/libs/tools/generator/core/src/services/generator-metadata-provider.ts index 987d8482619..9fabbdd7b01 100644 --- a/libs/tools/generator/core/src/services/generator-metadata-provider.ts +++ b/libs/tools/generator/core/src/services/generator-metadata-provider.ts @@ -1,13 +1,11 @@ import { Observable, + combineLatestWith, distinctUntilChanged, - filter, - first, map, shareReplay, switchMap, takeUntil, - withLatestFrom, } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -28,6 +26,8 @@ import { isForwarderExtensionId, toForwarderMetadata, Type, + Algorithms, + Types, } from "../metadata"; import { availableAlgorithms_vNext } from "../policies/available-algorithms-policy"; import { CredentialPreference } from "../types"; @@ -57,18 +57,57 @@ export class GeneratorMetadataProvider { } this.site = site; - this.generators = new Map(algorithms.map((a) => [a.id, a] as const)); + this._metadata = new Map(algorithms.map((a) => [a.id, a] as const)); } private readonly site: ExtensionSite; private readonly log: SemanticLogger; - private generators: Map>; + private _metadata: Map>; - // looks up a set of algorithms; does not enforce policy + /** Retrieve an algorithm's generator metadata + * @param algorithm identifies the algorithm + * @returns the algorithm's generator metadata + * @throws when the algorithm doesn't identify a known metadata entry + */ + metadata(algorithm: CredentialAlgorithm) { + let result = null; + if (isForwarderExtensionId(algorithm)) { + const extension = this.site.extensions.get(algorithm.forwarder); + if (!extension) { + this.log.panic(algorithm, "extension not found"); + } + + result = toForwarderMetadata(extension); + } else { + result = this._metadata.get(algorithm); + } + + if (!result) { + this.log.panic({ algorithm }, "metadata not found"); + } + + return result; + } + + /** retrieve credential types */ + types() : ReadonlyArray { + return Types; + } + + /** Retrieve the credential algorithm ids that match the request. + * @param requested when this has a `category` property, the method + * returns all algorithms in the category. When this has an `algorithm` + * property, the method returns 0 or 1 matching algorithms. + * @returns the matching algorithms. This method always returns an array; + * the array is empty when no algorithms match the input criteria. + * @throws when neither `requested.algorithm` nor `requested.category` contains + * a value. + * @remarks this method enforces technical requirements only. + * If you want these algorithms with policy controls applied, use `algorithms$`. + */ algorithms(requested: AlgorithmRequest): CredentialAlgorithm[]; algorithms(requested: TypeRequest): CredentialAlgorithm[]; - algorithms(requested: MetadataRequest): CredentialAlgorithm[]; algorithms(requested: MetadataRequest): CredentialAlgorithm[] { let algorithms: CredentialAlgorithm[]; if (requested.category) { @@ -78,8 +117,10 @@ export class GeneratorMetadataProvider { } algorithms = AlgorithmsByType[requested.category].concat(forwarders); + } else if (requested.algorithm && isForwarderExtensionId(requested.algorithm)) { + algorithms = this.site.extensions.has(requested.algorithm.forwarder) ? [requested.algorithm] : []; } else if (requested.algorithm) { - algorithms = [requested.algorithm]; + algorithms = Algorithms.includes(requested.algorithm) ? [requested.algorithm] : []; } else { this.log.panic(requested, "algorithm or category required"); } @@ -99,7 +140,8 @@ export class GeneratorMetadataProvider { const available$ = account$.pipe( switchMap((account) => { const policies$ = this.application.policy.getAll$(PolicyType.PasswordGenerator, account.id).pipe( - map((p) => new Set(availableAlgorithms_vNext(p))), + map((p) => availableAlgorithms_vNext(p).filter(a => this._metadata.has(a))), + map((p) => new Set()), // complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely takeUntil(anyComplete(account$)), ); @@ -111,118 +153,74 @@ export class GeneratorMetadataProvider { return available$; } - // looks up a set of algorithms; enforces policy - emits empty list when there's no algorithm available - available$( + /** Retrieve credential algorithms filtered by the user's active policy. + * @param requested when this has a `category` property, the method + * returns all algorithms in the category. When this has an `algorithm` + * property, the method returns 0 or 1 matching algorithms. + * @param dependencies.account the account requesting algorithm access; + * this parameter controls which policy, if any, is applied. + * @returns an observable that emits matching algorithms. When no algorithms + * match the request, an empty array is emitted. + * @throws when neither `requested.algorithm` nor `requested.category` contains + * a value. + * @remarks this method applies policy controls. In particular, it excludes + * algorithms prohibited by a policy control. If you want lists of algorithms + * supported by the client, use `algorithms`. + */ + algorithms$( requested: AlgorithmRequest, dependencies: BoundDependency<"account", Account>, - ): Observable[]>; - available$( + ): Observable; + algorithms$( requested: TypeRequest, dependencies: BoundDependency<"account", Account>, - ): Observable[]>; - available$( + ): Observable; + algorithms$( requested: MetadataRequest, dependencies: BoundDependency<"account", Account>, - ): Observable[]> { - let available$: Observable; + ): Observable { if (requested.category) { const { category } = requested; - available$ = this.isAvailable$(dependencies).pipe( + return this.isAvailable$(dependencies).pipe( map((isAvailable) => AlgorithmsByType[category].filter(isAvailable)), ); } else if (requested.algorithm) { const { algorithm } = requested; - available$ = this.isAvailable$(dependencies).pipe( + return this.isAvailable$(dependencies).pipe( map((isAvailable) => (isAvailable(algorithm) ? [algorithm] : [])), ); } else { this.log.panic(requested, "algorithm or category required"); } - - const result$ = available$.pipe( - map((available) => available.map((algorithm) => this.getMetadata(algorithm))), - ); - - return result$; } - // looks up a specific algorithm; enforces policy - observable completes without emission when there's no algorithm available. - algorithm$( - requested: AlgorithmRequest, - dependencies: BoundDependency<"account", Account>, - ): Observable>; - algorithm$( - requested: TypeRequest, - dependencies: BoundDependency<"account", Account>, - ): Observable>; - algorithm$( - requested: MetadataRequest, - dependencies: BoundDependency<"account", Account>, - ): Observable> { + preference$(credentialType: CredentialType, dependencies: BoundDependency<"account", Account>) { const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); - let algorithm$: Observable; - if (requested.category) { - this.log.debug(requested, "retrieving algorithm metadata by category"); - - const { category } = requested; - algorithm$ = this.preferences({ account$ }).pipe( - withLatestFrom(this.isAvailable$({ account$ })), - map(([preferences, isAvailable]) => { - let algorithm: CredentialAlgorithm | undefined = preferences[category].algorithm; - if (isAvailable(algorithm)) { - return algorithm; - } - - const algorithms = AlgorithmsByType[category]; - algorithm = algorithms.find(isAvailable)!; - this.log.debug( - { algorithm, category }, - "preference not available; defaulting the generator algorithm", - ); - + const algorithm$ = this.preferences({ account$ }).pipe( + combineLatestWith(this.isAvailable$({ account$ })), + map(([preferences, isAvailable]) => { + const algorithm: CredentialAlgorithm = preferences[credentialType].algorithm; + if (isAvailable(algorithm)) { return algorithm; - }), - ); - } else if (requested.algorithm) { - this.log.debug(requested, "retrieving algorithm metadata by algorithm"); + } - const { algorithm } = requested; - algorithm$ = this.isAvailable$({ account$ }).pipe( - map((isAvailable) => (isAvailable(algorithm) ? algorithm : undefined)), - first(), - ); - } else { - this.log.panic(requested, "algorithm or category required"); - } + const algorithms = AlgorithmsByType[credentialType]; + // `?? null` because logging types must be `Jsonify` + const defaultAlgorithm = algorithms.find(isAvailable) ?? null; + this.log.debug( + { algorithm, defaultAlgorithm, credentialType }, + "preference not available; defaulting the generator algorithm", + ); - const result$ = algorithm$.pipe( - filter((value) => !!value), - map((algorithm) => this.getMetadata(algorithm!)), + // `?? undefined` so that interface is ADR-14 compliant + return defaultAlgorithm ?? undefined; + }), + distinctUntilChanged() ); - return result$; - } - - private getMetadata(algorithm: CredentialAlgorithm) { - let result = null; - if (isForwarderExtensionId(algorithm)) { - const extension = this.site.extensions.get(algorithm.forwarder); - if (!extension) { - this.log.panic(algorithm, "extension not found"); - } - - result = toForwarderMetadata(extension); - } else { - result = this.generators.get(algorithm); - } - - if (!result) { - this.log.panic({ algorithm }, "failed to load metadata"); - } - - return result; + return algorithm$; } /** Get a subject bound to credential generator preferences.