diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 30b2578889..8f3c29e6a7 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -120,6 +120,7 @@ import { CollaborationService } from './collaboration/collaboration.service'; import { RoleController } from './controllers/role.controller'; import { BadRequestError } from './errors/response-errors/bad-request.error'; import { NotFoundError } from './errors/response-errors/not-found.error'; +import { PasswordUtility } from './services/password.utility'; const exec = promisify(callbackExec); @@ -264,6 +265,7 @@ export class Server extends AbstractServer { internalHooks, Container.get(SettingsRepository), userService, + Container.get(PasswordUtility), postHog, ), Container.get(MeController), @@ -298,6 +300,7 @@ export class Server extends AbstractServer { externalHooks, Container.get(UserService), Container.get(License), + Container.get(PasswordUtility), postHog, ), Container.get(VariablesController), diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 17ac7509b4..dd53a76324 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -1,17 +1,13 @@ import { In } from 'typeorm'; -import { compare, genSaltSync, hash } from 'bcryptjs'; import { Container } from 'typedi'; import type { WhereClause } from '@/Interfaces'; import type { User } from '@db/entities/User'; -import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User'; import config from '@/config'; import { License } from '@/License'; import { getWebhookBaseUrl } from '@/WebhookHelpers'; import { UserRepository } from '@db/repositories/user.repository'; import type { Scope } from '@n8n/permissions'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ApplicationError } from 'n8n-workflow'; export function isSharingEnabled(): boolean { return Container.get(License).isSharingEnabled(); @@ -30,42 +26,6 @@ export function generateUserInviteUrl(inviterId: string, inviteeId: string): str return `${getInstanceBaseUrl()}/signup?inviterId=${inviterId}&inviteeId=${inviteeId}`; } -// TODO: Enforce at model level -export function validatePassword(password?: string): string { - if (!password) { - throw new BadRequestError('Password is mandatory'); - } - - const hasInvalidLength = - password.length < MIN_PASSWORD_LENGTH || password.length > MAX_PASSWORD_LENGTH; - - const hasNoNumber = !/\d/.test(password); - - const hasNoUppercase = !/[A-Z]/.test(password); - - if (hasInvalidLength || hasNoNumber || hasNoUppercase) { - const message: string[] = []; - - if (hasInvalidLength) { - message.push( - `Password must be ${MIN_PASSWORD_LENGTH} to ${MAX_PASSWORD_LENGTH} characters long.`, - ); - } - - if (hasNoNumber) { - message.push('Password must contain at least 1 number.'); - } - - if (hasNoUppercase) { - message.push('Password must contain at least 1 uppercase letter.'); - } - - throw new BadRequestError(message.join(' ')); - } - - return password; -} - export async function getUserById(userId: string): Promise { const user = await Container.get(UserRepository).findOneOrFail({ where: { id: userId }, @@ -74,28 +34,6 @@ export async function getUserById(userId: string): Promise { return user; } -// ---------------------------------- -// hashing -// ---------------------------------- - -export const hashPassword = async (validPassword: string): Promise => - hash(validPassword, genSaltSync(10)); - -export async function compareHash(plaintext: string, hashed: string): Promise { - try { - return await compare(plaintext, hashed); - } catch (e) { - const error = e instanceof Error ? e : new Error(`${e}`); - - if (error instanceof Error && error.message.includes('Invalid salt version')) { - error.message += - '. Comparison against unhashed string. Please check that the value compared against has been hashed.'; - } - - throw new ApplicationError(error.message, { cause: error }); - } -} - // return the difference between two arrays export function rightDiff( [arr1, keyExtractor1]: [T1[], (item: T1) => string], diff --git a/packages/cli/src/auth/methods/email.ts b/packages/cli/src/auth/methods/email.ts index 7850441697..9775c1959d 100644 --- a/packages/cli/src/auth/methods/email.ts +++ b/packages/cli/src/auth/methods/email.ts @@ -1,5 +1,5 @@ import type { User } from '@db/entities/User'; -import { compareHash } from '@/UserManagement/UserManagementHelper'; +import { PasswordUtility } from '@/services/password.utility'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; import { isLdapLoginEnabled } from '@/Ldap/helpers'; @@ -15,7 +15,7 @@ export const handleEmailLogin = async ( relations: ['globalRole', 'authIdentities'], }); - if (user?.password && (await compareHash(password, user.password))) { + if (user?.password && (await Container.get(PasswordUtility).compare(password, user.password))) { return user; } diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index a20c10e7c1..7d4e4fbf61 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -110,3 +110,7 @@ export const TIME = { HOUR: 60 * 60 * 1000, DAY: 24 * 60 * 60 * 1000, }; + +export const MIN_PASSWORD_CHAR_LENGTH = 8; + +export const MAX_PASSWORD_CHAR_LENGTH = 64; diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 8a231b0864..ddc6e7f2e6 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -7,7 +7,6 @@ import { RoleRepository } from '@db/repositories/role.repository'; import { SettingsRepository } from '@db/repositories/settings.repository'; import { UserRepository } from '@db/repositories/user.repository'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; -import { hashPassword } from '@/UserManagement/UserManagementHelper'; import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { License } from '@/License'; import { LICENSE_FEATURES, inE2ETests } from '@/constants'; @@ -17,6 +16,7 @@ import type { BooleanLicenseFeature, IPushDataType } from '@/Interfaces'; import { MfaService } from '@/Mfa/mfa.service'; import { Push } from '@/push'; import { CacheService } from '@/services/cache.service'; +import { PasswordUtility } from '@/services/password.utility'; if (!inE2ETests) { console.error('E2E endpoints only allowed during E2E tests'); @@ -95,6 +95,7 @@ export class E2EController { private workflowRunner: ActiveWorkflowRunner, private mfaService: MfaService, private cacheService: CacheService, + private readonly passwordUtility: PasswordUtility, ) { license.isFeatureEnabled = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false; @@ -187,7 +188,7 @@ export class E2EController { const instanceOwner = { id: uuid(), ...owner, - password: await hashPassword(owner.password), + password: await this.passwordUtility.hash(owner.password), globalRoleId: globalOwnerRoleId, }; @@ -201,7 +202,7 @@ export class E2EController { const adminUser = { id: uuid(), ...admin, - password: await hashPassword(admin.password), + password: await this.passwordUtility.hash(admin.password), globalRoleId: globalAdminRoleId, }; @@ -214,7 +215,7 @@ export class E2EController { this.userRepo.create({ id: uuid(), ...payload, - password: await hashPassword(password), + password: await this.passwordUtility.hash(password), globalRoleId: globalMemberRoleId, }), ); diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index 2c298a2687..1bd2fb104a 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -11,7 +11,7 @@ import { License } from '@/License'; import { UserService } from '@/services/user.service'; import { Logger } from '@/Logger'; import { isSamlLicensedAndEnabled } from '@/sso/saml/samlHelpers'; -import { hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper'; +import { PasswordUtility } from '@/services/password.utility'; import { PostHogClient } from '@/posthog'; import type { User } from '@/databases/entities/User'; import validator from 'validator'; @@ -29,6 +29,7 @@ export class InvitationController { private readonly externalHooks: IExternalHooksClass, private readonly userService: UserService, private readonly license: License, + private readonly passwordUtility: PasswordUtility, private readonly postHog?: PostHogClient, ) {} @@ -133,7 +134,7 @@ export class InvitationController { throw new BadRequestError('Invalid payload'); } - const validPassword = validatePassword(password); + const validPassword = this.passwordUtility.validate(password); const users = await this.userService.findMany({ where: { id: In([inviterId, inviteeId]) }, @@ -163,7 +164,7 @@ export class InvitationController { invitee.firstName = firstName; invitee.lastName = lastName; - invitee.password = await hashPassword(validPassword); + invitee.password = await this.passwordUtility.hash(validPassword); const updatedUser = await this.userService.save(invitee); diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index c7d05cb8cb..66a2da0d8a 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -4,7 +4,7 @@ import { Response } from 'express'; import { Service } from 'typedi'; import { randomBytes } from 'crypto'; import { Authorized, Delete, Get, Patch, Post, RestController } from '@/decorators'; -import { compareHash, hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper'; +import { PasswordUtility } from '@/services/password.utility'; import { validateEntity } from '@/GenericHelpers'; import { issueCookie } from '@/auth/jwt'; import type { User } from '@db/entities/User'; @@ -31,6 +31,7 @@ export class MeController { private readonly externalHooks: ExternalHooks, private readonly internalHooks: InternalHooks, private readonly userService: UserService, + private readonly passwordUtility: PasswordUtility, ) {} /** @@ -119,14 +120,17 @@ export class MeController { throw new BadRequestError('Requesting user not set up.'); } - const isCurrentPwCorrect = await compareHash(currentPassword, req.user.password); + const isCurrentPwCorrect = await this.passwordUtility.compare( + currentPassword, + req.user.password, + ); if (!isCurrentPwCorrect) { throw new BadRequestError('Provided current password is incorrect.'); } - const validPassword = validatePassword(newPassword); + const validPassword = this.passwordUtility.validate(newPassword); - req.user.password = await hashPassword(validPassword); + req.user.password = await this.passwordUtility.hash(validPassword); const user = await this.userService.save(req.user); this.logger.info('Password updated successfully', { userId: user.id }); diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 18fd62d858..ef3e9ecbed 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -1,7 +1,7 @@ import validator from 'validator'; import { validateEntity } from '@/GenericHelpers'; import { Authorized, Post, RestController } from '@/decorators'; -import { hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper'; +import { PasswordUtility } from '@/services/password.utility'; import { issueCookie } from '@/auth/jwt'; import { Response } from 'express'; import { Config } from '@/config'; @@ -22,6 +22,7 @@ export class OwnerController { private readonly internalHooks: IInternalHooksClass, private readonly settingsRepository: SettingsRepository, private readonly userService: UserService, + private readonly passwordUtility: PasswordUtility, private readonly postHog?: PostHogClient, ) {} @@ -52,7 +53,7 @@ export class OwnerController { throw new BadRequestError('Invalid email address'); } - const validPassword = validatePassword(password); + const validPassword = this.passwordUtility.validate(password); if (!firstName || !lastName) { this.logger.debug( @@ -79,7 +80,7 @@ export class OwnerController { email, firstName, lastName, - password: await hashPassword(validPassword), + password: await this.passwordUtility.hash(validPassword), }); await validateEntity(owner); diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index f0429748aa..463e46d9ca 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -5,11 +5,8 @@ import { IsNull, Not } from 'typeorm'; import validator from 'validator'; import { Get, Post, RestController } from '@/decorators'; -import { - getInstanceBaseUrl, - hashPassword, - validatePassword, -} from '@/UserManagement/UserManagementHelper'; +import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; +import { PasswordUtility } from '@/services/password.utility'; import { UserManagementMailer } from '@/UserManagement/email'; import { PasswordResetRequest } from '@/requests'; import { issueCookie } from '@/auth/jwt'; @@ -45,6 +42,7 @@ export class PasswordResetController { private readonly userService: UserService, private readonly mfaService: MfaService, private readonly license: License, + private readonly passwordUtility: PasswordUtility, ) {} /** @@ -204,7 +202,7 @@ export class PasswordResetController { throw new BadRequestError('Missing user ID or password or reset password token'); } - const validPassword = validatePassword(password); + const validPassword = this.passwordUtility.validate(password); const user = await this.userService.resolvePasswordResetToken(token); if (!user) throw new NotFoundError(''); @@ -219,7 +217,7 @@ export class PasswordResetController { if (!validToken) throw new BadRequestError('Invalid MFA token.'); } - const passwordHash = await hashPassword(validPassword); + const passwordHash = await this.passwordUtility.hash(validPassword); await this.userService.update(user.id, { password: passwordHash }); diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 12ffcb31b4..d3d8a7122d 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -23,10 +23,6 @@ import type { AuthIdentity } from './AuthIdentity'; import { ownerPermissions, memberPermissions, adminPermissions } from '@/permissions/roles'; import { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions'; -export const MIN_PASSWORD_LENGTH = 8; - -export const MAX_PASSWORD_LENGTH = 64; - const STATIC_SCOPE_MAP: Record = { owner: ownerPermissions, member: memberPermissions, diff --git a/packages/cli/src/services/password.utility.ts b/packages/cli/src/services/password.utility.ts new file mode 100644 index 0000000000..d600371e55 --- /dev/null +++ b/packages/cli/src/services/password.utility.ts @@ -0,0 +1,45 @@ +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { Service as Utility } from 'typedi'; +import { compare, genSaltSync, hash } from 'bcryptjs'; +import { + MAX_PASSWORD_CHAR_LENGTH as maxLength, + MIN_PASSWORD_CHAR_LENGTH as minLength, +} from '@/constants'; + +@Utility() +export class PasswordUtility { + async hash(plaintext: string) { + const SALT_ROUNDS = 10; + const salt = genSaltSync(SALT_ROUNDS); + + return hash(plaintext, salt); + } + + async compare(plaintext: string, hashed: string) { + return compare(plaintext, hashed); + } + + validate(plaintext?: string) { + if (!plaintext) throw new BadRequestError('Password is mandatory'); + + const errorMessages: string[] = []; + + if (plaintext.length < minLength || plaintext.length > maxLength) { + errorMessages.push(`Password must be ${minLength} to ${maxLength} characters long.`); + } + + if (!/\d/.test(plaintext)) { + errorMessages.push('Password must contain at least 1 number.'); + } + + if (!/[A-Z]/.test(plaintext)) { + errorMessages.push('Password must contain at least 1 uppercase letter.'); + } + + if (errorMessages.length > 0) { + throw new BadRequestError(errorMessages.join(' ')); + } + + return plaintext; + } +} diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index 5b3f28c25e..87603231a1 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -3,7 +3,7 @@ import config from '@/config'; import { AuthIdentity } from '@db/entities/AuthIdentity'; import { User } from '@db/entities/User'; import { License } from '@/License'; -import { hashPassword } from '@/UserManagement/UserManagementHelper'; +import { PasswordUtility } from '@/services/password.utility'; import type { SamlPreferences } from './types/samlPreferences'; import type { SamlUserAttributes } from './types/samlUserAttributes'; import type { FlowResult } from 'samlify/types/src/flow'; @@ -106,7 +106,7 @@ export async function createUserFromSamlAttributes(attributes: SamlUserAttribute user.lastName = attributes.lastName; user.globalRole = await Container.get(RoleService).findGlobalMemberRole(); // generates a password that is not used or known to the user - user.password = await hashPassword(generatePassword()); + user.password = await Container.get(PasswordUtility).hash(generatePassword()); authIdentity.providerId = attributes.userPrincipalName; authIdentity.providerType = 'saml'; authIdentity.user = user; diff --git a/packages/cli/test/integration/invitations.api.test.ts b/packages/cli/test/integration/invitations.api.test.ts index 47073236f2..a87ddde5e5 100644 --- a/packages/cli/test/integration/invitations.api.test.ts +++ b/packages/cli/test/integration/invitations.api.test.ts @@ -2,7 +2,7 @@ import validator from 'validator'; import type { SuperAgentTest } from 'supertest'; import type { User } from '@db/entities/User'; -import { compareHash } from '@/UserManagement/UserManagementHelper'; +import { PasswordUtility } from '@/services/password.utility'; import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer'; import Container from 'typedi'; @@ -239,7 +239,10 @@ describe('POST /invitations/:id/accept', () => { expect(storedMember.lastName).not.toBe(memberData.lastName); expect(storedMember.password).not.toBe(memberData.password); - const comparisonResult = await compareHash(member.password, storedMember.password); + const comparisonResult = await Container.get(PasswordUtility).compare( + member.password, + storedMember.password, + ); expect(comparisonResult).toBe(false); }); diff --git a/packages/cli/test/integration/passwordReset.api.test.ts b/packages/cli/test/integration/passwordReset.api.test.ts index c507666b93..cf263a890d 100644 --- a/packages/cli/test/integration/passwordReset.api.test.ts +++ b/packages/cli/test/integration/passwordReset.api.test.ts @@ -26,6 +26,7 @@ import { import * as testDb from './shared/testDb'; import { getGlobalMemberRole, getGlobalOwnerRole } from './shared/db/roles'; import { createUser } from './shared/db/users'; +import { PasswordUtility } from '@/services/password.utility'; config.set('userManagement.jwtSecret', randomString(5, 10)); @@ -207,7 +208,10 @@ describe('POST /change-password', () => { id: owner.id, }); - const comparisonResult = await compare(passwordToStore, storedPassword); + const comparisonResult = await Container.get(PasswordUtility).compare( + passwordToStore, + storedPassword, + ); expect(comparisonResult).toBe(true); expect(storedPassword).not.toBe(passwordToStore); diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts index 0972ba5c18..98befbaa75 100644 --- a/packages/cli/test/integration/shared/random.ts +++ b/packages/cli/test/integration/shared/random.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'crypto'; -import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User'; +import { MIN_PASSWORD_CHAR_LENGTH, MAX_PASSWORD_CHAR_LENGTH } from '@/constants'; import type { CredentialPayload } from './types'; import { v4 as uuid } from 'uuid'; @@ -31,14 +31,14 @@ export const randomPositiveDigit = (): number => { const randomUppercaseLetter = () => chooseRandomly('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')); export const randomValidPassword = () => - randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH - 2) + + randomString(MIN_PASSWORD_CHAR_LENGTH, MAX_PASSWORD_CHAR_LENGTH - 2) + randomUppercaseLetter() + randomDigit(); export const randomInvalidPassword = () => chooseRandomly([ - randomString(1, MIN_PASSWORD_LENGTH - 1), - randomString(MAX_PASSWORD_LENGTH + 2, MAX_PASSWORD_LENGTH + 100), + randomString(1, MIN_PASSWORD_CHAR_LENGTH - 1), + randomString(MAX_PASSWORD_CHAR_LENGTH + 2, MAX_PASSWORD_CHAR_LENGTH + 100), 'abcdefgh', // valid length, no number, no uppercase 'abcdefg1', // valid length, has number, no uppercase 'abcdefgA', // valid length, no number, has uppercase diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 0de66cedab..5a76bec24c 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -21,6 +21,7 @@ import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } f import type { SetupProps, TestServer } from '../types'; import { InternalHooks } from '@/InternalHooks'; import { LicenseMocker } from '../license'; +import { PasswordUtility } from '@/services/password.utility'; /** * Plugin to prefix a path segment into a request URL pathname. @@ -229,6 +230,7 @@ export const setupTestServer = ({ Container.get(InternalHooks), Container.get(SettingsRepository), Container.get(UserService), + Container.get(PasswordUtility), ), ); break; @@ -277,6 +279,7 @@ export const setupTestServer = ({ Container.get(EHS), Container.get(USE), Container.get(License), + Container.get(PasswordUtility), ), ); break; diff --git a/packages/cli/test/unit/controllers/owner.controller.test.ts b/packages/cli/test/unit/controllers/owner.controller.test.ts index 1f45e54bb7..6f81f53a5d 100644 --- a/packages/cli/test/unit/controllers/owner.controller.test.ts +++ b/packages/cli/test/unit/controllers/owner.controller.test.ts @@ -14,6 +14,8 @@ import { License } from '@/License'; import { mockInstance } from '../../shared/mocking'; import { badPasswords } from '../shared/testData'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { PasswordUtility } from '@/services/password.utility'; +import Container from 'typedi'; describe('OwnerController', () => { const config = mock(); @@ -27,6 +29,7 @@ describe('OwnerController', () => { internalHooks, settingsRepository, userService, + Container.get(PasswordUtility), ); describe('setupOwner', () => { diff --git a/packages/cli/test/unit/utilities/password.utility.test.ts b/packages/cli/test/unit/utilities/password.utility.test.ts new file mode 100644 index 0000000000..3cab55ae48 --- /dev/null +++ b/packages/cli/test/unit/utilities/password.utility.test.ts @@ -0,0 +1,104 @@ +import { PasswordUtility } from '@/services/password.utility'; +import Container from 'typedi'; + +function toComponents(hash: string) { + const BCRYPT_HASH_REGEX = + /^\$(?.{2})\$(?\d{2})\$(?.{22})(?.{31})$/; + + const match = hash.match(BCRYPT_HASH_REGEX); + + if (!match?.groups) throw new Error('Invalid bcrypt hash format'); + + return match.groups; +} + +describe('PasswordUtility', () => { + const passwordUtility = Container.get(PasswordUtility); + + describe('hash()', () => { + test('should hash a plaintext password', async () => { + const plaintext = 'abcd1234X'; + const hashed = await passwordUtility.hash(plaintext); + + const { version, costFactor, salt, hashedPassword } = toComponents(hashed); + + expect(version).toBe('2a'); + expect(costFactor).toBe('10'); + expect(salt).toHaveLength(22); + expect(hashedPassword).toHaveLength(31); + }); + }); + + describe('compare()', () => { + test('should return true on match', async () => { + const plaintext = 'abcd1234X'; + const hashed = await passwordUtility.hash(plaintext); + + const isMatch = await passwordUtility.compare(plaintext, hashed); + + expect(isMatch).toBe(true); + }); + + test('should return false on mismatch', async () => { + const secondPlaintext = 'abcd1234Y'; + const hashed = await passwordUtility.hash('abcd1234X'); + + const isMatch = await passwordUtility.compare(secondPlaintext, hashed); + + expect(isMatch).toBe(false); + }); + }); + + describe('validate()', () => { + test('should throw on empty password', () => { + const check = () => passwordUtility.validate(); + + expect(check).toThrowError('Password is mandatory'); + }); + + test('should return same password if valid', () => { + const validPassword = 'abcd1234X'; + + const validated = passwordUtility.validate(validPassword); + + expect(validated).toBe(validPassword); + }); + + test('should require at least one uppercase letter', () => { + const invalidPassword = 'abcd1234'; + + const failingCheck = () => passwordUtility.validate(invalidPassword); + + expect(failingCheck).toThrowError('Password must contain at least 1 uppercase letter.'); + }); + + test('should require at least one number', () => { + const validPassword = 'abcd1234X'; + const invalidPassword = 'abcdEFGH'; + + const validated = passwordUtility.validate(validPassword); + + expect(validated).toBe(validPassword); + + const check = () => passwordUtility.validate(invalidPassword); + + expect(check).toThrowError('Password must contain at least 1 number.'); + }); + + test('should require a minimum length of 8 characters', () => { + const invalidPassword = 'a'.repeat(7); + + const check = () => passwordUtility.validate(invalidPassword); + + expect(check).toThrowError('Password must be 8 to 64 characters long.'); + }); + + test('should require a maximum length of 64 characters', () => { + const invalidPassword = 'a'.repeat(65); + + const check = () => passwordUtility.validate(invalidPassword); + + expect(check).toThrowError('Password must be 8 to 64 characters long.'); + }); + }); +});