Implement identity and password generator in mobile app (#771)

This commit is contained in:
Leendert de Borst
2025-05-01 13:03:06 +02:00
parent b6b9e05a31
commit f8d5ae6107
13 changed files with 104 additions and 754 deletions

View File

@@ -10,11 +10,16 @@ import { useWebApi } from '@/context/WebApiContext';
import { Credential } from '@/utils/types/Credential';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import Toast from 'react-native-toast-message';
import { Gender } from "@/utils/generators/Identity/types/Gender";
import emitter from '@/utils/EventEmitter';
import { FaviconExtractModel } from '@/utils/types/webapi/FaviconExtractModel';
import { AliasVaultToast } from '@/components/Toast';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import { Gender } from '@/utils/shared/identity-generator';
import { IdentityGeneratorEn } from '@/utils/shared/identity-generator';
import { IdentityGeneratorNl } from '@/utils/shared/identity-generator';
import { PasswordGenerator } from '@/utils/shared/password-generator';
import { BaseIdentityGenerator } from '@/utils/shared/identity-generator';
type CredentialMode = 'random' | 'manual';
export default function AddEditCredentialScreen() {
@@ -163,30 +168,59 @@ export default function AddEditCredentialScreen() {
}
};
const generateRandomValues = () : Credential => {
// Placeholder for random generation - will be replaced with actual generators
const randomString = (length: number) => Math.random().toString(36).substring(2, length + 2);
const generateRandomValues = async () : Promise<Credential> => {
try {
console.log('Generating random values');
// Get default identity language and password settings from database
const identityLanguage = await dbContext.sqliteClient!.getDefaultIdentityLanguage();
const passwordSettings = await dbContext.sqliteClient!.getPasswordSettings();
const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain();
// Get the current credential
const updatedCredential = credential;
// Initialize identity generator based on language
let identityGenerator: BaseIdentityGenerator;
switch (identityLanguage) {
case 'nl':
identityGenerator = new IdentityGeneratorNl();
break;
case 'en':
default:
identityGenerator = new IdentityGeneratorEn();
break;
}
// Assign random values to all fields
updatedCredential.Username = randomString(8);
updatedCredential.Password = randomString(12);
// Generate random identity
const identity = await identityGenerator.generateRandomIdentity();
updatedCredential.Alias = {
...(updatedCredential.Alias ?? {}),
Email: `${randomString(8)}@example.com`,
FirstName: randomString(6),
LastName: randomString(6),
NickName: randomString(6),
Gender: Math.random() > 0.5 ? Gender.Male : Gender.Female,
BirthDate: '0001-01-01 00:00:00',
};
// Initialize password generator with settings
const passwordGenerator = new PasswordGenerator(passwordSettings);
const password = passwordGenerator.generateRandomPassword();
setCredential(updatedCredential);
// Create email with domain if available
const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
return updatedCredential as Credential;
// Assign generated values
const updatedCredential: Partial<Credential> = {
...credential,
Username: identity.nickName,
Password: password,
Alias: {
...(credential.Alias ?? {}),
Email: email,
FirstName: identity.firstName,
LastName: identity.lastName,
NickName: identity.nickName,
Gender: identity.gender,
BirthDate: identity.birthDate.toISOString(),
}
};
setCredential(updatedCredential);
return updatedCredential as Credential;
} catch (error) {
console.error('Error generating random values:', error);
throw error;
}
};
const handleSave = async () => {
@@ -197,7 +231,7 @@ export default function AddEditCredentialScreen() {
// If mode is random, generate random values for all fields before saving.
if (mode === 'random') {
console.log('Generating random values');
credentialToSave = generateRandomValues();
credentialToSave = await generateRandomValues();
}
await executeVaultMutation(async () => {

View File

@@ -162,7 +162,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
clearDatabase,
getVaultMetadata,
testDatabaseConnection,
unlockVault
unlockVault,
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, getVaultMetadata, testDatabaseConnection, unlockVault]);
return (

View File

@@ -49,6 +49,54 @@ class SqliteClient {
}
}
/**
* Get the default email domain from the vault metadata.
* Returns the first valid private domain if available, otherwise the first valid public domain.
* Returns null if no valid domains are found.
*/
public async getDefaultEmailDomain(): Promise<string | null> {
try {
const metadata = await this.getVaultMetadata();
if (!metadata) {
return null;
}
const { privateEmailDomains, publicEmailDomains } = metadata;
// Check if a domain is valid (not empty, not 'DISABLED.TLD', and exists in either private or public domains)
const isValidDomain = (domain: string): boolean => {
return Boolean(domain &&
domain !== 'DISABLED.TLD' &&
(privateEmailDomains?.includes(domain) || publicEmailDomains?.includes(domain)));
};
// Get the default email domain from vault settings
const defaultEmailDomain = await this.getSetting('DefaultEmailDomain');
// First check if the default domain that is configured in the vault is still valid
if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) {
return defaultEmailDomain;
}
// If default domain is not valid, fall back to first available private domain
const firstPrivate = privateEmailDomains?.find(isValidDomain);
if (firstPrivate) {
return firstPrivate;
}
// Return first valid public domain if no private domains are available
const firstPublic = publicEmailDomains?.find(isValidDomain);
if (firstPublic) {
return firstPublic;
}
return null;
} catch (error) {
console.error('Error getting default email domain:', error);
return null;
}
}
/**
* Store the encryption key in the native keychain
*/
@@ -308,13 +356,6 @@ class SqliteClient {
return results.length > 0 ? results[0].Value : defaultValue;
}
/**
* Get the default email domain from the database.
*/
public async getDefaultEmailDomain(): Promise<string> {
return this.getSetting('DefaultEmailDomain');
}
/**
* Get the default identity language from the database.
*/

View File

@@ -1,130 +0,0 @@
import { Identity } from "./types/Identity";
/**
* Generate a username or email prefix.
*/
export class UsernameEmailGenerator {
private static readonly MIN_LENGTH = 6;
private static readonly MAX_LENGTH = 20;
private readonly symbols: string[] = ['.', '-'];
/**
* Generate a username based on an identity.
*/
public generateUsername(identity: Identity): string {
// Generate username based on email prefix but strip all non-alphanumeric characters
let username = this.generateEmailPrefix(identity);
username = username.replace(/[^a-zA-Z0-9]/g, '');
// Adjust length
if (username.length < UsernameEmailGenerator.MIN_LENGTH) {
username += this.generateRandomString(UsernameEmailGenerator.MIN_LENGTH - username.length);
} else if (username.length > UsernameEmailGenerator.MAX_LENGTH) {
username = username.substring(0, UsernameEmailGenerator.MAX_LENGTH);
}
return username;
}
/**
* Generate an email prefix based on an identity.
*/
public generateEmailPrefix(identity: Identity): string {
const parts: string[] = [];
switch (this.getSecureRandom(4)) {
case 0:
// First initial + last name
parts.push(identity.firstName.substring(0, 1).toLowerCase() + identity.lastName.toLowerCase());
break;
case 1:
// Full name
parts.push((identity.firstName + identity.lastName).toLowerCase());
break;
case 2:
// First name + last initial
parts.push(identity.firstName.toLowerCase() + identity.lastName.substring(0, 1).toLowerCase());
break;
case 3:
// First 3 chars of first name + last name
parts.push(identity.firstName.substring(0, Math.min(3, identity.firstName.length)).toLowerCase() + identity.lastName.toLowerCase());
break;
}
// Add birth year variations
if (this.getSecureRandom(3) !== 0) {
switch (this.getSecureRandom(2)) {
case 0:
parts.push(identity.birthDate.getFullYear().toString().substring(2));
break;
case 1:
parts.push(identity.birthDate.getFullYear().toString());
break;
}
} else if (this.getSecureRandom(2) === 0) {
// Add random numbers for more uniqueness
parts.push((this.getSecureRandom(990) + 10).toString());
}
// Join parts with random symbols, possibly multiple
let emailPrefix = parts.join(this.getRandomSymbol());
// Add extra random symbol at random position
if (this.getSecureRandom(2) === 0) {
const position = this.getSecureRandom(emailPrefix.length);
emailPrefix = emailPrefix.slice(0, position) + this.getRandomSymbol() + emailPrefix.slice(position);
}
emailPrefix = this.sanitizeEmailPrefix(emailPrefix);
// Adjust length
if (emailPrefix.length < UsernameEmailGenerator.MIN_LENGTH) {
emailPrefix += this.generateRandomString(UsernameEmailGenerator.MIN_LENGTH - emailPrefix.length);
} else if (emailPrefix.length > UsernameEmailGenerator.MAX_LENGTH) {
emailPrefix = emailPrefix.substring(0, UsernameEmailGenerator.MAX_LENGTH);
}
return emailPrefix;
}
/**
* Sanitize an email prefix.
*/
private sanitizeEmailPrefix(input: string): string {
// Remove any character that's not a letter, number, dot, underscore, or hyphen including special characters
let sanitized = input.replace(/[^a-zA-Z0-9._-]/g, '');
// Remove consecutive dots, underscores, or hyphens
sanitized = sanitized.replace(/[-_.]{2,}/g, (match) => match[0]);
// Remove leading and trailing dots, underscores, or hyphens
sanitized = sanitized.replace(/^[-._]+/, ''); // Remove from start
sanitized = sanitized.replace(/[-._]*$/, ''); // Remove from end
return sanitized;
}
/**
* Get a random symbol.
*/
private getRandomSymbol(): string {
return this.getSecureRandom(3) === 0 ? this.symbols[this.getSecureRandom(this.symbols.length)] : '';
}
/**
* Generate a random string.
*/
private generateRandomString(length: number): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
return Array.from({ length }, () => chars.charAt(this.getSecureRandom(chars.length))).join('');
}
/**
* Generate a secure random integer between 0 (inclusive) and max (exclusive)
*/
private getSecureRandom(max: number): number {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
return array[0] % max;
}
}

View File

@@ -1,54 +0,0 @@
import { IdentityGeneratorEn } from '../implementations/IdentityGeneratorEn';
import { IdentityGeneratorNl } from '../implementations/IdentityGeneratorNl';
import { describe, it, expect } from 'vitest';
import { IIdentityGenerator } from '../interfaces/IIdentityGenerator';
import { Gender } from '../types/Gender';
/**
* Test the identity generator.
*/
const testIdentityGenerator = (
language: string,
generator: IIdentityGenerator
) : void => {
describe(`IdentityGenerator${language}`, () => {
describe('generateRandomIdentity', () => {
it('should generate a random gender identity when no gender is specified', async () => {
const identity = await generator.generateRandomIdentity();
expect(identity).toBeDefined();
expect(identity.firstName).toBeTruthy();
expect(identity.lastName).toBeTruthy();
expect([Gender.Male, Gender.Female]).toContain(identity.gender);
});
it('should generate unique identities on subsequent calls', async () => {
const identity1 = await generator.generateRandomIdentity();
const identity2 = await generator.generateRandomIdentity();
expect(identity1).not.toEqual(identity2);
});
it('should generate an identity with all non-empty fields', async () => {
const identity = await generator.generateRandomIdentity();
Object.entries(identity).forEach(([, value]) => {
expect(value).toBeTruthy();
expect(value).not.toBe('');
expect(value).not.toBeNull();
expect(value).not.toBeUndefined();
});
// Check if the first and last names are longer than 1 character.
expect(identity.firstName.length).toBeGreaterThan(1);
expect(identity.lastName.length).toBeGreaterThan(1);
});
});
});
};
// Run tests for each language implementation
describe('Identity Generators', () => {
testIdentityGenerator('En', new IdentityGeneratorEn());
testIdentityGenerator('Nl', new IdentityGeneratorNl());
});

View File

@@ -1,39 +0,0 @@
import { BaseIdentityGenerator } from "./base/BaseIdentityGenerator";
/**
* Identity generator for English language using English word dictionaries.
*/
export class IdentityGeneratorEn extends BaseIdentityGenerator {
/**
* Get the male first names.
*/
protected getFirstNamesMaleJson(): string[] {
/*
* This is a placeholder for the dictionary-loader to replace with the actual data.
* See vite-plugins/dictionary-loader.ts for more information.
*/
return '__FIRSTNAMES_MALE_EN__';
}
/**
* Get the female first names.
*/
protected getFirstNamesFemaleJson(): string[] {
/*
* This is a placeholder for the dictionary-loader to replace with the actual data.
* See vite-plugins/dictionary-loader.ts for more information.
*/
return '__FIRSTNAMES_FEMALE_EN__';
}
/**
* Get the last names.
*/
protected getLastNamesJson(): string[] {
/*
* This is a placeholder for the dictionary-loader to replace with the actual data.
* See vite-plugins/dictionary-loader.ts for more information.
*/
return '__LASTNAMES_EN__';
}
}

View File

@@ -1,39 +0,0 @@
import { BaseIdentityGenerator } from "./base/BaseIdentityGenerator";
/**
* Identity generator for Dutch language using Dutch word dictionaries.
*/
export class IdentityGeneratorNl extends BaseIdentityGenerator {
/**
* Get the male first names.
*/
protected getFirstNamesMaleJson(): string[] {
/*
* This is a placeholder for the dictionary-loader to replace with the actual data.
* See vite-plugins/dictionary-loader.ts for more information.
*/
return '__FIRSTNAMES_MALE_NL__';
}
/**
* Get the female first names.
*/
protected getFirstNamesFemaleJson(): string[] {
/*
* This is a placeholder for the dictionary-loader to replace with the actual data.
* See vite-plugins/dictionary-loader.ts for more information.
*/
return '__FIRSTNAMES_FEMALE_NL__';
}
/**
* Get the last names.
*/
protected getLastNamesJson(): string[] {
/*
* This is a placeholder for the dictionary-loader to replace with the actual data.
* See vite-plugins/dictionary-loader.ts for more information.
*/
return '__LASTNAMES_NL__';
}
}

View File

@@ -1,75 +0,0 @@
import { UsernameEmailGenerator } from '../../UsernameEmailGenerator';
import { Gender } from '../../types/Gender';
import { IIdentityGenerator } from '../../interfaces/IIdentityGenerator';
import { Identity } from '../../types/Identity';
/**
* Base identity generator.
*/
export abstract class BaseIdentityGenerator implements IIdentityGenerator {
protected firstNamesMale: string[] = [];
protected firstNamesFemale: string[] = [];
protected lastNames: string[] = [];
private readonly random = Math.random;
/**
* Constructor.
*/
public constructor() {
// Each implementing class should provide these as static JSON strings
this.firstNamesMale = this.getFirstNamesMaleJson();
this.firstNamesFemale = this.getFirstNamesFemaleJson();
this.lastNames = this.getLastNamesJson();
}
protected abstract getFirstNamesMaleJson(): string[];
protected abstract getFirstNamesFemaleJson(): string[];
protected abstract getLastNamesJson(): string[];
/**
* Generate a random date of birth.
*/
protected generateRandomDateOfBirth(): Date {
const today = new Date();
const minAge = 21;
const maxAge = 65;
const minDate = new Date(today.getFullYear() - maxAge, today.getMonth(), today.getDate());
const maxDate = new Date(today.getFullYear() - minAge, today.getMonth(), today.getDate());
const timestamp = minDate.getTime() + (this.random() * (maxDate.getTime() - minDate.getTime()));
return new Date(timestamp);
}
/**
* Generate a random identity.
*/
public async generateRandomIdentity(): Promise<Identity> {
const identity: Identity = {
firstName: '',
lastName: '',
gender: Gender.Male,
birthDate: new Date(),
emailPrefix: '',
nickName: ''
};
// Determine gender
if (this.random() < 0.5) {
identity.firstName = this.firstNamesMale[Math.floor(this.random() * this.firstNamesMale.length)];
identity.gender = Gender.Male;
} else {
identity.firstName = this.firstNamesFemale[Math.floor(this.random() * this.firstNamesFemale.length)];
identity.gender = Gender.Female;
}
identity.lastName = this.lastNames[Math.floor(this.random() * this.lastNames.length)];
identity.birthDate = this.generateRandomDateOfBirth();
const generator = new UsernameEmailGenerator();
identity.emailPrefix = generator.generateEmailPrefix(identity);
identity.nickName = generator.generateUsername(identity);
return identity;
}
}

View File

@@ -1,5 +0,0 @@
import { Identity } from "../types/Identity";
export interface IIdentityGenerator {
generateRandomIdentity(): Promise<Identity>;
}

View File

@@ -1,5 +0,0 @@
export enum Gender {
Male = 'Male',
Female = 'Female',
Other = 'Other'
}

View File

@@ -1,13 +0,0 @@
import { Gender } from './Gender';
/**
* Identity.
*/
export type Identity = {
firstName: string;
lastName: string;
gender: Gender;
birthDate: Date;
emailPrefix: string;
nickName: string;
}

View File

@@ -1,259 +0,0 @@
import { PasswordSettings } from '../../types/PasswordSettings';
/**
* Generate a random password.
*/
export class PasswordGenerator {
private readonly lowercaseChars = 'abcdefghijklmnopqrstuvwxyz';
private readonly uppercaseChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
private readonly numberChars = '0123456789';
private readonly specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?';
private readonly ambiguousChars = 'Il1O0';
private length: number = 18;
private useLowercase: boolean = true;
private useUppercase: boolean = true;
private useNumbers: boolean = true;
private useSpecial: boolean = true;
private useNonAmbiguous: boolean = false;
/**
* Create a new instance of PasswordGenerator.
* @param settings Optional password settings to initialize with.
*/
public constructor(settings?: PasswordSettings) {
if (settings) {
this.applySettings(settings);
}
}
/**
* Apply password settings to this generator.
*/
public applySettings(settings: PasswordSettings): this {
this.length = settings.Length;
this.useLowercase = settings.UseLowercase;
this.useUppercase = settings.UseUppercase;
this.useNumbers = settings.UseNumbers;
this.useSpecial = settings.UseSpecialChars;
this.useNonAmbiguous = settings.UseNonAmbiguousChars;
return this;
}
/**
* Set the length of the password.
*/
public setLength(length: number): this {
this.length = length;
return this;
}
/**
* Set if lowercase letters should be used.
*/
public useLowercaseLetters(use: boolean): this {
this.useLowercase = use;
return this;
}
/**
* Set if uppercase letters should be used.
*/
public useUppercaseLetters(use: boolean): this {
this.useUppercase = use;
return this;
}
/**
* Set if numeric characters should be used.
*/
public useNumericCharacters(use: boolean): this {
this.useNumbers = use;
return this;
}
/**
* Set if special characters should be used.
*/
public useSpecialCharacters(use: boolean): this {
this.useSpecial = use;
return this;
}
/**
* Set if only non-ambiguous characters should be used.
*/
public useNonAmbiguousCharacters(use: boolean): this {
this.useNonAmbiguous = use;
return this;
}
/**
* Get a random index from the crypto module.
*/
private getUnbiasedRandomIndex(max: number): number {
// Calculate the largest multiple of max that fits within Uint32.
const limit = Math.floor((2 ** 32) / max) * max;
while (true) {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
const value = array[0];
// Reject values that would introduce bias.
if (value < limit) {
return value % max;
}
}
}
/**
* Generate a random password.
*/
public generateRandomPassword(): string {
// Build the character set based on settings
const chars = this.buildCharacterSet();
// Generate initial password.
let password = this.generateInitialPassword(chars);
// Ensure a character from each set is present as some websites require this.
password = this.ensureRequirements(password);
return password;
}
/**
* Build character set based on selected options.
*/
private buildCharacterSet(): string {
let chars = '';
if (this.useLowercase) {
chars += this.lowercaseChars;
}
if (this.useUppercase) {
chars += this.uppercaseChars;
}
if (this.useNumbers) {
chars += this.numberChars;
}
if (this.useSpecial) {
chars += this.specialChars;
}
// Ensure at least one character set is selected, otherwise default to lowercase.
if (chars.length === 0) {
chars = this.lowercaseChars;
}
// Remove ambiguous characters if needed.
if (this.useNonAmbiguous) {
chars = this.removeAmbiguousCharacters(chars);
}
return chars;
}
/**
* Remove ambiguous characters from a character set.
*/
private removeAmbiguousCharacters(chars: string): string {
for (const ambChar of this.ambiguousChars) {
chars = chars.replace(ambChar, '');
}
return chars;
}
/**
* Generate initial random password.
*/
private generateInitialPassword(chars: string): string {
let password = '';
for (let i = 0; i < this.length; i++) {
password += chars[this.getUnbiasedRandomIndex(chars.length)];
}
return password;
}
/**
* Ensure the generated password meets all specified requirements.
*/
private ensureRequirements(password: string): string {
if (this.useLowercase && !/[a-z]/.exec(password)) {
password = this.addCharacterFromSet(
password,
this.getSafeCharacterSet(this.lowercaseChars, true)
);
}
if (this.useUppercase && !/[A-Z]/.exec(password)) {
password = this.addCharacterFromSet(
password,
this.getSafeCharacterSet(this.uppercaseChars, true)
);
}
if (this.useNumbers && !/\d/.exec(password)) {
password = this.addCharacterFromSet(
password,
this.getSafeCharacterSet(this.numberChars, false)
);
}
if (this.useSpecial && !/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/.exec(password)) {
password = this.addCharacterFromSet(
password,
this.specialChars
);
}
return password;
}
/**
* Get a character set with ambiguous characters removed if needed.
*/
private getSafeCharacterSet(charSet: string, isAlpha: boolean): string {
// If we're not using non-ambiguous characters, just return the original set.
if (!this.useNonAmbiguous) {
return charSet;
}
let safeSet = charSet;
for (const ambChar of this.ambiguousChars) {
// For numeric sets, only process numeric ambiguous characters
if (!isAlpha && !/\d/.test(ambChar)) {
continue;
}
let charToRemove = ambChar;
// Handle case conversion for alphabetic characters.
if (isAlpha) {
if (charSet === this.lowercaseChars) {
charToRemove = ambChar.toLowerCase();
} else {
charToRemove = ambChar.toUpperCase();
}
}
safeSet = safeSet.replace(charToRemove, '');
}
return safeSet;
}
/**
* Add a character from the given set at a random position in the password.
*/
private addCharacterFromSet(password: string, charSet: string): string {
const pos = this.getUnbiasedRandomIndex(this.length);
const char = charSet[this.getUnbiasedRandomIndex(charSet.length)];
return password.substring(0, pos) + char + password.substring(pos + 1);
}
}

View File

@@ -1,106 +0,0 @@
import { PasswordGenerator } from '../PasswordGenerator';
import { describe, it, expect, beforeEach } from 'vitest';
describe('PasswordGenerator', () => {
let generator: PasswordGenerator;
beforeEach(() => {
generator = new PasswordGenerator();
});
it('generates password with default settings', () => {
const password = generator.generateRandomPassword();
// Default length is 18
expect(password.length).toBe(18);
// Should contain at least one of each character type by default
expect(password).toMatch(/[a-z]/); // lowercase
expect(password).toMatch(/[A-Z]/); // uppercase
expect(password).toMatch(/[0-9]/); // numbers
expect(password).toMatch(/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/); // special
});
it('respects custom length setting', () => {
const customLength = 24;
const password = generator.setLength(customLength).generateRandomPassword();
expect(password.length).toBe(customLength);
});
it('respects lowercase setting', () => {
const password = generator.useLowercaseLetters(false).generateRandomPassword();
expect(password).not.toMatch(/[a-z]/);
});
it('respects uppercase setting', () => {
const password = generator.useUppercaseLetters(false).generateRandomPassword();
expect(password).not.toMatch(/[A-Z]/);
});
it('respects numbers setting', () => {
const password = generator.useNumericCharacters(false).generateRandomPassword();
expect(password).not.toMatch(/[0-9]/);
});
it('respects special characters setting', () => {
const password = generator.useSpecialCharacters(false).generateRandomPassword();
expect(password).not.toMatch(/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/);
});
it('generates different passwords on subsequent calls', () => {
const password1 = generator.generateRandomPassword();
const password2 = generator.generateRandomPassword();
expect(password1).not.toBe(password2);
});
it('handles minimum character requirements', () => {
let hasLower = false;
let hasUpper = false;
let hasNumber = false;
let hasSpecial = false;
// Generate 20 passwords and check if at least one contains all required characters
for (let i = 0; i < 20; i++) {
const password = generator.generateRandomPassword();
hasLower = hasLower || /[a-z]/.test(password);
hasUpper = hasUpper || /[A-Z]/.test(password);
hasNumber = hasNumber || /[0-9]/.test(password);
hasSpecial = hasSpecial || /[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/.test(password);
// Break early if we've found all character types
if (hasLower && hasUpper && hasNumber && hasSpecial) {
break;
}
}
// Assert that we found at least one password with each character type
expect(hasLower).toBe(true);
expect(hasUpper).toBe(true);
expect(hasNumber).toBe(true);
expect(hasSpecial).toBe(true);
});
it('falls back to lowercase when all options disabled', () => {
const password = generator
.useLowercaseLetters(false)
.useUppercaseLetters(false)
.useNumericCharacters(false)
.useSpecialCharacters(false)
.generateRandomPassword();
// Should fall back to lowercase
expect(password).toMatch(/^[a-z]+$/);
});
it('maintains method chaining', () => {
const result = generator
.setLength(20)
.useLowercaseLetters(true)
.useUppercaseLetters(true)
.useNumericCharacters(true)
.useSpecialCharacters(true);
expect(result).toBe(generator);
});
});