[PM-6556] reintroduce policy reduction for multi-org accounts (#8409)

This commit is contained in:
✨ Audrey ✨ 2024-03-26 07:59:45 -04:00 committed by GitHub
parent da14d01062
commit d000f081da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 388 additions and 241 deletions

View File

@ -1,3 +1,5 @@
import { Observable } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
@ -21,13 +23,16 @@ export abstract class GeneratorStrategy<Options, Policy> {
/** Length of time in milliseconds to cache the evaluator */
cache_ms: number;
/** Creates an evaluator from a generator policy.
/** Operator function that converts a policy collection observable to a single
* policy evaluator observable.
* @param policy The policy being evaluated.
* @returns the policy evaluator. If `policy` is is `null` or `undefined`,
* then the evaluator defaults to the application's limits.
* @throws when the policy's type does not match the generator's policy type.
*/
evaluator: (policy: AdminPolicy) => PolicyEvaluator<Policy, Options>;
toEvaluator: () => (
source: Observable<AdminPolicy[]>,
) => Observable<PolicyEvaluator<Policy, Options>>;
/** Generates credentials from the given options.
* @param options The options used to generate the credentials.

View File

@ -4,7 +4,7 @@
*/
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { BehaviorSubject, firstValueFrom, map, pipe } from "rxjs";
import { FakeSingleUserState, awaitAsync } from "../../../spec";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
@ -20,12 +20,12 @@ import { PasswordGenerationOptions } from "./password";
import { DefaultGeneratorService } from ".";
function mockPolicyService(config?: { state?: BehaviorSubject<Policy> }) {
function mockPolicyService(config?: { state?: BehaviorSubject<Policy[]> }) {
const service = mock<PolicyService>();
// FIXME: swap out the mock return value when `getAll$` becomes available
const stateValue = config?.state ?? new BehaviorSubject<Policy>(null);
service.get$.mockReturnValue(stateValue);
const stateValue = config?.state ?? new BehaviorSubject<Policy[]>([null]);
service.getAll$.mockReturnValue(stateValue);
// const stateValue = config?.state ?? new BehaviorSubject<Policy[]>(null);
// service.getAll$.mockReturnValue(stateValue);
@ -46,7 +46,9 @@ function mockGeneratorStrategy(config?: {
// the value from `config`.
durableState: jest.fn(() => durableState),
policy: config?.policy ?? PolicyType.DisableSend,
evaluator: jest.fn(() => config?.evaluator ?? mock<PolicyEvaluator<any, any>>()),
toEvaluator: jest.fn(() =>
pipe(map(() => config?.evaluator ?? mock<PolicyEvaluator<any, any>>())),
),
});
return strategy;
@ -94,9 +96,7 @@ describe("Password generator service", () => {
await firstValueFrom(service.evaluator$(SomeUser));
// FIXME: swap out the expect when `getAll$` becomes available
expect(policy.get$).toHaveBeenCalledWith(PolicyType.PasswordGenerator);
//expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
});
it("should map the policy using the generation strategy", async () => {
@ -112,21 +112,22 @@ describe("Password generator service", () => {
it("should update the evaluator when the password generator policy changes", async () => {
// set up dependencies
const state = new BehaviorSubject<Policy>(null);
const state = new BehaviorSubject<Policy[]>([null]);
const policy = mockPolicyService({ state });
const strategy = mockGeneratorStrategy();
const service = new DefaultGeneratorService(strategy, policy);
// model responses for the observable update
// model responses for the observable update. The map is called multiple times,
// and the array shift ensures reference equality is maintained.
const firstEvaluator = mock<PolicyEvaluator<any, any>>();
strategy.evaluator.mockReturnValueOnce(firstEvaluator);
const secondEvaluator = mock<PolicyEvaluator<any, any>>();
strategy.evaluator.mockReturnValueOnce(secondEvaluator);
const evaluators = [firstEvaluator, secondEvaluator];
strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift())));
// act
const evaluator$ = service.evaluator$(SomeUser);
const firstResult = await firstValueFrom(evaluator$);
state.next(null);
state.next([null]);
const secondResult = await firstValueFrom(evaluator$);
// assert
@ -142,9 +143,7 @@ describe("Password generator service", () => {
await firstValueFrom(service.evaluator$(SomeUser));
await firstValueFrom(service.evaluator$(SomeUser));
// FIXME: swap out the expect when `getAll$` becomes available
expect(policy.get$).toHaveBeenCalledTimes(1);
//expect(policy.getAll$).toHaveBeenCalledTimes(1);
expect(policy.getAll$).toHaveBeenCalledTimes(1);
});
it("should cache the password generator policy for each user", async () => {
@ -155,9 +154,8 @@ describe("Password generator service", () => {
await firstValueFrom(service.evaluator$(SomeUser));
await firstValueFrom(service.evaluator$(AnotherUser));
// FIXME: enable this test when `getAll$` becomes available
// expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser);
// expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser);
expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser);
expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser);
});
});

View File

@ -1,4 +1,4 @@
import { firstValueFrom, map, share, timer, ReplaySubject, Observable } from "rxjs";
import { firstValueFrom, share, timer, ReplaySubject, Observable } from "rxjs";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
@ -44,14 +44,12 @@ export class DefaultGeneratorService<Options, Policy> implements GeneratorServic
}
private createEvaluator(userId: UserId) {
// FIXME: when it becomes possible to get a user-specific policy observable
// (`getAll$`) update this code to call it instead of `get$`.
const policies$ = this.policy.get$(this.strategy.policy);
const evaluator$ = this.policy.getAll$(this.strategy.policy, userId).pipe(
// create the evaluator from the policies
this.strategy.toEvaluator(),
// cache evaluator in a replay subject to amortize creation cost
// and reduce GC pressure.
const evaluator$ = policies$.pipe(
map((policy) => this.strategy.evaluator(policy)),
// cache evaluator in a replay subject to amortize creation cost
// and reduce GC pressure.
share({
connector: () => new ReplaySubject(1),
resetOnRefCountZero: () => timer(this.strategy.cache_ms),

View File

@ -0,0 +1,51 @@
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 { PolicyId } from "../../../types/guid";
import { DisabledPassphraseGeneratorPolicy, leastPrivilege } from "./passphrase-generator-policy";
function createPolicy(
data: any,
type: PolicyType = PolicyType.PasswordGenerator,
enabled: boolean = true,
) {
return new Policy({
id: "id" as PolicyId,
organizationId: "organizationId",
data,
enabled,
type,
});
}
describe("leastPrivilege", () => {
it("should return the accumulator when the policy type does not apply", () => {
const policy = createPolicy({}, PolicyType.RequireSso);
const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy);
expect(result).toEqual(DisabledPassphraseGeneratorPolicy);
});
it("should return the accumulator when the policy is not enabled", () => {
const policy = createPolicy({}, PolicyType.PasswordGenerator, false);
const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy);
expect(result).toEqual(DisabledPassphraseGeneratorPolicy);
});
it.each([
["minNumberWords", 10],
["capitalize", true],
["includeNumber", true],
])("should take the %p from the policy", (input, value) => {
const policy = createPolicy({ [input]: value });
const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy);
expect(result).toEqual({ ...DisabledPassphraseGeneratorPolicy, [input]: value });
});
});

View File

@ -1,3 +1,8 @@
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";
/** Policy options enforced during passphrase generation. */
export type PassphraseGeneratorPolicy = {
minNumberWords: number;
@ -11,3 +16,24 @@ export const DisabledPassphraseGeneratorPolicy: PassphraseGeneratorPolicy = Obje
capitalize: false,
includeNumber: false,
});
/** Reduces a policy into an accumulator by accepting the most restrictive
* values from each policy.
* @param acc the accumulator
* @param policy the policy to reduce
* @returns the most restrictive values between the policy and accumulator.
*/
export function leastPrivilege(
acc: PassphraseGeneratorPolicy,
policy: Policy,
): PassphraseGeneratorPolicy {
if (policy.type !== PolicyType.PasswordGenerator) {
return acc;
}
return {
minNumberWords: Math.max(acc.minNumberWords, policy.data.minNumberWords ?? acc.minNumberWords),
capitalize: policy.data.capitalize || acc.capitalize,
includeNumber: policy.data.includeNumber || acc.includeNumber,
};
}

View File

@ -4,6 +4,7 @@
*/
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
@ -21,17 +22,8 @@ import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorStrategy } from
const SomeUser = "some user" as UserId;
describe("Password generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new PassphraseGeneratorStrategy(null, 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", () => {
describe("toEvaluator()", () => {
it("should map to the policy evaluator", async () => {
const strategy = new PassphraseGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
@ -42,7 +34,8 @@ describe("Password generation strategy", () => {
},
});
const evaluator = strategy.evaluator(policy);
const evaluator$ = of([policy]).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator);
expect(evaluator.policy).toMatchObject({
@ -52,13 +45,18 @@ describe("Password generation strategy", () => {
});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new PassphraseGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
it.each([[[]], [null], [undefined]])(
"should map `%p` to a disabled password policy evaluator",
async (policies) => {
const strategy = new PassphraseGeneratorStrategy(null, null);
expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator);
expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy);
});
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator);
expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy);
},
);
});
describe("durableState", () => {

View File

@ -1,18 +1,19 @@
import { map, pipe } from "rxjs";
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 { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { PASSPHRASE_SETTINGS } from "../key-definitions";
import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction";
import { reduceCollection } from "../reduce-collection.operator";
import { PassphraseGenerationOptions } from "./passphrase-generation-options";
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
import {
DisabledPassphraseGeneratorPolicy,
PassphraseGeneratorPolicy,
leastPrivilege,
} from "./passphrase-generator-policy";
const ONE_MINUTE = 60 * 1000;
@ -23,6 +24,7 @@ export class PassphraseGeneratorStrategy
{
/** instantiates the password generator strategy.
* @param legacy generates the passphrase
* @param stateProvider provides durable state
*/
constructor(
private legacy: PasswordGenerationServiceAbstraction,
@ -39,26 +41,17 @@ export class PassphraseGeneratorStrategy
return PolicyType.PasswordGenerator;
}
/** {@link GeneratorStrategy.cache_ms} */
get cache_ms() {
return ONE_MINUTE;
}
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy): PassphraseGeneratorOptionsEvaluator {
if (!policy) {
return new PassphraseGeneratorOptionsEvaluator(DisabledPassphraseGeneratorPolicy);
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);
}
return new PassphraseGeneratorOptionsEvaluator({
minNumberWords: policy.data.minNumberWords,
capitalize: policy.data.capitalize,
includeNumber: policy.data.includeNumber,
});
/** {@link GeneratorStrategy.toEvaluator} */
toEvaluator() {
return pipe(
reduceCollection(leastPrivilege, DisabledPassphraseGeneratorPolicy),
map((policy) => new PassphraseGeneratorOptionsEvaluator(policy)),
);
}
/** {@link GeneratorStrategy.generate} */

View File

@ -0,0 +1,55 @@
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 { PolicyId } from "../../../types/guid";
import { DisabledPasswordGeneratorPolicy, leastPrivilege } from "./password-generator-policy";
function createPolicy(
data: any,
type: PolicyType = PolicyType.PasswordGenerator,
enabled: boolean = true,
) {
return new Policy({
id: "id" as PolicyId,
organizationId: "organizationId",
data,
enabled,
type,
});
}
describe("leastPrivilege", () => {
it("should return the accumulator when the policy type does not apply", () => {
const policy = createPolicy({}, PolicyType.RequireSso);
const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy);
expect(result).toEqual(DisabledPasswordGeneratorPolicy);
});
it("should return the accumulator when the policy is not enabled", () => {
const policy = createPolicy({}, PolicyType.PasswordGenerator, false);
const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy);
expect(result).toEqual(DisabledPasswordGeneratorPolicy);
});
it.each([
["minLength", 10, "minLength"],
["useUpper", true, "useUppercase"],
["useLower", true, "useLowercase"],
["useNumbers", true, "useNumbers"],
["minNumbers", 10, "numberCount"],
["useSpecial", true, "useSpecial"],
["minSpecial", 10, "specialCount"],
])("should take the %p from the policy", (input, value, expected) => {
const policy = createPolicy({ [input]: value });
const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy);
expect(result).toEqual({ ...DisabledPasswordGeneratorPolicy, [expected]: value });
});
});

View File

@ -1,3 +1,8 @@
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";
/** Policy options enforced during password generation. */
export type PasswordGeneratorPolicy = {
/** The minimum length of generated passwords.
@ -48,3 +53,25 @@ export const DisabledPasswordGeneratorPolicy: PasswordGeneratorPolicy = Object.f
useSpecial: false,
specialCount: 0,
});
/** Reduces a policy into an accumulator by accepting the most restrictive
* values from each policy.
* @param acc the accumulator
* @param policy the policy to reduce
* @returns the most restrictive values between the policy and accumulator.
*/
export function leastPrivilege(acc: PasswordGeneratorPolicy, policy: Policy) {
if (policy.type !== PolicyType.PasswordGenerator || !policy.enabled) {
return acc;
}
return {
minLength: Math.max(acc.minLength, policy.data.minLength ?? acc.minLength),
useUppercase: policy.data.useUpper || acc.useUppercase,
useLowercase: policy.data.useLower || acc.useLowercase,
useNumbers: policy.data.useNumbers || acc.useNumbers,
numberCount: Math.max(acc.numberCount, policy.data.minNumbers ?? acc.numberCount),
useSpecial: policy.data.useSpecial || acc.useSpecial,
specialCount: Math.max(acc.specialCount, policy.data.minSpecial ?? acc.specialCount),
};
}

View File

@ -4,6 +4,7 @@
*/
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
@ -24,17 +25,8 @@ import {
const SomeUser = "some user" as UserId;
describe("Password generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new PasswordGeneratorStrategy(null, 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", () => {
describe("toEvaluator()", () => {
it("should map to a password policy evaluator", async () => {
const strategy = new PasswordGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
@ -49,7 +41,8 @@ describe("Password generation strategy", () => {
},
});
const evaluator = strategy.evaluator(policy);
const evaluator$ = of([policy]).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator);
expect(evaluator.policy).toMatchObject({
@ -63,13 +56,18 @@ describe("Password generation strategy", () => {
});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new PasswordGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
it.each([[[]], [null], [undefined]])(
"should map `%p` to a disabled password policy evaluator",
async (policies) => {
const strategy = new PasswordGeneratorStrategy(null, null);
expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator);
expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy);
});
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator);
expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy);
},
);
});
describe("durableState", () => {

View File

@ -1,11 +1,11 @@
import { map, pipe } from "rxjs";
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 { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { PASSWORD_SETTINGS } from "../key-definitions";
import { reduceCollection } from "../reduce-collection.operator";
import { PasswordGenerationOptions } from "./password-generation-options";
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
@ -13,6 +13,7 @@ import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-
import {
DisabledPasswordGeneratorPolicy,
PasswordGeneratorPolicy,
leastPrivilege,
} from "./password-generator-policy";
const ONE_MINUTE = 60 * 1000;
@ -43,26 +44,12 @@ export class PasswordGeneratorStrategy
return ONE_MINUTE;
}
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy): PasswordGeneratorOptionsEvaluator {
if (!policy) {
return new PasswordGeneratorOptionsEvaluator(DisabledPasswordGeneratorPolicy);
}
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.toEvaluator} */
toEvaluator() {
return pipe(
reduceCollection(leastPrivilege, DisabledPasswordGeneratorPolicy),
map((policy) => new PasswordGeneratorOptionsEvaluator(policy)),
);
}
/** {@link GeneratorStrategy.generate} */

View File

@ -0,0 +1,33 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { of, firstValueFrom } from "rxjs";
import { reduceCollection } from "./reduce-collection.operator";
describe("reduceCollection", () => {
it.each([[null], [undefined], [[]]])(
"should return the default value when the collection is %p",
async (value: number[]) => {
const reduce = (acc: number, value: number) => acc + value;
const source$ = of(value);
const result$ = source$.pipe(reduceCollection(reduce, 100));
const result = await firstValueFrom(result$);
expect(result).toEqual(100);
},
);
it("should reduce the collection to a single value", async () => {
const reduce = (acc: number, value: number) => acc + value;
const source$ = of([1, 2, 3]);
const result$ = source$.pipe(reduceCollection(reduce, 0));
const result = await firstValueFrom(result$);
expect(result).toEqual(6);
});
});

View File

@ -0,0 +1,20 @@
import { map, OperatorFunction } from "rxjs";
/**
* An observable operator that reduces an emitted collection to a single object,
* returning a default if all items are ignored.
* @param reduce The reduce function to apply to the filtered collection. The
* first argument is the accumulator, and the second is the current item. The
* return value is the new accumulator.
* @param defaultValue The default value to return if the collection is empty. The
* default value is also the initial value of the accumulator.
*/
export function reduceCollection<Item, Accumulator>(
reduce: (acc: Accumulator, value: Item) => Accumulator,
defaultValue: Accumulator,
): OperatorFunction<Item[], Accumulator> {
return map((values: Item[]) => {
const reduced = (values ?? []).reduce(reduce, structuredClone(defaultValue));
return reduced;
});
}

View File

@ -1,4 +1,5 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
@ -12,39 +13,26 @@ import { CATCHALL_SETTINGS } from "../key-definitions";
import { CatchallGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
const SomePolicy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
minLength: 10,
},
});
describe("Email subaddress list generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new CatchallGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
describe("toEvaluator()", () => {
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
"should map any input (= %p) to the default policy evaluator",
async (policies) => {
const strategy = new CatchallGeneratorStrategy(null, null);
expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+"));
});
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
it("should map to the policy evaluator", () => {
const strategy = new CatchallGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
minLength: 10,
},
});
const evaluator = strategy.evaluator(policy);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
expect(evaluator.policy).toMatchObject({});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new CatchallGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
});
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
},
);
});
describe("durableState", () => {

View File

@ -1,5 +1,6 @@
import { map, pipe } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions";
@ -41,18 +42,9 @@ export class CatchallGeneratorStrategy
return ONE_MINUTE;
}
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<CatchallGenerationOptions>();
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);
}
return new DefaultPolicyEvaluator<CatchallGenerationOptions>();
/** {@link GeneratorStrategy.toEvaluator} */
toEvaluator() {
return pipe(map((_) => new DefaultPolicyEvaluator<CatchallGenerationOptions>()));
}
/** {@link GeneratorStrategy.generate} */

