mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-28 12:45:45 +01:00
[PM-5606] Add reactive generator service (#7446)
This commit is contained in:
parent
0de72144b9
commit
dbf836b573
@ -1,3 +1,5 @@
|
||||
export * from "./utils";
|
||||
export * from "./intercept-console";
|
||||
export * from "./matchers";
|
||||
export * from "./fake-state-provider";
|
||||
export * from "./fake-account-service";
|
||||
|
@ -4,6 +4,7 @@ export enum FeatureFlag {
|
||||
ItemShare = "item-share",
|
||||
FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional
|
||||
BulkCollectionAccess = "bulk-collection-access",
|
||||
GeneratorToolsModernization = "generator-tools-modernization",
|
||||
KeyRotationImprovements = "key-rotation-improvements",
|
||||
FlexibleCollectionsMigration = "flexible-collections-migration",
|
||||
}
|
||||
|
@ -19,6 +19,9 @@ import { StateDefinition } from "./state-definition";
|
||||
|
||||
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||
|
||||
export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk");
|
||||
|
||||
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
||||
|
||||
export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk");
|
||||
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
|
||||
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy as AdminPolicy } from "../../../admin-console/models/domain/policy";
|
||||
import { KeyDefinition } from "../../../platform/state";
|
||||
|
||||
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
|
||||
|
||||
/** Tailors the generator service to generate a specific kind of credentials */
|
||||
export abstract class GeneratorStrategy<Options, Policy> {
|
||||
/** The key used when storing credentials on disk. */
|
||||
disk: KeyDefinition<Options>;
|
||||
|
||||
/** Identifies the policy enforced by the generator. */
|
||||
policy: PolicyType;
|
||||
|
||||
/** Length of time in milliseconds to cache the evaluator */
|
||||
cache_ms: number;
|
||||
|
||||
/** Creates an evaluator from a generator policy.
|
||||
* @param policy The policy being evaluated.
|
||||
* @returns the policy evaluator.
|
||||
* @throws when the policy's type does not match the generator's policy type.
|
||||
*/
|
||||
evaluator: (policy: AdminPolicy) => PolicyEvaluator<Policy, Options>;
|
||||
|
||||
/** Generates credentials from the given options.
|
||||
* @param options The options used to generate the credentials.
|
||||
* @returns a promise that resolves to the generated credentials.
|
||||
*/
|
||||
generate: (options: Options) => Promise<string>;
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
|
||||
|
||||
/** Generates credentials used for user authentication
|
||||
* @typeParam Options the credential generation configuration
|
||||
* @typeParam Policy the policy enforced by the generator
|
||||
*/
|
||||
export abstract class GeneratorService<Options, Policy> {
|
||||
/** An observable monitoring the options saved to disk.
|
||||
* The observable updates when the options are saved.
|
||||
*/
|
||||
options$: Observable<Options>;
|
||||
|
||||
/** An observable monitoring the options used to enforce policy.
|
||||
* The observable updates when the policy changes.
|
||||
*/
|
||||
policy$: Observable<PolicyEvaluator<Policy, Options>>;
|
||||
|
||||
/** Enforces the policy on the given options
|
||||
* @param options the options to enforce the policy on
|
||||
* @returns a new instance of the options with the policy enforced
|
||||
*/
|
||||
enforcePolicy: (options: Options) => Promise<Options>;
|
||||
|
||||
/** Generates credentials
|
||||
* @param options the options to generate credentials with
|
||||
* @returns a promise that resolves with the generated credentials
|
||||
*/
|
||||
generate: (options: Options) => Promise<string>;
|
||||
|
||||
/** Saves the given options to disk.
|
||||
* @param options the options to save
|
||||
* @returns a promise that resolves when the options are saved
|
||||
*/
|
||||
saveOptions: (options: Options) => Promise<void>;
|
||||
}
|
3
libs/common/src/tools/generator/abstractions/index.ts
Normal file
3
libs/common/src/tools/generator/abstractions/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { GeneratorService } from "./generator.service.abstraction";
|
||||
export { GeneratorStrategy } from "./generator-strategy.abstraction";
|
||||
export { PolicyEvaluator } from "./policy-evaluator.abstraction";
|
@ -0,0 +1,28 @@
|
||||
/** Applies policy to a generation request */
|
||||
export abstract class PolicyEvaluator<Policy, PolicyTarget> {
|
||||
/** The policy to enforce */
|
||||
policy: Policy;
|
||||
|
||||
/** Returns true when a policy is being enforced by the evaluator.
|
||||
* @remarks `applyPolicy` should be called when a policy is not in
|
||||
* effect to enforce the application's default policy.
|
||||
*/
|
||||
policyInEffect: boolean;
|
||||
|
||||
/** Apply policy to a set of options.
|
||||
* @param options The options to build from. These options are not altered.
|
||||
* @returns A complete generation request with policy applied.
|
||||
* @remarks This method only applies policy overrides.
|
||||
* Pass the result to `sanitize` to ensure consistency.
|
||||
*/
|
||||
applyPolicy: (options: PolicyTarget) => PolicyTarget;
|
||||
|
||||
/** Ensures internal options consistency.
|
||||
* @param options The options to cascade. These options are not altered.
|
||||
* @returns A new generation request with cascade applied.
|
||||
* @remarks This method fills null and undefined values by looking at
|
||||
* pairs of flags and values (e.g. `number` and `minNumber`). If the flag
|
||||
* and value are inconsistent, the flag cascades to the value.
|
||||
*/
|
||||
sanitize: (options: PolicyTarget) => PolicyTarget;
|
||||
}
|
@ -0,0 +1,192 @@
|
||||
/**
|
||||
* include structuredClone in test environment.
|
||||
* @jest-environment ../../../../shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeActiveUserStateProvider, mockAccountServiceWith } from "../../../spec";
|
||||
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "../../admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "../../admin-console/models/domain/policy";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { ActiveUserState, ActiveUserStateProvider, KeyDefinition } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { GeneratorStrategy, PolicyEvaluator } from "./abstractions";
|
||||
import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "./key-definitions";
|
||||
import { PasswordGenerationOptions } from "./password";
|
||||
|
||||
import { DefaultGeneratorService } from ".";
|
||||
|
||||
function mockPolicyService(config?: { data?: any; policy?: BehaviorSubject<Policy> }) {
|
||||
const state = mock<Policy>({ data: config?.data ?? {} });
|
||||
const subject = config?.policy ?? new BehaviorSubject<Policy>(state);
|
||||
|
||||
const service = mock<PolicyService>();
|
||||
service.get$.mockReturnValue(subject.asObservable());
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
function mockGeneratorStrategy(config?: {
|
||||
disk?: KeyDefinition<any>;
|
||||
policy?: PolicyType;
|
||||
evaluator?: any;
|
||||
}) {
|
||||
const strategy = mock<GeneratorStrategy<any, any>>({
|
||||
// intentionally arbitrary so that tests that need to check
|
||||
// whether they're used properly are guaranteed to test
|
||||
// the value from `config`.
|
||||
disk: config?.disk ?? {},
|
||||
policy: config?.policy ?? PolicyType.DisableSend,
|
||||
evaluator: jest.fn(() => config?.evaluator ?? mock<PolicyEvaluator<any, any>>()),
|
||||
});
|
||||
|
||||
return strategy;
|
||||
}
|
||||
|
||||
// FIXME: Use the fake instead, once it's updated to monitor its method calls.
|
||||
function mockStateProvider(): [
|
||||
ActiveUserStateProvider,
|
||||
ActiveUserState<PasswordGenerationOptions>,
|
||||
] {
|
||||
const state = mock<ActiveUserState<PasswordGenerationOptions>>();
|
||||
const provider = mock<ActiveUserStateProvider>();
|
||||
provider.get.mockReturnValue(state);
|
||||
|
||||
return [provider, state];
|
||||
}
|
||||
|
||||
function fakeStateProvider(key: KeyDefinition<any>, initalValue: any): FakeActiveUserStateProvider {
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const acctService = mockAccountServiceWith(userId);
|
||||
const provider = new FakeActiveUserStateProvider(acctService);
|
||||
provider.mockFor(key.key, initalValue);
|
||||
return provider;
|
||||
}
|
||||
|
||||
describe("Password generator service", () => {
|
||||
describe("constructor()", () => {
|
||||
it("should initialize the password generator policy", () => {
|
||||
const policy = mockPolicyService();
|
||||
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
|
||||
|
||||
new DefaultGeneratorService(strategy, policy, null);
|
||||
|
||||
expect(policy.get$).toHaveBeenCalledWith(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("options$", () => {
|
||||
it("should return the state from strategy.key", () => {
|
||||
const policy = mockPolicyService();
|
||||
const strategy = mockGeneratorStrategy({ disk: PASSPHRASE_SETTINGS });
|
||||
const [state] = mockStateProvider();
|
||||
const service = new DefaultGeneratorService(strategy, policy, state);
|
||||
|
||||
// invoke the getter. It returns the state but that's not important.
|
||||
service.options$;
|
||||
|
||||
expect(state.get).toHaveBeenCalledWith(PASSPHRASE_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveOptions()", () => {
|
||||
it("should update the state at strategy.key", async () => {
|
||||
const policy = mockPolicyService();
|
||||
const [provider, state] = mockStateProvider();
|
||||
const strategy = mockGeneratorStrategy({ disk: PASSWORD_SETTINGS });
|
||||
const service = new DefaultGeneratorService(strategy, policy, provider);
|
||||
|
||||
await service.saveOptions({});
|
||||
|
||||
expect(provider.get).toHaveBeenCalledWith(PASSWORD_SETTINGS);
|
||||
expect(state.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should trigger an options$ update", async () => {
|
||||
const policy = mockPolicyService();
|
||||
const strategy = mockGeneratorStrategy();
|
||||
// using the fake here because we're testing that the update and the
|
||||
// property are wired together. If we were to mock that, we'd be testing
|
||||
// the mock configuration instead of the wiring.
|
||||
const provider = fakeStateProvider(strategy.disk, { length: 9 });
|
||||
const service = new DefaultGeneratorService(strategy, policy, provider);
|
||||
|
||||
await service.saveOptions({ length: 10 });
|
||||
|
||||
const options = await firstValueFrom(service.options$);
|
||||
expect(options).toEqual({ length: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy$", () => {
|
||||
it("should map the policy using the generation strategy", async () => {
|
||||
const policyService = mockPolicyService();
|
||||
const evaluator = mock<PolicyEvaluator<any, any>>();
|
||||
const strategy = mockGeneratorStrategy({ evaluator });
|
||||
|
||||
const service = new DefaultGeneratorService(strategy, policyService, null);
|
||||
|
||||
const policy = await firstValueFrom(service.policy$);
|
||||
|
||||
expect(policy).toBe(evaluator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforcePolicy()", () => {
|
||||
describe("should load the policy", () => {
|
||||
it("from the cache by default", async () => {
|
||||
const policy = mockPolicyService();
|
||||
const strategy = mockGeneratorStrategy();
|
||||
const service = new DefaultGeneratorService(strategy, policy, null);
|
||||
|
||||
await service.enforcePolicy({});
|
||||
await service.enforcePolicy({});
|
||||
|
||||
expect(strategy.evaluator).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("from the policy service when the policy changes", async () => {
|
||||
const policy = new BehaviorSubject<Policy>(mock<Policy>({ data: {} }));
|
||||
const policyService = mockPolicyService({ policy });
|
||||
const strategy = mockGeneratorStrategy();
|
||||
const service = new DefaultGeneratorService(strategy, policyService, null);
|
||||
|
||||
await service.enforcePolicy({});
|
||||
policy.next(mock<Policy>({ data: { some: "change" } }));
|
||||
await service.enforcePolicy({});
|
||||
|
||||
expect(strategy.evaluator).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should evaluate the policy using the generation strategy", async () => {
|
||||
const policy = mockPolicyService();
|
||||
const evaluator = mock<PolicyEvaluator<any, any>>();
|
||||
const strategy = mockGeneratorStrategy({ evaluator });
|
||||
const service = new DefaultGeneratorService(strategy, policy, null);
|
||||
|
||||
await service.enforcePolicy({});
|
||||
|
||||
expect(evaluator.applyPolicy).toHaveBeenCalled();
|
||||
expect(evaluator.sanitize).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it("should invoke the generation strategy", async () => {
|
||||
const strategy = mockGeneratorStrategy();
|
||||
const policy = mockPolicyService();
|
||||
const service = new DefaultGeneratorService(strategy, policy, null);
|
||||
|
||||
await service.generate({});
|
||||
|
||||
expect(strategy.generate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
64
libs/common/src/tools/generator/default-generator.service.ts
Normal file
64
libs/common/src/tools/generator/default-generator.service.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { firstValueFrom, map, share, timer, ReplaySubject, Observable } from "rxjs";
|
||||
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { ActiveUserStateProvider } from "../../platform/state";
|
||||
|
||||
import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "./abstractions";
|
||||
|
||||
/** {@link GeneratorServiceAbstraction} */
|
||||
export class DefaultGeneratorService<Options, Policy> implements GeneratorService<Options, Policy> {
|
||||
/** Instantiates the generator service
|
||||
* @param strategy tailors the service to a specific generator type
|
||||
* (e.g. password, passphrase)
|
||||
* @param policy provides the policy to enforce
|
||||
* @param state saves and loads password generation options to the location
|
||||
* specified by the strategy
|
||||
*/
|
||||
constructor(
|
||||
private strategy: GeneratorStrategy<Options, Policy>,
|
||||
private policy: PolicyService,
|
||||
private state: ActiveUserStateProvider,
|
||||
) {
|
||||
this._policy$ = this.policy.get$(this.strategy.policy).pipe(
|
||||
map((policy) => this.strategy.evaluator(policy)),
|
||||
share({
|
||||
// cache evaluator in a replay subject to amortize creation cost
|
||||
// and reduce GC pressure.
|
||||
connector: () => new ReplaySubject(1),
|
||||
resetOnRefCountZero: () => timer(this.strategy.cache_ms),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _policy$: Observable<PolicyEvaluator<Policy, Options>>;
|
||||
|
||||
/** {@link GeneratorService.options$} */
|
||||
get options$() {
|
||||
return this.state.get(this.strategy.disk).state$;
|
||||
}
|
||||
|
||||
/** {@link GeneratorService.saveOptions} */
|
||||
async saveOptions(options: Options): Promise<void> {
|
||||
await this.state.get(this.strategy.disk).update(() => options);
|
||||
}
|
||||
|
||||
/** {@link GeneratorService.policy$} */
|
||||
get policy$() {
|
||||
return this._policy$;
|
||||
}
|
||||
|
||||
/** {@link GeneratorService.enforcePolicy} */
|
||||
async enforcePolicy(options: Options): Promise<Options> {
|
||||
const policy = await firstValueFrom(this._policy$);
|
||||
const evaluated = policy.applyPolicy(options);
|
||||
const sanitized = policy.sanitize(evaluated);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/** {@link GeneratorService.generate} */
|
||||
async generate(options: Options): Promise<string> {
|
||||
return await this.strategy.generate(options);
|
||||
}
|
||||
}
|
4
libs/common/src/tools/generator/index.ts
Normal file
4
libs/common/src/tools/generator/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./abstractions/index";
|
||||
export * from "./password/index";
|
||||
|
||||
export { DefaultGeneratorService } from "./default-generator.service";
|
49
libs/common/src/tools/generator/key-definition.spec.ts
Normal file
49
libs/common/src/tools/generator/key-definition.spec.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import {
|
||||
ENCRYPTED_HISTORY,
|
||||
ENCRYPTED_USERNAME_SETTINGS,
|
||||
PASSPHRASE_SETTINGS,
|
||||
PASSWORD_SETTINGS,
|
||||
PLAINTEXT_USERNAME_SETTINGS,
|
||||
} from "./key-definitions";
|
||||
|
||||
describe("Key definitions", () => {
|
||||
describe("PASSWORD_SETTINGS", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = {};
|
||||
const result = PASSWORD_SETTINGS.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PASSPHRASE_SETTINGS", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = {};
|
||||
const result = PASSPHRASE_SETTINGS.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ENCRYPTED_USERNAME_SETTINGS", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = {};
|
||||
const result = ENCRYPTED_USERNAME_SETTINGS.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PLAINTEXT_USERNAME_SETTINGS", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = {};
|
||||
const result = PLAINTEXT_USERNAME_SETTINGS.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ENCRYPTED_HISTORY", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = {};
|
||||
const result = ENCRYPTED_HISTORY.deserializer(value as any);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
});
|
49
libs/common/src/tools/generator/key-definitions.ts
Normal file
49
libs/common/src/tools/generator/key-definitions.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { GENERATOR_DISK, GENERATOR_MEMORY, KeyDefinition } from "../../platform/state";
|
||||
|
||||
import { GeneratedPasswordHistory } from "./password/generated-password-history";
|
||||
import { PasswordGenerationOptions } from "./password/password-generation-options";
|
||||
|
||||
/** plaintext password generation options */
|
||||
export const PASSWORD_SETTINGS = new KeyDefinition<PasswordGenerationOptions>(
|
||||
GENERATOR_DISK,
|
||||
"passwordGeneratorSettings",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
},
|
||||
);
|
||||
|
||||
/** plaintext passphrase generation options */
|
||||
export const PASSPHRASE_SETTINGS = new KeyDefinition<PasswordGenerationOptions>(
|
||||
GENERATOR_DISK,
|
||||
"passphraseGeneratorSettings",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
},
|
||||
);
|
||||
|
||||
/** plaintext username generation options */
|
||||
export const ENCRYPTED_USERNAME_SETTINGS = new KeyDefinition<PasswordGenerationOptions>(
|
||||
GENERATOR_DISK,
|
||||
"usernameGeneratorSettings",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
},
|
||||
);
|
||||
|
||||
/** plaintext username generation options */
|
||||
export const PLAINTEXT_USERNAME_SETTINGS = new KeyDefinition<PasswordGenerationOptions>(
|
||||
GENERATOR_MEMORY,
|
||||
"usernameGeneratorSettings",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
},
|
||||
);
|
||||
|
||||
/** encrypted password generation history */
|
||||
export const ENCRYPTED_HISTORY = new KeyDefinition<GeneratedPasswordHistory>(
|
||||
GENERATOR_DISK,
|
||||
"passwordGeneratorHistory",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
},
|
||||
);
|
@ -1,3 +1,10 @@
|
||||
// password generator "v2" interfaces
|
||||
export * from "./password-generation-options";
|
||||
export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
|
||||
export { PasswordGeneratorPolicy } from "./password-generator-policy";
|
||||
export { PasswordGeneratorStrategy } from "./password-generator-strategy";
|
||||
|
||||
// legacy interfaces
|
||||
export { PasswordGeneratorOptions } from "./password-generator-options";
|
||||
export { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
|
||||
export { PasswordGenerationService } from "./password-generation.service";
|
||||
|
@ -0,0 +1,83 @@
|
||||
import { DefaultBoundaries } from "./password-generator-options-evaluator";
|
||||
|
||||
/** Request format for password credential generation.
|
||||
* All members of this type may be `undefined` when the user is
|
||||
* generating a passphrase.
|
||||
*
|
||||
* @remarks The name of this type is a bit of a misnomer. This type
|
||||
* it is used with the "password generator" types. The name
|
||||
* `PasswordGeneratorOptions` is already in use by legacy code.
|
||||
*/
|
||||
export type PasswordGenerationOptions = {
|
||||
/** The length of the password selected by the user */
|
||||
length?: number;
|
||||
|
||||
/** The minimum length of the password. This defaults to 5, and increases
|
||||
* to ensure `minLength` is at least as large as the sum of the other minimums.
|
||||
*/
|
||||
minLength?: number;
|
||||
|
||||
/** `true` when ambiguous characters may be included in the output.
|
||||
* `false` when ambiguous characters should not be included in the output.
|
||||
*/
|
||||
ambiguous?: boolean;
|
||||
|
||||
/** `true` when uppercase ASCII characters should be included in the output
|
||||
* This value defaults to `false.
|
||||
*/
|
||||
uppercase?: boolean;
|
||||
|
||||
/** The minimum number of uppercase characters to include in the output.
|
||||
* The value is ignored when `uppercase` is `false`.
|
||||
* The value defaults to 1 when `uppercase` is `true`.
|
||||
*/
|
||||
minUppercase?: number;
|
||||
|
||||
/** `true` when lowercase ASCII characters should be included in the output.
|
||||
* This value defaults to `false`.
|
||||
*/
|
||||
lowercase?: boolean;
|
||||
|
||||
/** The minimum number of lowercase characters to include in the output.
|
||||
* The value defaults to 1 when `lowercase` is `true`.
|
||||
* The value defaults to 0 when `lowercase` is `false`.
|
||||
*/
|
||||
minLowercase?: number;
|
||||
|
||||
/** Whether or not to include ASCII digits in the output
|
||||
* This value defaults to `true` when `minNumber` is at least 1.
|
||||
* This value defaults to `false` when `minNumber` is less than 1.
|
||||
*/
|
||||
number?: boolean;
|
||||
|
||||
/** The minimum number of digits to include in the output.
|
||||
* The value defaults to 1 when `number` is `true`.
|
||||
* The value defaults to 0 when `number` is `false`.
|
||||
*/
|
||||
minNumber?: number;
|
||||
|
||||
/** Whether or not to include special characters in the output.
|
||||
* This value defaults to `true` when `minSpecial` is at least 1.
|
||||
* This value defaults to `false` when `minSpecial` is less than 1.
|
||||
*/
|
||||
special?: boolean;
|
||||
|
||||
/** The minimum number of special characters to include in the output.
|
||||
* This value defaults to 1 when `special` is `true`.
|
||||
* This value defaults to 0 when `special` is `false`.
|
||||
*/
|
||||
minSpecial?: number;
|
||||
};
|
||||
|
||||
/** The default options for password generation. */
|
||||
export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions> = Object.freeze({
|
||||
length: 14,
|
||||
minLength: DefaultBoundaries.length.min,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
minNumber: 1,
|
||||
special: true,
|
||||
minSpecial: 1,
|
||||
});
|
@ -1,17 +1,19 @@
|
||||
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
|
||||
/**
|
||||
* include structuredClone in test environment.
|
||||
* @jest-environment ../../../../shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { PasswordGenerationOptions } from "./password-generator-options";
|
||||
import {
|
||||
DefaultBoundaries,
|
||||
PasswordGeneratorOptionsEvaluator,
|
||||
} from "./password-generator-options-evaluator";
|
||||
import { DefaultBoundaries } from "./password-generator-options-evaluator";
|
||||
import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy";
|
||||
|
||||
import { PasswordGenerationOptions, PasswordGeneratorOptionsEvaluator } from ".";
|
||||
|
||||
describe("Password generator options builder", () => {
|
||||
const defaultOptions = Object.freeze({ minLength: 0 });
|
||||
|
||||
describe("constructor()", () => {
|
||||
it("should set the policy object to a copy of the input policy", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.minLength = 10; // arbitrary change for deep equality check
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
@ -21,7 +23,7 @@ describe("Password generator options builder", () => {
|
||||
});
|
||||
|
||||
it("should set default boundaries when a default policy is used", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
@ -35,7 +37,7 @@ describe("Password generator options builder", () => {
|
||||
(minLength) => {
|
||||
expect(minLength).toBeLessThan(DefaultBoundaries.length.min);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.minLength = minLength;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
@ -50,7 +52,7 @@ describe("Password generator options builder", () => {
|
||||
expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.min);
|
||||
expect(expectedLength).toBeLessThanOrEqual(DefaultBoundaries.length.max);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.minLength = expectedLength;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
@ -65,7 +67,7 @@ describe("Password generator options builder", () => {
|
||||
(expectedLength) => {
|
||||
expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.max);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.minLength = expectedLength;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
@ -81,7 +83,7 @@ describe("Password generator options builder", () => {
|
||||
expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.min);
|
||||
expect(expectedMinDigits).toBeLessThanOrEqual(DefaultBoundaries.minDigits.max);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.numberCount = expectedMinDigits;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
@ -96,7 +98,7 @@ describe("Password generator options builder", () => {
|
||||
(expectedMinDigits) => {
|
||||
expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.max);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.numberCount = expectedMinDigits;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
@ -116,7 +118,7 @@ describe("Password generator options builder", () => {
|
||||
DefaultBoundaries.minSpecialCharacters.max,
|
||||
);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.specialCount = expectedSpecialCharacters;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
@ -135,7 +137,7 @@ describe("Password generator options builder", () => {
|
||||
DefaultBoundaries.minSpecialCharacters.max,
|
||||
);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.specialCount = expectedSpecialCharacters;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
@ -154,7 +156,7 @@ describe("Password generator options builder", () => {
|
||||
(expectedLength, numberCount, specialCount) => {
|
||||
expect(expectedLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.numberCount = numberCount;
|
||||
policy.specialCount = specialCount;
|
||||
|
||||
@ -165,6 +167,71 @@ describe("Password generator options builder", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("policyInEffect", () => {
|
||||
it("should return false when the policy has no effect", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return true when the policy has a minlength greater than the default boundary", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.minLength = DefaultBoundaries.length.min + 1;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has a number count greater than the default boundary", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.numberCount = DefaultBoundaries.minDigits.min + 1;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has a special character count greater than the default boundary", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.specialCount = DefaultBoundaries.minSpecialCharacters.min + 1;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has uppercase enabled", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useUppercase = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has lowercase enabled", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useLowercase = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has numbers enabled", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useNumbers = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has special characters enabled", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useSpecial = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPolicy(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
|
||||
@ -175,7 +242,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'",
|
||||
(expectedUppercase, uppercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useUppercase = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, uppercase });
|
||||
@ -189,7 +256,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true",
|
||||
(uppercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useUppercase = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, uppercase });
|
||||
@ -207,7 +274,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'",
|
||||
(expectedLowercase, lowercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useLowercase = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, lowercase });
|
||||
@ -221,7 +288,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true",
|
||||
(lowercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useLowercase = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, lowercase });
|
||||
@ -239,7 +306,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'",
|
||||
(expectedNumber, number) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useNumbers = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number });
|
||||
@ -253,7 +320,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.number` (= %s) to true when `policy.useNumbers` is true",
|
||||
(number) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useNumbers = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number });
|
||||
@ -271,7 +338,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'",
|
||||
(expectedSpecial, special) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useSpecial = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special });
|
||||
@ -285,7 +352,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.special` (= %s) to true when `policy.useSpecial` is true",
|
||||
(special) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useSpecial = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special });
|
||||
@ -299,7 +366,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([1, 2, 3, 4])(
|
||||
"should set `options.length` (= %i) to the minimum it is less than the minimum length",
|
||||
(length) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(length).toBeLessThan(builder.length.min);
|
||||
|
||||
@ -314,7 +381,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([5, 10, 50, 100, 128])(
|
||||
"should not change `options.length` (= %i) when it is within the boundaries",
|
||||
(length) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(length).toBeGreaterThanOrEqual(builder.length.min);
|
||||
expect(length).toBeLessThanOrEqual(builder.length.max);
|
||||
@ -330,7 +397,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([129, 500, 9000])(
|
||||
"should set `options.length` (= %i) to the maximum length when it is exceeded",
|
||||
(length) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(length).toBeGreaterThan(builder.length.max);
|
||||
|
||||
@ -352,7 +419,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0",
|
||||
(expectedNumber, minNumber) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, minNumber });
|
||||
|
||||
@ -363,7 +430,7 @@ describe("Password generator options builder", () => {
|
||||
);
|
||||
|
||||
it("should set `options.minNumber` to the minimum value when `options.number` is true", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number: true });
|
||||
|
||||
@ -373,7 +440,7 @@ describe("Password generator options builder", () => {
|
||||
});
|
||||
|
||||
it("should set `options.minNumber` to 0 when `options.number` is false", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number: false });
|
||||
|
||||
@ -385,7 +452,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([1, 2, 3, 4])(
|
||||
"should set `options.minNumber` (= %i) to the minimum it is less than the minimum number",
|
||||
(minNumber) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.numberCount = 5; // arbitrary value greater than minNumber
|
||||
expect(minNumber).toBeLessThan(policy.numberCount);
|
||||
|
||||
@ -401,7 +468,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([1, 3, 5, 7, 9])(
|
||||
"should not change `options.minNumber` (= %i) when it is within the boundaries",
|
||||
(minNumber) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min);
|
||||
expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max);
|
||||
@ -417,7 +484,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([10, 20, 400])(
|
||||
"should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded",
|
||||
(minNumber) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minNumber).toBeGreaterThan(builder.minDigits.max);
|
||||
|
||||
@ -439,7 +506,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0",
|
||||
(expectedSpecial, minSpecial) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, minSpecial });
|
||||
|
||||
@ -450,7 +517,7 @@ describe("Password generator options builder", () => {
|
||||
);
|
||||
|
||||
it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special: true });
|
||||
|
||||
@ -460,7 +527,7 @@ describe("Password generator options builder", () => {
|
||||
});
|
||||
|
||||
it("should set `options.minSpecial` to 0 when `options.special` is false", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special: false });
|
||||
|
||||
@ -472,7 +539,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([1, 2, 3, 4])(
|
||||
"should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters",
|
||||
(minSpecial) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.specialCount = 5; // arbitrary value greater than minSpecial
|
||||
expect(minSpecial).toBeLessThan(policy.specialCount);
|
||||
|
||||
@ -488,7 +555,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([1, 3, 5, 7, 9])(
|
||||
"should not change `options.minSpecial` (= %i) when it is within the boundaries",
|
||||
(minSpecial) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min);
|
||||
expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max);
|
||||
@ -504,7 +571,7 @@ describe("Password generator options builder", () => {
|
||||
it.each([10, 20, 400])(
|
||||
"should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded",
|
||||
(minSpecial) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max);
|
||||
|
||||
@ -517,7 +584,7 @@ describe("Password generator options builder", () => {
|
||||
);
|
||||
|
||||
it("should preserve unknown properties", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
unknown: "property",
|
||||
@ -540,7 +607,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should output `options.minLowercase === %i` when `options.lowercase` is %s",
|
||||
(expectedMinLowercase, lowercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ lowercase, ...defaultOptions });
|
||||
|
||||
@ -556,7 +623,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should output `options.minUppercase === %i` when `options.uppercase` is %s",
|
||||
(expectedMinUppercase, uppercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ uppercase, ...defaultOptions });
|
||||
|
||||
@ -572,7 +639,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set",
|
||||
(expectedMinNumber, number) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ number, ...defaultOptions });
|
||||
|
||||
@ -590,7 +657,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set",
|
||||
(expectedNumber, minNumber) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ minNumber, ...defaultOptions });
|
||||
|
||||
@ -606,7 +673,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set",
|
||||
(special, expectedMinSpecial) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ special, ...defaultOptions });
|
||||
|
||||
@ -624,7 +691,7 @@ describe("Password generator options builder", () => {
|
||||
])(
|
||||
"should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set",
|
||||
(minSpecial, expectedSpecial) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ minSpecial, ...defaultOptions });
|
||||
|
||||
@ -645,7 +712,7 @@ describe("Password generator options builder", () => {
|
||||
const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial;
|
||||
expect(sumOfMinimums).toBeLessThan(DefaultBoundaries.length.min);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
minLowercase,
|
||||
@ -670,7 +737,7 @@ describe("Password generator options builder", () => {
|
||||
(expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => {
|
||||
expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
minLowercase,
|
||||
@ -687,7 +754,7 @@ describe("Password generator options builder", () => {
|
||||
);
|
||||
|
||||
it("should preserve unknown properties", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
unknown: "property",
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
|
||||
import { PolicyEvaluator } from "../abstractions/policy-evaluator.abstraction";
|
||||
|
||||
import { PasswordGenerationOptions } from "./password-generator-options";
|
||||
import { PasswordGenerationOptions } from "./password-generation-options";
|
||||
import { PasswordGeneratorPolicy } from "./password-generator-policy";
|
||||
|
||||
function initializeBoundaries() {
|
||||
const length = Object.freeze({
|
||||
@ -37,7 +38,9 @@ type Boundary = {
|
||||
|
||||
/** Enforces policy for password generation.
|
||||
*/
|
||||
export class PasswordGeneratorOptionsEvaluator {
|
||||
export class PasswordGeneratorOptionsEvaluator
|
||||
implements PolicyEvaluator<PasswordGeneratorPolicy, PasswordGenerationOptions>
|
||||
{
|
||||
// This design is not ideal, but it is a step towards a more robust password
|
||||
// generator. Ideally, `sanitize` would be implemented on an options class,
|
||||
// and `applyPolicy` would be implemented on a policy class, "mise en place".
|
||||
@ -62,13 +65,13 @@ export class PasswordGeneratorOptionsEvaluator {
|
||||
|
||||
/** Policy applied by the evaluator.
|
||||
*/
|
||||
readonly policy: PasswordGeneratorPolicyOptions;
|
||||
readonly policy: PasswordGeneratorPolicy;
|
||||
|
||||
/** Instantiates the evaluator.
|
||||
* @param policy The policy applied by the evaluator. When this conflicts with
|
||||
* the defaults, the policy takes precedence.
|
||||
*/
|
||||
constructor(policy: PasswordGeneratorPolicyOptions) {
|
||||
constructor(policy: PasswordGeneratorPolicy) {
|
||||
function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
|
||||
const boundary = {
|
||||
min: Math.max(defaultBoundary.min, value),
|
||||
@ -78,7 +81,7 @@ export class PasswordGeneratorOptionsEvaluator {
|
||||
return boundary;
|
||||
}
|
||||
|
||||
this.policy = policy.clone();
|
||||
this.policy = structuredClone(policy);
|
||||
this.minDigits = createBoundary(policy.numberCount, DefaultBoundaries.minDigits);
|
||||
this.minSpecialCharacters = createBoundary(
|
||||
policy.specialCount,
|
||||
@ -96,12 +99,22 @@ export class PasswordGeneratorOptionsEvaluator {
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply policy to a set of options.
|
||||
* @param options The options to build from. These options are not altered.
|
||||
* @returns A complete password generation request with policy applied.
|
||||
* @remarks This method only applies policy overrides.
|
||||
* Pass the result to `sanitize` to ensure consistency.
|
||||
*/
|
||||
/** {@link PolicyEvaluator.policyInEffect} */
|
||||
get policyInEffect(): boolean {
|
||||
const policies = [
|
||||
this.policy.useUppercase,
|
||||
this.policy.useLowercase,
|
||||
this.policy.useNumbers,
|
||||
this.policy.useSpecial,
|
||||
this.policy.minLength > DefaultBoundaries.length.min,
|
||||
this.policy.numberCount > DefaultBoundaries.minDigits.min,
|
||||
this.policy.specialCount > DefaultBoundaries.minSpecialCharacters.min,
|
||||
];
|
||||
|
||||
return policies.includes(true);
|
||||
}
|
||||
|
||||
/** {@link PolicyEvaluator.applyPolicy} */
|
||||
applyPolicy(options: PasswordGenerationOptions): PasswordGenerationOptions {
|
||||
function fitToBounds(value: number, boundaries: Boundary) {
|
||||
const { min, max } = boundaries;
|
||||
@ -137,13 +150,7 @@ export class PasswordGeneratorOptionsEvaluator {
|
||||
};
|
||||
}
|
||||
|
||||
/** Ensures internal options consistency.
|
||||
* @param options The options to cascade. These options are not altered.
|
||||
* @returns A new password generation request with cascade applied.
|
||||
* @remarks This method fills null and undefined values by looking at
|
||||
* pairs of flags and values (e.g. `number` and `minNumber`). If the flag
|
||||
* and value are inconsistent, the flag cascades to the value.
|
||||
*/
|
||||
/** {@link PolicyEvaluator.sanitize} */
|
||||
sanitize(options: PasswordGenerationOptions): PasswordGenerationOptions {
|
||||
function cascade(enabled: boolean, value: number): [boolean, number] {
|
||||
const enabledResult = enabled ?? value > 0;
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { PasswordGenerationOptions } from "./password-generation-options";
|
||||
|
||||
/** Request format for credential generation.
|
||||
* This type includes all properties suitable for reactive data binding.
|
||||
*/
|
||||
@ -12,71 +14,6 @@ export type PasswordGeneratorOptions = PasswordGenerationOptions &
|
||||
type?: "password" | "passphrase";
|
||||
};
|
||||
|
||||
/** Request format for password credential generation.
|
||||
* All members of this type may be `undefined` when the user is
|
||||
* generating a passphrase.
|
||||
*/
|
||||
export type PasswordGenerationOptions = {
|
||||
/** The length of the password selected by the user */
|
||||
length?: number;
|
||||
|
||||
/** The minimum length of the password. This defaults to 5, and increases
|
||||
* to ensure `minLength` is at least as large as the sum of the other minimums.
|
||||
*/
|
||||
minLength?: number;
|
||||
|
||||
/** `true` when ambiguous characters may be included in the output.
|
||||
* `false` when ambiguous characters should not be included in the output.
|
||||
*/
|
||||
ambiguous?: boolean;
|
||||
|
||||
/** `true` when uppercase ASCII characters should be included in the output
|
||||
* This value defaults to `false.
|
||||
*/
|
||||
uppercase?: boolean;
|
||||
|
||||
/** The minimum number of uppercase characters to include in the output.
|
||||
* The value is ignored when `uppercase` is `false`.
|
||||
* The value defaults to 1 when `uppercase` is `true`.
|
||||
*/
|
||||
minUppercase?: number;
|
||||
|
||||
/** `true` when lowercase ASCII characters should be included in the output.
|
||||
* This value defaults to `false`.
|
||||
*/
|
||||
lowercase?: boolean;
|
||||
|
||||
/** The minimum number of lowercase characters to include in the output.
|
||||
* The value defaults to 1 when `lowercase` is `true`.
|
||||
* The value defaults to 0 when `lowercase` is `false`.
|
||||
*/
|
||||
minLowercase?: number;
|
||||
|
||||
/** Whether or not to include ASCII digits in the output
|
||||
* This value defaults to `true` when `minNumber` is at least 1.
|
||||
* This value defaults to `false` when `minNumber` is less than 1.
|
||||
*/
|
||||
number?: boolean;
|
||||
|
||||
/** The minimum number of digits to include in the output.
|
||||
* The value defaults to 1 when `number` is `true`.
|
||||
* The value defaults to 0 when `number` is `false`.
|
||||
*/
|
||||
minNumber?: number;
|
||||
|
||||
/** Whether or not to include special characters in the output.
|
||||
* This value defaults to `true` when `minSpecial` is at least 1.
|
||||
* This value defaults to `false` when `minSpecial` is less than 1.
|
||||
*/
|
||||
special?: boolean;
|
||||
|
||||
/** The minimum number of special characters to include in the output.
|
||||
* This value defaults to 1 when `special` is `true`.
|
||||
* This value defaults to 0 when `special` is `false`.
|
||||
*/
|
||||
minSpecial?: number;
|
||||
};
|
||||
|
||||
/** Request format for passphrase credential generation.
|
||||
* The members of this type may be `undefined` when the user is
|
||||
* generating a password.
|
||||
|
@ -0,0 +1,50 @@
|
||||
/** Policy options enforced during password generation. */
|
||||
export type PasswordGeneratorPolicy = {
|
||||
/** The minimum length of generated passwords.
|
||||
* When this is less than or equal to zero, it is ignored.
|
||||
* If this is less than the total number of characters required by
|
||||
* the policy's other settings, then it is ignored.
|
||||
*/
|
||||
minLength: number;
|
||||
|
||||
/** When this is true, an uppercase character must be part of
|
||||
* the generated password.
|
||||
*/
|
||||
useUppercase: boolean;
|
||||
|
||||
/** When this is true, a lowercase character must be part of
|
||||
* the generated password.
|
||||
*/
|
||||
useLowercase: boolean;
|
||||
|
||||
/** When this is true, at least one digit must be part of the generated
|
||||
* password.
|
||||
*/
|
||||
useNumbers: boolean;
|
||||
|
||||
/** The quantity of digits to include in the generated password.
|
||||
* When this is less than or equal to zero, it is ignored.
|
||||
*/
|
||||
numberCount: number;
|
||||
|
||||
/** When this is true, at least one digit must be part of the generated
|
||||
* password.
|
||||
*/
|
||||
useSpecial: boolean;
|
||||
|
||||
/** The quantity of special characters to include in the generated
|
||||
* password. When this is less than or equal to zero, it is ignored.
|
||||
*/
|
||||
specialCount: number;
|
||||
};
|
||||
|
||||
/** The default options for password generation policy. */
|
||||
export const DisabledPasswordGeneratorPolicy: PasswordGeneratorPolicy = Object.freeze({
|
||||
minLength: 0,
|
||||
useUppercase: false,
|
||||
useLowercase: false,
|
||||
useNumbers: false,
|
||||
numberCount: 0,
|
||||
useSpecial: false,
|
||||
specialCount: 0,
|
||||
});
|
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* include structuredClone in test environment.
|
||||
* @jest-environment ../../../../shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { PASSWORD_SETTINGS } from "../key-definitions";
|
||||
|
||||
import {
|
||||
PasswordGenerationServiceAbstraction,
|
||||
PasswordGeneratorOptionsEvaluator,
|
||||
PasswordGeneratorStrategy,
|
||||
} from ".";
|
||||
|
||||
describe("Password generation strategy", () => {
|
||||
describe("evaluator()", () => {
|
||||
it("should throw if the policy type is incorrect", () => {
|
||||
const strategy = new PasswordGeneratorStrategy(null);
|
||||
const policy = mock<Policy>({
|
||||
type: PolicyType.DisableSend,
|
||||
});
|
||||
|
||||
expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+"));
|
||||
});
|
||||
|
||||
it("should map to the policy evaluator", () => {
|
||||
const strategy = new PasswordGeneratorStrategy(null);
|
||||
const policy = mock<Policy>({
|
||||
type: PolicyType.PasswordGenerator,
|
||||
data: {
|
||||
minLength: 10,
|
||||
useUpper: true,
|
||||
useLower: true,
|
||||
useNumbers: true,
|
||||
minNumbers: 1,
|
||||
useSpecial: true,
|
||||
minSpecial: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const evaluator = strategy.evaluator(policy);
|
||||
|
||||
expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator);
|
||||
expect(evaluator.policy).toMatchObject({
|
||||
minLength: 10,
|
||||
useUppercase: true,
|
||||
useLowercase: true,
|
||||
useNumbers: true,
|
||||
numberCount: 1,
|
||||
useSpecial: true,
|
||||
specialCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("disk", () => {
|
||||
it("should use password settings key", () => {
|
||||
const legacy = mock<PasswordGenerationServiceAbstraction>();
|
||||
const strategy = new PasswordGeneratorStrategy(legacy);
|
||||
|
||||
expect(strategy.disk).toBe(PASSWORD_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const legacy = mock<PasswordGenerationServiceAbstraction>();
|
||||
const strategy = new PasswordGeneratorStrategy(legacy);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it("should call the legacy service with the given options", async () => {
|
||||
const legacy = mock<PasswordGenerationServiceAbstraction>();
|
||||
const strategy = new PasswordGeneratorStrategy(legacy);
|
||||
const options = {
|
||||
type: "password",
|
||||
minLength: 1,
|
||||
useUppercase: true,
|
||||
useLowercase: true,
|
||||
useNumbers: true,
|
||||
numberCount: 1,
|
||||
useSpecial: true,
|
||||
specialCount: 1,
|
||||
};
|
||||
|
||||
await strategy.generate(options);
|
||||
|
||||
expect(legacy.generatePassword).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
it("should set the generation type to password", async () => {
|
||||
const legacy = mock<PasswordGenerationServiceAbstraction>();
|
||||
const strategy = new PasswordGeneratorStrategy(legacy);
|
||||
|
||||
await strategy.generate({ type: "foo" } as any);
|
||||
|
||||
expect(legacy.generatePassword).toHaveBeenCalledWith({ type: "password" });
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,60 @@
|
||||
import { GeneratorStrategy } from "..";
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { PASSWORD_SETTINGS } from "../key-definitions";
|
||||
|
||||
import { PasswordGenerationOptions } from "./password-generation-options";
|
||||
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
|
||||
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
|
||||
import { PasswordGeneratorPolicy } from "./password-generator-policy";
|
||||
|
||||
const ONE_MINUTE = 60 * 1000;
|
||||
|
||||
/** {@link GeneratorStrategy} */
|
||||
export class PasswordGeneratorStrategy
|
||||
implements GeneratorStrategy<PasswordGenerationOptions, PasswordGeneratorPolicy>
|
||||
{
|
||||
/** instantiates the password generator strategy.
|
||||
* @param legacy generates the password
|
||||
*/
|
||||
constructor(private legacy: PasswordGenerationServiceAbstraction) {}
|
||||
|
||||
/** {@link GeneratorStrategy.disk} */
|
||||
get disk() {
|
||||
return PASSWORD_SETTINGS;
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.policy} */
|
||||
get policy() {
|
||||
return PolicyType.PasswordGenerator;
|
||||
}
|
||||
|
||||
get cache_ms() {
|
||||
return ONE_MINUTE;
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.evaluator} */
|
||||
evaluator(policy: Policy): PasswordGeneratorOptionsEvaluator {
|
||||
if (policy.type !== this.policy) {
|
||||
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
|
||||
throw Error("Mismatched policy type. " + details);
|
||||
}
|
||||
|
||||
return new PasswordGeneratorOptionsEvaluator({
|
||||
minLength: policy.data.minLength,
|
||||
useUppercase: policy.data.useUpper,
|
||||
useLowercase: policy.data.useLower,
|
||||
useNumbers: policy.data.useNumbers,
|
||||
numberCount: policy.data.minNumbers,
|
||||
useSpecial: policy.data.useSpecial,
|
||||
specialCount: policy.data.minSpecial,
|
||||
});
|
||||
}
|
||||
|
||||
/** {@link GeneratorStrategy.generate} */
|
||||
generate(options: PasswordGenerationOptions): Promise<string> {
|
||||
return this.legacy.generatePassword({ ...options, type: "password" });
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user