mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
[PM-252] fix inconsistent generator configuration behavior (#6755)
* decompose password generator policy enforcement * integrate new logic with UI * improve UX of minimum password length * improve password generator policy options documentation * initialize min length to default minimum length boundary * reset form value on input to prevent UI desync from model --------- Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
parent
bfa76885ac
commit
df406a9862
@ -268,6 +268,9 @@
|
||||
"length": {
|
||||
"message": "Length"
|
||||
},
|
||||
"passwordMinLength": {
|
||||
"message": "Minimum password length"
|
||||
},
|
||||
"uppercase": {
|
||||
"message": "Uppercase (A-Z)"
|
||||
},
|
||||
|
@ -341,7 +341,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.img-right {
|
||||
.img-right,
|
||||
.txt-right {
|
||||
float: right;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
@ -176,7 +176,7 @@
|
||||
<input
|
||||
id="length"
|
||||
type="number"
|
||||
min="5"
|
||||
[min]="passwordOptions.minLength"
|
||||
max="128"
|
||||
[(ngModel)]="passwordOptions.length"
|
||||
(change)="savePasswordOptions()"
|
||||
@ -184,7 +184,7 @@
|
||||
<input
|
||||
id="lengthRange"
|
||||
type="range"
|
||||
min="5"
|
||||
[min]="passwordOptions.minLength"
|
||||
max="128"
|
||||
step="1"
|
||||
[(ngModel)]="passwordOptions.length"
|
||||
@ -194,6 +194,18 @@
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="box-content-row" appBoxRow>
|
||||
<span>{{ "passwordMinLength" | i18n }}</span>
|
||||
<span
|
||||
class="sr-only"
|
||||
attr.aria-label="{{ 'passwordMinLength' | i18n }}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ passwordOptionsMinLengthForReader$ | async }}
|
||||
</span>
|
||||
<span class="txt-right">{{ passwordOptions.minLength }}</span>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-checkbox" appBoxRow>
|
||||
<label for="uppercase">A-Z</label>
|
||||
<input
|
||||
@ -221,10 +233,10 @@
|
||||
<input
|
||||
id="numbers"
|
||||
type="checkbox"
|
||||
(change)="savePasswordOptions()"
|
||||
attr.aria-label="{{ 'numbers' | i18n }}"
|
||||
[disabled]="enforcedPasswordPolicyOptions.useNumbers"
|
||||
[(ngModel)]="passwordOptions.number"
|
||||
[ngModel]="passwordOptions.number"
|
||||
(ngModelChange)="setPasswordOptionsNumber($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-checkbox" appBoxRow>
|
||||
@ -232,10 +244,10 @@
|
||||
<input
|
||||
id="special"
|
||||
type="checkbox"
|
||||
(change)="savePasswordOptions()"
|
||||
attr.aria-label="{{ 'specialCharacters' | i18n }}"
|
||||
[disabled]="enforcedPasswordPolicyOptions.useSpecial"
|
||||
[(ngModel)]="passwordOptions.special"
|
||||
[ngModel]="passwordOptions.special"
|
||||
(ngModelChange)="setPasswordOptionsSpecial($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -249,8 +261,8 @@
|
||||
type="number"
|
||||
min="0"
|
||||
max="9"
|
||||
(change)="savePasswordOptions()"
|
||||
[(ngModel)]="passwordOptions.minNumber"
|
||||
(input)="onPasswordOptionsMinNumberInput($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-input" appBoxRow>
|
||||
@ -260,8 +272,8 @@
|
||||
type="number"
|
||||
min="0"
|
||||
max="9"
|
||||
(change)="savePasswordOptions()"
|
||||
[(ngModel)]="passwordOptions.minSpecial"
|
||||
(input)="onPasswordOptionsMinSpecialInput($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-checkbox" appBoxRow>
|
||||
|
@ -200,7 +200,7 @@
|
||||
<input
|
||||
id="length"
|
||||
type="number"
|
||||
min="5"
|
||||
[min]="passwordOptions.minLength"
|
||||
max="128"
|
||||
[(ngModel)]="passwordOptions.length"
|
||||
(blur)="savePasswordOptions()"
|
||||
@ -208,7 +208,7 @@
|
||||
<input
|
||||
id="lengthRange"
|
||||
type="range"
|
||||
min="5"
|
||||
[min]="passwordOptions.minLength"
|
||||
max="128"
|
||||
step="1"
|
||||
[(ngModel)]="passwordOptions.length"
|
||||
@ -218,6 +218,18 @@
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="box-content-row" appBoxRow>
|
||||
<span>{{ "passwordMinLength" | i18n }}</span>
|
||||
<span class="txt-right">{{ passwordOptions.minLength }}</span>
|
||||
<span
|
||||
class="sr-only"
|
||||
attr.aria-label="{{ 'passwordMinLength' | i18n }}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ passwordOptionsMinLengthForReader$ | async }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-checkbox" appBoxRow>
|
||||
<label for="uppercase">A-Z</label>
|
||||
<input
|
||||
@ -247,7 +259,8 @@
|
||||
type="checkbox"
|
||||
(change)="savePasswordOptions()"
|
||||
[disabled]="enforcedPasswordPolicyOptions?.useNumbers"
|
||||
[(ngModel)]="passwordOptions.number"
|
||||
[ngModel]="passwordOptions.number"
|
||||
(ngModelChange)="setPasswordOptionsNumber($event)"
|
||||
attr.aria-label="{{ 'numbers' | i18n }}"
|
||||
/>
|
||||
</div>
|
||||
@ -258,7 +271,8 @@
|
||||
type="checkbox"
|
||||
(change)="savePasswordOptions()"
|
||||
[disabled]="enforcedPasswordPolicyOptions?.useSpecial"
|
||||
[(ngModel)]="passwordOptions.special"
|
||||
[ngModel]="passwordOptions.special"
|
||||
(ngModelChange)="setPasswordOptionsSpecial($event)"
|
||||
attr.aria-label="{{ 'specialCharacters' | i18n }}"
|
||||
/>
|
||||
</div>
|
||||
@ -275,6 +289,7 @@
|
||||
max="9"
|
||||
(change)="savePasswordOptions()"
|
||||
[(ngModel)]="passwordOptions.minNumber"
|
||||
(input)="onPasswordOptionsMinNumberInput($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-input" appBoxRow>
|
||||
@ -286,6 +301,7 @@
|
||||
max="9"
|
||||
(change)="savePasswordOptions()"
|
||||
[(ngModel)]="passwordOptions.minSpecial"
|
||||
(input)="onPasswordOptionsMinSpecialInput($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-checkbox" appBoxRow>
|
||||
|
@ -403,6 +403,9 @@
|
||||
"length": {
|
||||
"message": "Length"
|
||||
},
|
||||
"passwordMinLength": {
|
||||
"message": "Minimum password length"
|
||||
},
|
||||
"uppercase": {
|
||||
"message": "Uppercase (A-Z)"
|
||||
},
|
||||
|
@ -217,7 +217,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.img-right {
|
||||
.img-right,
|
||||
.txt-right {
|
||||
float: right;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
@ -109,13 +109,31 @@
|
||||
id="length"
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="5"
|
||||
[min]="passwordOptions.minLength"
|
||||
max="128"
|
||||
[(ngModel)]="passwordOptions.length"
|
||||
(blur)="savePasswordOptions()"
|
||||
(change)="lengthChanged()"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="min-length">{{ "passwordMinLength" | i18n }}</label>
|
||||
<input
|
||||
id="min-length"
|
||||
class="form-control"
|
||||
type="text"
|
||||
readonly="true"
|
||||
[value]="passwordOptions.length"
|
||||
/>
|
||||
<span
|
||||
class="sr-only"
|
||||
attr.aria-label="{{ 'passwordMinLength' | i18n }}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ passwordOptionsMinLengthForReader$ | async }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label for="min-number">{{ "minNumbers" | i18n }}</label>
|
||||
<input
|
||||
@ -124,8 +142,8 @@
|
||||
type="number"
|
||||
min="0"
|
||||
max="9"
|
||||
(blur)="savePasswordOptions()"
|
||||
[(ngModel)]="passwordOptions.minNumber"
|
||||
(input)="onPasswordOptionsMinNumberInput($event)"
|
||||
(change)="minNumberChanged()"
|
||||
/>
|
||||
</div>
|
||||
@ -137,8 +155,8 @@
|
||||
type="number"
|
||||
min="0"
|
||||
max="9"
|
||||
(blur)="savePasswordOptions()"
|
||||
[(ngModel)]="passwordOptions.minSpecial"
|
||||
(input)="onPasswordOptionsMinSpecialInput($event)"
|
||||
(change)="minSpecialChanged()"
|
||||
/>
|
||||
</div>
|
||||
@ -175,7 +193,8 @@
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
(change)="savePasswordOptions()"
|
||||
[(ngModel)]="passwordOptions.number"
|
||||
[ngModel]="passwordOptions.number"
|
||||
(ngModelChange)="setPasswordOptionsNumber($event)"
|
||||
[disabled]="enforcedPasswordPolicyOptions?.useNumbers"
|
||||
attr.aria-label="{{ 'numbers' | i18n }}"
|
||||
/>
|
||||
@ -186,8 +205,8 @@
|
||||
id="special"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
(change)="savePasswordOptions()"
|
||||
[(ngModel)]="passwordOptions.special"
|
||||
[ngModel]="passwordOptions.special"
|
||||
(ngModelChange)="setPasswordOptionsSpecial($event)"
|
||||
[disabled]="enforcedPasswordPolicyOptions?.useSpecial"
|
||||
attr.aria-label="{{ 'specialCharacters' | i18n }}"
|
||||
/>
|
||||
|
@ -1150,6 +1150,9 @@
|
||||
"length": {
|
||||
"message": "Length"
|
||||
},
|
||||
"passwordMinLength": {
|
||||
"message": "Minimum password length"
|
||||
},
|
||||
"uppercase": {
|
||||
"message": "Uppercase (A-Z)",
|
||||
"description": "Include uppercase letters in the password generator."
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { first } from "rxjs/operators";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { debounceTime, first, map } from "rxjs/operators";
|
||||
|
||||
import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@ -12,6 +13,7 @@ import {
|
||||
PasswordGenerationServiceAbstraction,
|
||||
PasswordGeneratorOptions,
|
||||
} from "@bitwarden/common/tools/generator/password";
|
||||
import { DefaultBoundaries } from "@bitwarden/common/tools/generator/password/password-generator-options-evaluator";
|
||||
import {
|
||||
UsernameGenerationServiceAbstraction,
|
||||
UsernameGeneratorOptions,
|
||||
@ -40,6 +42,16 @@ export class GeneratorComponent implements OnInit {
|
||||
enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions;
|
||||
usernameWebsite: string = null;
|
||||
|
||||
// update screen reader minimum password length with 500ms debounce
|
||||
// so that the user isn't flooded with status updates
|
||||
private _passwordOptionsMinLengthForReader = new BehaviorSubject<number>(
|
||||
DefaultBoundaries.length.min,
|
||||
);
|
||||
protected passwordOptionsMinLengthForReader$ = this._passwordOptionsMinLengthForReader.pipe(
|
||||
map((val) => val || DefaultBoundaries.length.min),
|
||||
debounceTime(500),
|
||||
);
|
||||
|
||||
constructor(
|
||||
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||
protected usernameGenerationService: UsernameGenerationServiceAbstraction,
|
||||
@ -144,6 +156,44 @@ export class GeneratorComponent implements OnInit {
|
||||
await this.passwordGenerationService.addHistory(this.password);
|
||||
}
|
||||
|
||||
async onPasswordOptionsMinNumberInput($event: Event) {
|
||||
// `savePasswordOptions()` replaces the null
|
||||
this.passwordOptions.number = null;
|
||||
|
||||
await this.savePasswordOptions();
|
||||
|
||||
// fixes UI desync that occurs when minNumber has a fixed value
|
||||
// that is reset through normalization
|
||||
($event.target as HTMLInputElement).value = `${this.passwordOptions.minNumber}`;
|
||||
}
|
||||
|
||||
async setPasswordOptionsNumber($event: boolean) {
|
||||
this.passwordOptions.number = $event;
|
||||
// `savePasswordOptions()` replaces the null
|
||||
this.passwordOptions.minNumber = null;
|
||||
|
||||
await this.savePasswordOptions();
|
||||
}
|
||||
|
||||
async onPasswordOptionsMinSpecialInput($event: Event) {
|
||||
// `savePasswordOptions()` replaces the null
|
||||
this.passwordOptions.special = null;
|
||||
|
||||
await this.savePasswordOptions();
|
||||
|
||||
// fixes UI desync that occurs when minSpecial has a fixed value
|
||||
// that is reset through normalization
|
||||
($event.target as HTMLInputElement).value = `${this.passwordOptions.minSpecial}`;
|
||||
}
|
||||
|
||||
async setPasswordOptionsSpecial($event: boolean) {
|
||||
this.passwordOptions.special = $event;
|
||||
// `savePasswordOptions()` replaces the null
|
||||
this.passwordOptions.minSpecial = null;
|
||||
|
||||
await this.savePasswordOptions();
|
||||
}
|
||||
|
||||
async sliderInput() {
|
||||
this.normalizePasswordOptions();
|
||||
this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions);
|
||||
@ -240,6 +290,8 @@ export class GeneratorComponent implements OnInit {
|
||||
this.passwordOptions,
|
||||
this.enforcedPasswordPolicyOptions,
|
||||
);
|
||||
|
||||
this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength);
|
||||
}
|
||||
|
||||
private async initForwardOptions() {
|
||||
|
@ -1,18 +1,73 @@
|
||||
import Domain from "../../../platform/models/domain/domain-base";
|
||||
|
||||
/** Enterprise policy for the password generator.
|
||||
* @see PolicyType.PasswordGenerator
|
||||
*/
|
||||
export class PasswordGeneratorPolicyOptions extends Domain {
|
||||
defaultType = "";
|
||||
/** The default kind of credential to generate */
|
||||
defaultType: "password" | "passphrase" | "" = "";
|
||||
|
||||
/** 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.
|
||||
* This field is not used for passphrases.
|
||||
*/
|
||||
minLength = 0;
|
||||
|
||||
/** When this is true, an uppercase character must be part of
|
||||
* the generated password.
|
||||
* This field is not used for passphrases.
|
||||
*/
|
||||
useUppercase = false;
|
||||
|
||||
/** When this is true, a lowercase character must be part of
|
||||
* the generated password. This field is not used for passphrases.
|
||||
*/
|
||||
useLowercase = false;
|
||||
|
||||
/** When this is true, at least one digit must be part of the generated
|
||||
* password. This field is not used for passphrases.
|
||||
*/
|
||||
useNumbers = false;
|
||||
|
||||
/** The quantity of digits to include in the generated password.
|
||||
* When this is less than or equal to zero, it is ignored.
|
||||
* This field is not used for passphrases.
|
||||
*/
|
||||
numberCount = 0;
|
||||
|
||||
/** When this is true, at least one digit must be part of the generated
|
||||
* password. This field is not used for passphrases.
|
||||
*/
|
||||
useSpecial = false;
|
||||
|
||||
/** The quantity of special characters to include in the generated
|
||||
* password. When this is less than or equal to zero, it is ignored.
|
||||
* This field is not used for passphrases.
|
||||
*/
|
||||
specialCount = 0;
|
||||
|
||||
/** The minimum number of words required by generated passphrases.
|
||||
* This field is not used for passwords.
|
||||
*/
|
||||
minNumberWords = 0;
|
||||
|
||||
/** When this is true, the first letter of each word in the passphrase
|
||||
* is capitalized. This field is not used for passwords.
|
||||
*/
|
||||
capitalize = false;
|
||||
|
||||
/** When this is true, a number is included within the passphrase.
|
||||
* This field is not used for passwords.
|
||||
*/
|
||||
includeNumber = false;
|
||||
|
||||
/** Checks whether the policy affects the password generator.
|
||||
* @returns True if at least one password or passphrase requirement has been set.
|
||||
* If it returns False, then no requirements have been set and the policy should
|
||||
* not be enforced.
|
||||
*/
|
||||
inEffect() {
|
||||
return (
|
||||
this.defaultType !== "" ||
|
||||
@ -28,4 +83,12 @@ export class PasswordGeneratorPolicyOptions extends Domain {
|
||||
this.includeNumber
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates a copy of the policy.
|
||||
*/
|
||||
clone() {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
Object.assign(policy, this);
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,220 @@
|
||||
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
|
||||
|
||||
import {
|
||||
DefaultBoundaries,
|
||||
PassphraseGeneratorOptionsEvaluator,
|
||||
} from "./passphrase-generator-options-evaluator";
|
||||
import { PassphraseGenerationOptions } from "./password-generator-options";
|
||||
|
||||
describe("Password generator options builder", () => {
|
||||
describe("constructor()", () => {
|
||||
it("should set the policy object to a copy of the input policy", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.minLength = 10; // arbitrary change for deep equality check
|
||||
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policy).toEqual(policy);
|
||||
expect(builder.policy).not.toBe(policy);
|
||||
});
|
||||
|
||||
it("should set default boundaries when a default policy is used", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.numWords).toEqual(DefaultBoundaries.numWords);
|
||||
});
|
||||
|
||||
it.each([1, 2])(
|
||||
"should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)",
|
||||
(minNumberWords) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.minNumberWords = minNumberWords;
|
||||
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.numWords).toEqual(DefaultBoundaries.numWords);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([8, 12, 18])(
|
||||
"should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words",
|
||||
(minNumberWords) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.minNumberWords = minNumberWords;
|
||||
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.numWords.min).toEqual(minNumberWords);
|
||||
expect(builder.numWords.max).toEqual(DefaultBoundaries.numWords.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([150, 300, 9000])(
|
||||
"should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries",
|
||||
(minNumberWords) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.minNumberWords = minNumberWords;
|
||||
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.numWords.min).toEqual(minNumberWords);
|
||||
expect(builder.numWords.max).toEqual(minNumberWords);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("applyPolicy(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
|
||||
it("should set `capitalize` to `false` when the policy does not override it", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({});
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.capitalize).toBe(false);
|
||||
});
|
||||
|
||||
it("should set `capitalize` to `true` when the policy overrides it", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.capitalize = true;
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ capitalize: false });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.capitalize).toBe(true);
|
||||
});
|
||||
|
||||
it("should set `includeNumber` to false when the policy does not override it", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({});
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.includeNumber).toBe(false);
|
||||
});
|
||||
|
||||
it("should set `includeNumber` to true when the policy overrides it", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.includeNumber = true;
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ includeNumber: false });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.includeNumber).toBe(true);
|
||||
});
|
||||
|
||||
it("should set `numWords` to the minimum value when it isn't supplied", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({});
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.numWords).toBe(builder.numWords.min);
|
||||
});
|
||||
|
||||
it.each([1, 2])(
|
||||
"should set `numWords` (= %i) to the minimum value when it is less than the minimum",
|
||||
(numWords) => {
|
||||
expect(numWords).toBeLessThan(DefaultBoundaries.numWords.min);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ numWords });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.numWords).toBe(builder.numWords.min);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([3, 8, 18, 20])(
|
||||
"should set `numWords` (= %i) to the input value when it is within the boundaries",
|
||||
(numWords) => {
|
||||
expect(numWords).toBeGreaterThanOrEqual(DefaultBoundaries.numWords.min);
|
||||
expect(numWords).toBeLessThanOrEqual(DefaultBoundaries.numWords.max);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ numWords });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.numWords).toBe(numWords);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([21, 30, 50, 100])(
|
||||
"should set `numWords` (= %i) to the maximum value when it is greater than the maximum",
|
||||
(numWords) => {
|
||||
expect(numWords).toBeGreaterThan(DefaultBoundaries.numWords.max);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ numWords });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.numWords).toBe(builder.numWords.max);
|
||||
},
|
||||
);
|
||||
|
||||
it("should preserve unknown properties", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
unknown: "property",
|
||||
another: "unknown property",
|
||||
}) as PassphraseGenerationOptions;
|
||||
|
||||
const sanitizedOptions: any = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.unknown).toEqual("property");
|
||||
expect(sanitizedOptions.another).toEqual("unknown property");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitize(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
|
||||
it("should return the input options without altering them", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ wordSeparator: "%" });
|
||||
|
||||
const sanitizedOptions = builder.sanitize(options);
|
||||
|
||||
expect(sanitizedOptions).toEqual(options);
|
||||
});
|
||||
|
||||
it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({});
|
||||
|
||||
const sanitizedOptions = builder.sanitize(options);
|
||||
|
||||
expect(sanitizedOptions.wordSeparator).toEqual("-");
|
||||
});
|
||||
|
||||
it("should preserve unknown properties", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
unknown: "property",
|
||||
another: "unknown property",
|
||||
}) as PassphraseGenerationOptions;
|
||||
|
||||
const sanitizedOptions: any = builder.sanitize(options);
|
||||
|
||||
expect(sanitizedOptions.unknown).toEqual("property");
|
||||
expect(sanitizedOptions.another).toEqual("unknown property");
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,105 @@
|
||||
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
|
||||
|
||||
import { PassphraseGenerationOptions } from "./password-generator-options";
|
||||
|
||||
type Boundary = {
|
||||
readonly min: number;
|
||||
readonly max: number;
|
||||
};
|
||||
|
||||
function initializeBoundaries() {
|
||||
const numWords = Object.freeze({
|
||||
min: 3,
|
||||
max: 20,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
numWords,
|
||||
});
|
||||
}
|
||||
|
||||
/** Immutable default boundaries for passphrase generation.
|
||||
* These are used when the policy does not override a value.
|
||||
*/
|
||||
export const DefaultBoundaries = initializeBoundaries();
|
||||
|
||||
/** Enforces policy for passphrase generation options.
|
||||
*/
|
||||
export class PassphraseGeneratorOptionsEvaluator {
|
||||
// This design is not ideal, but it is a step towards a more robust passphrase
|
||||
// generator. Ideally, `sanitize` would be implemented on an options class,
|
||||
// and `applyPolicy` would be implemented on a policy class, "mise en place".
|
||||
//
|
||||
// The current design of the passphrase generator, unfortunately, would require
|
||||
// a substantial rewrite to make this feasible. Hopefully this change can be
|
||||
// applied when the passphrase generator is ported to rust.
|
||||
|
||||
/** Policy applied by the evaluator.
|
||||
*/
|
||||
readonly policy: PasswordGeneratorPolicyOptions;
|
||||
|
||||
/** Boundaries for the number of words allowed in the password.
|
||||
*/
|
||||
readonly numWords: Boundary;
|
||||
|
||||
/** Instantiates the evaluator.
|
||||
* @param policy The policy applied by the evaluator. When this conflicts with
|
||||
* the defaults, the policy takes precedence.
|
||||
*/
|
||||
constructor(policy: PasswordGeneratorPolicyOptions) {
|
||||
function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
|
||||
const boundary = {
|
||||
min: Math.max(defaultBoundary.min, value),
|
||||
max: Math.max(defaultBoundary.max, value),
|
||||
};
|
||||
|
||||
return boundary;
|
||||
}
|
||||
|
||||
this.policy = policy.clone();
|
||||
this.numWords = createBoundary(policy.minNumberWords, DefaultBoundaries.numWords);
|
||||
}
|
||||
|
||||
/** Apply policy to the input options.
|
||||
* @param options The options to build from. These options are not altered.
|
||||
* @returns A new password generation request with policy applied.
|
||||
*/
|
||||
applyPolicy(options: PassphraseGenerationOptions): PassphraseGenerationOptions {
|
||||
function fitToBounds(value: number, boundaries: Boundary) {
|
||||
const { min, max } = boundaries;
|
||||
|
||||
const withUpperBound = Math.min(value ?? boundaries.min, max);
|
||||
const withLowerBound = Math.max(withUpperBound, min);
|
||||
|
||||
return withLowerBound;
|
||||
}
|
||||
|
||||
// apply policy overrides
|
||||
const capitalize = this.policy.capitalize || options.capitalize || false;
|
||||
const includeNumber = this.policy.includeNumber || options.includeNumber || false;
|
||||
|
||||
// apply boundaries
|
||||
const numWords = fitToBounds(options.numWords, this.numWords);
|
||||
|
||||
return {
|
||||
...options,
|
||||
numWords,
|
||||
capitalize,
|
||||
includeNumber,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ensures internal options consistency.
|
||||
* @param options The options to cascade. These options are not altered.
|
||||
* @returns A passphrase generation request with cascade applied.
|
||||
*/
|
||||
sanitize(options: PassphraseGenerationOptions): PassphraseGenerationOptions {
|
||||
// ensure words are separated by a single character
|
||||
const wordSeparator = options.wordSeparator?.[0] ?? "-";
|
||||
|
||||
return {
|
||||
...options,
|
||||
wordSeparator,
|
||||
};
|
||||
}
|
||||
}
|
@ -7,11 +7,14 @@ import { EFFLongWordList } from "../../../platform/misc/wordlist";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
|
||||
import { GeneratedPasswordHistory } from "./generated-password-history";
|
||||
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
|
||||
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
|
||||
import { PasswordGeneratorOptions } from "./password-generator-options";
|
||||
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
|
||||
|
||||
const DefaultOptions: PasswordGeneratorOptions = {
|
||||
length: 14,
|
||||
minLength: 5,
|
||||
ambiguous: false,
|
||||
number: true,
|
||||
minNumber: 1,
|
||||
@ -28,6 +31,8 @@ const DefaultOptions: PasswordGeneratorOptions = {
|
||||
includeNumber: false,
|
||||
};
|
||||
|
||||
const DefaultPolicy = new PasswordGeneratorPolicyOptions();
|
||||
|
||||
const MaxPasswordsInHistory = 100;
|
||||
|
||||
export class PasswordGenerationService implements PasswordGenerationServiceAbstraction {
|
||||
@ -38,20 +43,12 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
|
||||
) {}
|
||||
|
||||
async generatePassword(options: PasswordGeneratorOptions): Promise<string> {
|
||||
// overload defaults with given options
|
||||
const o = Object.assign({}, DefaultOptions, options);
|
||||
|
||||
if (o.type === "passphrase") {
|
||||
return this.generatePassphrase(options);
|
||||
if ((options.type ?? DefaultOptions.type) === "passphrase") {
|
||||
return this.generatePassphrase({ ...DefaultOptions, ...options });
|
||||
}
|
||||
|
||||
// sanitize
|
||||
this.sanitizePasswordLength(o, true);
|
||||
|
||||
const minLength: number = o.minUppercase + o.minLowercase + o.minNumber + o.minSpecial;
|
||||
if (o.length < minLength) {
|
||||
o.length = minLength;
|
||||
}
|
||||
const evaluator = new PasswordGeneratorOptionsEvaluator(DefaultPolicy);
|
||||
const o = evaluator.sanitize({ ...DefaultOptions, ...options });
|
||||
|
||||
const positions: string[] = [];
|
||||
if (o.lowercase && o.minLowercase > 0) {
|
||||
@ -144,7 +141,8 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
|
||||
}
|
||||
|
||||
async generatePassphrase(options: PasswordGeneratorOptions): Promise<string> {
|
||||
const o = Object.assign({}, DefaultOptions, options);
|
||||
const evaluator = new PassphraseGeneratorOptionsEvaluator(DefaultPolicy);
|
||||
const o = evaluator.sanitize({ ...DefaultOptions, ...options });
|
||||
|
||||
if (o.numWords == null || o.numWords <= 2) {
|
||||
o.numWords = DefaultOptions.numWords;
|
||||
@ -192,65 +190,25 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
|
||||
async enforcePasswordGeneratorPoliciesOnOptions(
|
||||
options: PasswordGeneratorOptions,
|
||||
): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> {
|
||||
let enforcedPolicyOptions = await this.getPasswordGeneratorPolicyOptions();
|
||||
if (enforcedPolicyOptions != null) {
|
||||
if (options.length < enforcedPolicyOptions.minLength) {
|
||||
options.length = enforcedPolicyOptions.minLength;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.useUppercase) {
|
||||
options.uppercase = true;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.useLowercase) {
|
||||
options.lowercase = true;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.useNumbers) {
|
||||
options.number = true;
|
||||
}
|
||||
|
||||
if (options.minNumber < enforcedPolicyOptions.numberCount) {
|
||||
options.minNumber = enforcedPolicyOptions.numberCount;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.useSpecial) {
|
||||
options.special = true;
|
||||
}
|
||||
|
||||
if (options.minSpecial < enforcedPolicyOptions.specialCount) {
|
||||
options.minSpecial = enforcedPolicyOptions.specialCount;
|
||||
}
|
||||
|
||||
// Must normalize these fields because the receiving call expects all options to pass the current rules
|
||||
if (options.minSpecial + options.minNumber > options.length) {
|
||||
options.minSpecial = options.length - options.minNumber;
|
||||
}
|
||||
|
||||
if (options.numWords < enforcedPolicyOptions.minNumberWords) {
|
||||
options.numWords = enforcedPolicyOptions.minNumberWords;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.capitalize) {
|
||||
options.capitalize = true;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.includeNumber) {
|
||||
options.includeNumber = true;
|
||||
}
|
||||
let policy = await this.getPasswordGeneratorPolicyOptions();
|
||||
policy = policy ?? new PasswordGeneratorPolicyOptions();
|
||||
|
||||
// Force default type if password/passphrase selected via policy
|
||||
if (
|
||||
enforcedPolicyOptions.defaultType === "password" ||
|
||||
enforcedPolicyOptions.defaultType === "passphrase"
|
||||
) {
|
||||
options.type = enforcedPolicyOptions.defaultType;
|
||||
if (policy.defaultType === "password" || policy.defaultType === "passphrase") {
|
||||
options.type = policy.defaultType;
|
||||
}
|
||||
} else {
|
||||
// UI layer expects an instantiated object to prevent more explicit null checks
|
||||
enforcedPolicyOptions = new PasswordGeneratorPolicyOptions();
|
||||
}
|
||||
return [options, enforcedPolicyOptions];
|
||||
|
||||
const evaluator = options.type
|
||||
? new PasswordGeneratorOptionsEvaluator(policy)
|
||||
: new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
// Ensure the options to pass the current rules
|
||||
const withPolicy = evaluator.applyPolicy(options);
|
||||
const sanitized = evaluator.sanitize(withPolicy);
|
||||
|
||||
// callers assume this function updates the options parameter
|
||||
const result = Object.assign(options, sanitized);
|
||||
return [result, policy];
|
||||
}
|
||||
|
||||
async getPasswordGeneratorPolicyOptions(): Promise<PasswordGeneratorPolicyOptions> {
|
||||
@ -389,62 +347,17 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
|
||||
options: PasswordGeneratorOptions,
|
||||
enforcedPolicyOptions: PasswordGeneratorPolicyOptions,
|
||||
) {
|
||||
options.minLowercase = 0;
|
||||
options.minUppercase = 0;
|
||||
const evaluator = options.type
|
||||
? new PasswordGeneratorOptionsEvaluator(enforcedPolicyOptions)
|
||||
: new PassphraseGeneratorOptionsEvaluator(enforcedPolicyOptions);
|
||||
|
||||
if (!options.length || options.length < 5) {
|
||||
options.length = 5;
|
||||
} else if (options.length > 128) {
|
||||
options.length = 128;
|
||||
}
|
||||
const evaluatedOptions = evaluator.applyPolicy(options);
|
||||
const santizedOptions = evaluator.sanitize(evaluatedOptions);
|
||||
|
||||
if (options.length < enforcedPolicyOptions.minLength) {
|
||||
options.length = enforcedPolicyOptions.minLength;
|
||||
}
|
||||
// callers assume this function updates the options parameter
|
||||
Object.assign(options, santizedOptions);
|
||||
|
||||
if (!options.minNumber) {
|
||||
options.minNumber = 0;
|
||||
} else if (options.minNumber > options.length) {
|
||||
options.minNumber = options.length;
|
||||
} else if (options.minNumber > 9) {
|
||||
options.minNumber = 9;
|
||||
}
|
||||
|
||||
if (options.minNumber < enforcedPolicyOptions.numberCount) {
|
||||
options.minNumber = enforcedPolicyOptions.numberCount;
|
||||
}
|
||||
|
||||
if (!options.minSpecial) {
|
||||
options.minSpecial = 0;
|
||||
} else if (options.minSpecial > options.length) {
|
||||
options.minSpecial = options.length;
|
||||
} else if (options.minSpecial > 9) {
|
||||
options.minSpecial = 9;
|
||||
}
|
||||
|
||||
if (options.minSpecial < enforcedPolicyOptions.specialCount) {
|
||||
options.minSpecial = enforcedPolicyOptions.specialCount;
|
||||
}
|
||||
|
||||
if (options.minSpecial + options.minNumber > options.length) {
|
||||
options.minSpecial = options.length - options.minNumber;
|
||||
}
|
||||
|
||||
if (options.numWords == null || options.length < 3) {
|
||||
options.numWords = 3;
|
||||
} else if (options.numWords > 20) {
|
||||
options.numWords = 20;
|
||||
}
|
||||
|
||||
if (options.numWords < enforcedPolicyOptions.minNumberWords) {
|
||||
options.numWords = enforcedPolicyOptions.minNumberWords;
|
||||
}
|
||||
|
||||
if (options.wordSeparator != null && options.wordSeparator.length > 1) {
|
||||
options.wordSeparator = options.wordSeparator[0];
|
||||
}
|
||||
|
||||
this.sanitizePasswordLength(options, false);
|
||||
return options;
|
||||
}
|
||||
|
||||
private capitalize(str: string) {
|
||||
@ -505,54 +418,4 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizePasswordLength(options: any, forGeneration: boolean) {
|
||||
let minUppercaseCalc = 0;
|
||||
let minLowercaseCalc = 0;
|
||||
let minNumberCalc: number = options.minNumber;
|
||||
let minSpecialCalc: number = options.minSpecial;
|
||||
|
||||
if (options.uppercase && options.minUppercase <= 0) {
|
||||
minUppercaseCalc = 1;
|
||||
} else if (!options.uppercase) {
|
||||
minUppercaseCalc = 0;
|
||||
}
|
||||
|
||||
if (options.lowercase && options.minLowercase <= 0) {
|
||||
minLowercaseCalc = 1;
|
||||
} else if (!options.lowercase) {
|
||||
minLowercaseCalc = 0;
|
||||
}
|
||||
|
||||
if (options.number && options.minNumber <= 0) {
|
||||
minNumberCalc = 1;
|
||||
} else if (!options.number) {
|
||||
minNumberCalc = 0;
|
||||
}
|
||||
|
||||
if (options.special && options.minSpecial <= 0) {
|
||||
minSpecialCalc = 1;
|
||||
} else if (!options.special) {
|
||||
minSpecialCalc = 0;
|
||||
}
|
||||
|
||||
// This should never happen but is a final safety net
|
||||
if (!options.length || options.length < 1) {
|
||||
options.length = 10;
|
||||
}
|
||||
|
||||
const minLength: number = minUppercaseCalc + minLowercaseCalc + minNumberCalc + minSpecialCalc;
|
||||
// Normalize and Generation both require this modification
|
||||
if (options.length < minLength) {
|
||||
options.length = minLength;
|
||||
}
|
||||
|
||||
// Apply other changes if the options object passed in is for generation
|
||||
if (forGeneration) {
|
||||
options.minUppercase = minUppercaseCalc;
|
||||
options.minLowercase = minLowercaseCalc;
|
||||
options.minNumber = minNumberCalc;
|
||||
options.minSpecial = minSpecialCalc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,703 @@
|
||||
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
|
||||
|
||||
import { PasswordGenerationOptions } from "./password-generator-options";
|
||||
import {
|
||||
DefaultBoundaries,
|
||||
PasswordGeneratorOptionsEvaluator,
|
||||
} from "./password-generator-options-evaluator";
|
||||
|
||||
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();
|
||||
policy.minLength = 10; // arbitrary change for deep equality check
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policy).toEqual(policy);
|
||||
expect(builder.policy).not.toBe(policy);
|
||||
});
|
||||
|
||||
it("should set default boundaries when a default policy is used", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.length).toEqual(DefaultBoundaries.length);
|
||||
expect(builder.minDigits).toEqual(DefaultBoundaries.minDigits);
|
||||
expect(builder.minSpecialCharacters).toEqual(DefaultBoundaries.minSpecialCharacters);
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4])(
|
||||
"should use the default length boundaries when they are greater than `policy.minLength` (= %i)",
|
||||
(minLength) => {
|
||||
expect(minLength).toBeLessThan(DefaultBoundaries.length.min);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.minLength = minLength;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.length).toEqual(DefaultBoundaries.length);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([8, 20, 100])(
|
||||
"should use `policy.minLength` (= %i) when it is greater than the default minimum length",
|
||||
(expectedLength) => {
|
||||
expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.min);
|
||||
expect(expectedLength).toBeLessThanOrEqual(DefaultBoundaries.length.max);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.minLength = expectedLength;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.length.min).toEqual(expectedLength);
|
||||
expect(builder.length.max).toEqual(DefaultBoundaries.length.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([150, 300, 9000])(
|
||||
"should use `policy.minLength` (= %i) when it is greater than the default boundaries",
|
||||
(expectedLength) => {
|
||||
expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.max);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.minLength = expectedLength;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.length.min).toEqual(expectedLength);
|
||||
expect(builder.length.max).toEqual(expectedLength);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([3, 5, 8, 9])(
|
||||
"should use `policy.numberCount` (= %i) when it is greater than the default minimum digits",
|
||||
(expectedMinDigits) => {
|
||||
expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.min);
|
||||
expect(expectedMinDigits).toBeLessThanOrEqual(DefaultBoundaries.minDigits.max);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.numberCount = expectedMinDigits;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.minDigits.min).toEqual(expectedMinDigits);
|
||||
expect(builder.minDigits.max).toEqual(DefaultBoundaries.minDigits.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([10, 20, 400])(
|
||||
"should use `policy.numberCount` (= %i) when it is greater than the default digit boundaries",
|
||||
(expectedMinDigits) => {
|
||||
expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.max);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.numberCount = expectedMinDigits;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.minDigits.min).toEqual(expectedMinDigits);
|
||||
expect(builder.minDigits.max).toEqual(expectedMinDigits);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([2, 4, 6])(
|
||||
"should use `policy.specialCount` (= %i) when it is greater than the default minimum special characters",
|
||||
(expectedSpecialCharacters) => {
|
||||
expect(expectedSpecialCharacters).toBeGreaterThan(
|
||||
DefaultBoundaries.minSpecialCharacters.min,
|
||||
);
|
||||
expect(expectedSpecialCharacters).toBeLessThanOrEqual(
|
||||
DefaultBoundaries.minSpecialCharacters.max,
|
||||
);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.specialCount = expectedSpecialCharacters;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters);
|
||||
expect(builder.minSpecialCharacters.max).toEqual(
|
||||
DefaultBoundaries.minSpecialCharacters.max,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([10, 20, 400])(
|
||||
"should use `policy.specialCount` (= %i) when it is greater than the default special characters boundaries",
|
||||
(expectedSpecialCharacters) => {
|
||||
expect(expectedSpecialCharacters).toBeGreaterThan(
|
||||
DefaultBoundaries.minSpecialCharacters.max,
|
||||
);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.specialCount = expectedSpecialCharacters;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters);
|
||||
expect(builder.minSpecialCharacters.max).toEqual(expectedSpecialCharacters);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[8, 6, 2],
|
||||
[6, 2, 4],
|
||||
[16, 8, 8],
|
||||
])(
|
||||
"should ensure the minimum length (= %i) is at least the sum of minimums (= %i + %i)",
|
||||
(expectedLength, numberCount, specialCount) => {
|
||||
expect(expectedLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.numberCount = numberCount;
|
||||
policy.specialCount = specialCount;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.length.min).toBeGreaterThanOrEqual(expectedLength);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("applyPolicy(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, true],
|
||||
[false, undefined],
|
||||
])(
|
||||
"should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'",
|
||||
(expectedUppercase, uppercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.useUppercase = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, uppercase });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.uppercase).toEqual(expectedUppercase);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true",
|
||||
(uppercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.useUppercase = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, uppercase });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.uppercase).toEqual(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, true],
|
||||
[false, undefined],
|
||||
])(
|
||||
"should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'",
|
||||
(expectedLowercase, lowercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.useLowercase = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, lowercase });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.lowercase).toEqual(expectedLowercase);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true",
|
||||
(lowercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.useLowercase = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, lowercase });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.lowercase).toEqual(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, true],
|
||||
[false, undefined],
|
||||
])(
|
||||
"should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'",
|
||||
(expectedNumber, number) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.useNumbers = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.number).toEqual(expectedNumber);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.number` (= %s) to true when `policy.useNumbers` is true",
|
||||
(number) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.useNumbers = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.number).toEqual(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, true],
|
||||
[false, undefined],
|
||||
])(
|
||||
"should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'",
|
||||
(expectedSpecial, special) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.useSpecial = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.special).toEqual(expectedSpecial);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.special` (= %s) to true when `policy.useSpecial` is true",
|
||||
(special) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
policy.useSpecial = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.special).toEqual(true);
|
||||
},
|
||||
);
|
||||
|
||||
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 builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(length).toBeLessThan(builder.length.min);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, length });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.length).toEqual(builder.length.min);
|
||||
},
|
||||
);
|
||||
|
||||
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 builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(length).toBeGreaterThanOrEqual(builder.length.min);
|
||||
expect(length).toBeLessThanOrEqual(builder.length.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, length });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.length).toEqual(length);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([129, 500, 9000])(
|
||||
"should set `options.length` (= %i) to the maximum length when it is exceeded",
|
||||
(length) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(length).toBeGreaterThan(builder.length.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, length });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.length).toEqual(builder.length.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, 1],
|
||||
[true, 3],
|
||||
[true, 600],
|
||||
[false, 0],
|
||||
[false, -2],
|
||||
[false, -600],
|
||||
])(
|
||||
"should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0",
|
||||
(expectedNumber, minNumber) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, minNumber });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.number).toEqual(expectedNumber);
|
||||
},
|
||||
);
|
||||
|
||||
it("should set `options.minNumber` to the minimum value when `options.number` is true", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number: true });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min);
|
||||
});
|
||||
|
||||
it("should set `options.minNumber` to 0 when `options.number` is false", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number: false });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minNumber).toEqual(0);
|
||||
});
|
||||
|
||||
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();
|
||||
policy.numberCount = 5; // arbitrary value greater than minNumber
|
||||
expect(minNumber).toBeLessThan(policy.numberCount);
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, minNumber });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min);
|
||||
},
|
||||
);
|
||||
|
||||
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 builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min);
|
||||
expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, minNumber });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minNumber).toEqual(minNumber);
|
||||
},
|
||||
);
|
||||
|
||||
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 builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minNumber).toBeGreaterThan(builder.minDigits.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, minNumber });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, 1],
|
||||
[true, 3],
|
||||
[true, 600],
|
||||
[false, 0],
|
||||
[false, -2],
|
||||
[false, -600],
|
||||
])(
|
||||
"should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0",
|
||||
(expectedSpecial, minSpecial) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, minSpecial });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.special).toEqual(expectedSpecial);
|
||||
},
|
||||
);
|
||||
|
||||
it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special: true });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minSpecial).toEqual(builder.minDigits.min);
|
||||
});
|
||||
|
||||
it("should set `options.minSpecial` to 0 when `options.special` is false", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special: false });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minSpecial).toEqual(0);
|
||||
});
|
||||
|
||||
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();
|
||||
policy.specialCount = 5; // arbitrary value greater than minSpecial
|
||||
expect(minSpecial).toBeLessThan(policy.specialCount);
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, minSpecial });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.min);
|
||||
},
|
||||
);
|
||||
|
||||
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 builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min);
|
||||
expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, minSpecial });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minSpecial).toEqual(minSpecial);
|
||||
},
|
||||
);
|
||||
|
||||
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 builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, minSpecial });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.max);
|
||||
},
|
||||
);
|
||||
|
||||
it("should preserve unknown properties", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
unknown: "property",
|
||||
another: "unknown property",
|
||||
}) as PasswordGenerationOptions;
|
||||
|
||||
const sanitizedOptions: any = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.unknown).toEqual("property");
|
||||
expect(sanitizedOptions.another).toEqual("unknown property");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitize(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
|
||||
it.each([
|
||||
[1, true],
|
||||
[0, false],
|
||||
])(
|
||||
"should output `options.minLowercase === %i` when `options.lowercase` is %s",
|
||||
(expectedMinLowercase, lowercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ lowercase, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minLowercase).toEqual(expectedMinLowercase);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[1, true],
|
||||
[0, false],
|
||||
])(
|
||||
"should output `options.minUppercase === %i` when `options.uppercase` is %s",
|
||||
(expectedMinUppercase, uppercase) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ uppercase, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minUppercase).toEqual(expectedMinUppercase);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[1, true],
|
||||
[0, false],
|
||||
])(
|
||||
"should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set",
|
||||
(expectedMinNumber, number) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ number, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minNumber).toEqual(expectedMinNumber);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, 3],
|
||||
[true, 2],
|
||||
[true, 1],
|
||||
[false, 0],
|
||||
])(
|
||||
"should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set",
|
||||
(expectedNumber, minNumber) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ minNumber, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.number).toEqual(expectedNumber);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, 1],
|
||||
[false, 0],
|
||||
])(
|
||||
"should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set",
|
||||
(special, expectedMinSpecial) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ special, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minSpecial).toEqual(expectedMinSpecial);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[3, true],
|
||||
[2, true],
|
||||
[1, true],
|
||||
[0, false],
|
||||
])(
|
||||
"should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set",
|
||||
(minSpecial, expectedSpecial) => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ minSpecial, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.special).toEqual(expectedSpecial);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[0, 0, 0, 0],
|
||||
[1, 1, 0, 0],
|
||||
[0, 0, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
])(
|
||||
"should set `options.minLength` to the minimum boundary when the sum of minimums (%i + %i + %i + %i) is less than the default minimum length.",
|
||||
(minLowercase, minUppercase, minNumber, minSpecial) => {
|
||||
const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial;
|
||||
expect(sumOfMinimums).toBeLessThan(DefaultBoundaries.length.min);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
minLowercase,
|
||||
minUppercase,
|
||||
minNumber,
|
||||
minSpecial,
|
||||
...defaultOptions,
|
||||
});
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minLength).toEqual(builder.length.min);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[12, 3, 3, 3, 3],
|
||||
[8, 2, 2, 2, 2],
|
||||
[9, 3, 3, 3, 0],
|
||||
])(
|
||||
"should set `options.minLength === %i` to the sum of minimums (%i + %i + %i + %i) when the sum is at least the default minimum length.",
|
||||
(expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => {
|
||||
expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min);
|
||||
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
minLowercase,
|
||||
minUppercase,
|
||||
minNumber,
|
||||
minSpecial,
|
||||
...defaultOptions,
|
||||
});
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minLength).toEqual(expectedMinLength);
|
||||
},
|
||||
);
|
||||
|
||||
it("should preserve unknown properties", () => {
|
||||
const policy = new PasswordGeneratorPolicyOptions();
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
unknown: "property",
|
||||
another: "unknown property",
|
||||
}) as PasswordGenerationOptions;
|
||||
|
||||
const sanitizedOptions: any = builder.sanitize(options);
|
||||
|
||||
expect(sanitizedOptions.unknown).toEqual("property");
|
||||
expect(sanitizedOptions.another).toEqual("unknown property");
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,179 @@
|
||||
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
|
||||
|
||||
import { PasswordGenerationOptions } from "./password-generator-options";
|
||||
|
||||
function initializeBoundaries() {
|
||||
const length = Object.freeze({
|
||||
min: 5,
|
||||
max: 128,
|
||||
});
|
||||
|
||||
const minDigits = Object.freeze({
|
||||
min: 0,
|
||||
max: 9,
|
||||
});
|
||||
|
||||
const minSpecialCharacters = Object.freeze({
|
||||
min: 0,
|
||||
max: 9,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
length,
|
||||
minDigits,
|
||||
minSpecialCharacters,
|
||||
});
|
||||
}
|
||||
|
||||
/** Immutable default boundaries for password generation.
|
||||
* These are used when the policy does not override a value.
|
||||
*/
|
||||
export const DefaultBoundaries = initializeBoundaries();
|
||||
|
||||
type Boundary = {
|
||||
readonly min: number;
|
||||
readonly max: number;
|
||||
};
|
||||
|
||||
/** Enforces policy for password generation.
|
||||
*/
|
||||
export class PasswordGeneratorOptionsEvaluator {
|
||||
// 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".
|
||||
//
|
||||
// The current design of the password generator, unfortunately, would require
|
||||
// a substantial rewrite to make this feasible. Hopefully this change can be
|
||||
// applied when the password generator is ported to rust.
|
||||
|
||||
/** Boundaries for the password length. This is always large enough
|
||||
* to accommodate the minimum number of digits and special characters.
|
||||
*/
|
||||
readonly length: Boundary;
|
||||
|
||||
/** Boundaries for the minimum number of digits allowed in the password.
|
||||
*/
|
||||
readonly minDigits: Boundary;
|
||||
|
||||
/** Boundaries for the minimum number of special characters allowed
|
||||
* in the password.
|
||||
*/
|
||||
readonly minSpecialCharacters: Boundary;
|
||||
|
||||
/** Policy applied by the evaluator.
|
||||
*/
|
||||
readonly policy: PasswordGeneratorPolicyOptions;
|
||||
|
||||
/** Instantiates the evaluator.
|
||||
* @param policy The policy applied by the evaluator. When this conflicts with
|
||||
* the defaults, the policy takes precedence.
|
||||
*/
|
||||
constructor(policy: PasswordGeneratorPolicyOptions) {
|
||||
function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
|
||||
const boundary = {
|
||||
min: Math.max(defaultBoundary.min, value),
|
||||
max: Math.max(defaultBoundary.max, value),
|
||||
};
|
||||
|
||||
return boundary;
|
||||
}
|
||||
|
||||
this.policy = policy.clone();
|
||||
this.minDigits = createBoundary(policy.numberCount, DefaultBoundaries.minDigits);
|
||||
this.minSpecialCharacters = createBoundary(
|
||||
policy.specialCount,
|
||||
DefaultBoundaries.minSpecialCharacters,
|
||||
);
|
||||
|
||||
// the overall length should be at least as long as the sum of the minimums
|
||||
const minConsistentLength = this.minDigits.min + this.minSpecialCharacters.min;
|
||||
const minPolicyLength = policy.minLength > 0 ? policy.minLength : DefaultBoundaries.length.min;
|
||||
const minLength = Math.max(minPolicyLength, minConsistentLength, DefaultBoundaries.length.min);
|
||||
|
||||
this.length = {
|
||||
min: minLength,
|
||||
max: Math.max(DefaultBoundaries.length.max, minLength),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
applyPolicy(options: PasswordGenerationOptions): PasswordGenerationOptions {
|
||||
function fitToBounds(value: number, boundaries: Boundary) {
|
||||
const { min, max } = boundaries;
|
||||
|
||||
const withUpperBound = Math.min(value || 0, max);
|
||||
const withLowerBound = Math.max(withUpperBound, min);
|
||||
|
||||
return withLowerBound;
|
||||
}
|
||||
|
||||
// apply policy overrides
|
||||
const uppercase = this.policy.useUppercase || options.uppercase || false;
|
||||
const lowercase = this.policy.useLowercase || options.lowercase || false;
|
||||
|
||||
// these overrides can cascade numeric fields to boolean fields
|
||||
const number = this.policy.useNumbers || options.number || options.minNumber > 0;
|
||||
const special = this.policy.useSpecial || options.special || options.minSpecial > 0;
|
||||
|
||||
// apply boundaries; the boundaries can cascade boolean fields to numeric fields
|
||||
const length = fitToBounds(options.length, this.length);
|
||||
const minNumber = fitToBounds(options.minNumber, this.minDigits);
|
||||
const minSpecial = fitToBounds(options.minSpecial, this.minSpecialCharacters);
|
||||
|
||||
return {
|
||||
...options,
|
||||
length,
|
||||
uppercase,
|
||||
lowercase,
|
||||
number,
|
||||
minNumber,
|
||||
special,
|
||||
minSpecial,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
sanitize(options: PasswordGenerationOptions): PasswordGenerationOptions {
|
||||
function cascade(enabled: boolean, value: number): [boolean, number] {
|
||||
const enabledResult = enabled ?? value > 0;
|
||||
const valueResult = enabledResult ? value || 1 : 0;
|
||||
|
||||
return [enabledResult, valueResult];
|
||||
}
|
||||
|
||||
const [lowercase, minLowercase] = cascade(options.lowercase, options.minLowercase);
|
||||
const [uppercase, minUppercase] = cascade(options.uppercase, options.minUppercase);
|
||||
const [number, minNumber] = cascade(options.number, options.minNumber);
|
||||
const [special, minSpecial] = cascade(options.special, options.minSpecial);
|
||||
|
||||
// minimums can only increase the length
|
||||
const minConsistentLength = minLowercase + minUppercase + minNumber + minSpecial;
|
||||
const minLength = Math.max(minConsistentLength, this.length.min);
|
||||
const length = Math.max(options.length ?? minLength, minLength);
|
||||
|
||||
return {
|
||||
...options,
|
||||
length,
|
||||
minLength,
|
||||
lowercase,
|
||||
minLowercase,
|
||||
uppercase,
|
||||
minUppercase,
|
||||
number,
|
||||
minNumber,
|
||||
special,
|
||||
minSpecial,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,17 +1,105 @@
|
||||
export type PasswordGeneratorOptions = {
|
||||
length?: number;
|
||||
ambiguous?: boolean;
|
||||
uppercase?: boolean;
|
||||
minUppercase?: number;
|
||||
lowercase?: boolean;
|
||||
minLowercase?: number;
|
||||
number?: boolean;
|
||||
minNumber?: number;
|
||||
special?: boolean;
|
||||
minSpecial?: number;
|
||||
numWords?: number;
|
||||
wordSeparator?: string;
|
||||
capitalize?: boolean;
|
||||
includeNumber?: boolean;
|
||||
/** Request format for credential generation.
|
||||
* This type includes all properties suitable for reactive data binding.
|
||||
*/
|
||||
export type PasswordGeneratorOptions = PasswordGenerationOptions &
|
||||
PassphraseGenerationOptions & {
|
||||
/** The algorithm to use for credential generation.
|
||||
* Properties on @see PasswordGenerationOptions should be processed
|
||||
* only when `type === "password"`.
|
||||
* Properties on @see PassphraseGenerationOptions should be processed
|
||||
* only when `type === "passphrase"`.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
export type PassphraseGenerationOptions = {
|
||||
/** The number of words to include in the passphrase.
|
||||
* This value defaults to 4.
|
||||
*/
|
||||
numWords?: number;
|
||||
|
||||
/** The ASCII separator character to use between words in the passphrase.
|
||||
* This value defaults to a dash.
|
||||
* If multiple characters appear in the string, only the first character is used.
|
||||
*/
|
||||
wordSeparator?: string;
|
||||
|
||||
/** `true` when the first character of every word should be capitalized.
|
||||
* This value defaults to `false`.
|
||||
*/
|
||||
capitalize?: boolean;
|
||||
|
||||
/** `true` when a number should be included in the passphrase.
|
||||
* This value defaults to `false`.
|
||||
*/
|
||||
includeNumber?: boolean;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user