View File

@ -1,4 +1,5 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
@ -12,39 +13,26 @@ import { EFF_USERNAME_SETTINGS } from "../key-definitions";
import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
const SomePolicy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
minLength: 10,
},
});
describe("EFF long word list generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new EffUsernameGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
describe("toEvaluator()", () => {
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
"should map any input (= %p) to the default policy evaluator",
async (policies) => {
const strategy = new EffUsernameGeneratorStrategy(null, null);
expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+"));
});
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
it("should map to the policy evaluator", () => {
const strategy = new EffUsernameGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
minLength: 10,
},
});
const evaluator = strategy.evaluator(policy);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
expect(evaluator.policy).toMatchObject({});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new EffUsernameGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
});
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
},
);
});
describe("durableState", () => {

View File

@ -1,5 +1,6 @@
import { map, pipe } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions";
@ -41,18 +42,9 @@ export class EffUsernameGeneratorStrategy
return ONE_MINUTE;
}
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<EffUsernameGenerationOptions>();
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);
}
return new DefaultPolicyEvaluator<EffUsernameGenerationOptions>();
/** {@link GeneratorStrategy.toEvaluator} */
toEvaluator() {
return pipe(map((_) => new DefaultPolicyEvaluator<EffUsernameGenerationOptions>()));
}
/** {@link GeneratorStrategy.generate} */

