[PM-5606] Add reactive generator service (#7446)

This commit is contained in:
✨ Audrey ✨ 2024-01-23 14:22:52 -05:00 committed by GitHub
parent 0de72144b9
commit dbf836b573
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 917 additions and 134 deletions

View File

@ -1,3 +1,5 @@
export * from "./utils";
export * from "./intercept-console";
export * from "./matchers";
export * from "./fake-state-provider";
export * from "./fake-account-service";

View File

@ -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",
}

View File

@ -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");

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -0,0 +1,3 @@
export { GeneratorService } from "./generator.service.abstraction";
export { GeneratorStrategy } from "./generator-strategy.abstraction";
export { PolicyEvaluator } from "./policy-evaluator.abstraction";

View File

@ -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;
}

View File

@ -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();
});
});
});

View 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);
}
}

View File

@ -0,0 +1,4 @@
export * from "./abstractions/index";
export * from "./password/index";
export { DefaultGeneratorService } from "./default-generator.service";

View 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);
});
});
});

View 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,
},
);

View File

@ -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";

View File

@ -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,
});

View File

@ -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",

View File

@ -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;

View File

@ -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.

View File

@ -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,
});

View File

@ -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" });
});
});
});

View File

@ -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" });
}
}