View File

@ -1,6 +1,11 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
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 { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { StateProvider } from "../../../platform/state";
@ -29,6 +34,12 @@ class TestForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
const SomeUser = "some user" as UserId;
const AnotherUser = "another user" as UserId;
const SomePolicy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
minLength: 10,
},
});
describe("ForwarderGeneratorStrategy", () => {
const encryptService = mock<EncryptService>();
@ -63,11 +74,17 @@ describe("ForwarderGeneratorStrategy", () => {
});
});
it("evaluator returns the default policy evaluator", () => {
const strategy = new TestForwarder(null, null, null);
describe("toEvaluator()", () => {
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
"should map any input (= %p) to the default policy evaluator",
async (policies) => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
const result = strategy.evaluator(null);
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
expect(result).toBeInstanceOf(DefaultPolicyEvaluator);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
},
);
});
});

View File

@ -1,5 +1,6 @@
import { map, pipe } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { KeyDefinition, SingleUserState, StateProvider } from "../../../platform/state";
@ -81,8 +82,8 @@ export abstract class ForwarderGeneratorStrategy<
/** Determine where forwarder configuration is stored */
protected abstract readonly key: KeyDefinition<Options>;
/** {@link GeneratorStrategy.evaluator} */
evaluator = (_policy: Policy) => {
return new DefaultPolicyEvaluator<Options>();
/** {@link GeneratorStrategy.toEvaluator} */
toEvaluator = () => {
return pipe(map((_) => new DefaultPolicyEvaluator<Options>()));
};
}

View File

@ -1,4 +1,5 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
@ -12,39 +13,26 @@ import { SUBADDRESS_SETTINGS } from "../key-definitions";
import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
const SomePolicy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
minLength: 10,
},
});
describe("Email subaddress list generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new SubaddressGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
describe("toEvaluator()", () => {
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
"should map any input (= %p) to the default policy evaluator",
async (policies) => {
const strategy = new SubaddressGeneratorStrategy(null, null);
expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+"));
});
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
it("should map to the policy evaluator", () => {
const strategy = new SubaddressGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
minLength: 10,
},
});
const evaluator = strategy.evaluator(policy);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
expect(evaluator.policy).toMatchObject({});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new SubaddressGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
});
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
},
);
});
describe("durableState", () => {

View File

@ -1,5 +1,6 @@
import { map, pipe } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions";
@ -41,18 +42,9 @@ export class SubaddressGeneratorStrategy
return ONE_MINUTE;
}
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<SubaddressGenerationOptions>();
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);
}
return new DefaultPolicyEvaluator<SubaddressGenerationOptions>();
/** {@link GeneratorStrategy.toEvaluator} */
toEvaluator() {
return pipe(map((_) => new DefaultPolicyEvaluator<SubaddressGenerationOptions>()));
}
/** {@link GeneratorStrategy.generate} */