diff --git a/package-lock.json b/package-lock.json index 410bf504..48863885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9722,6 +9722,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -17421,6 +17422,7 @@ "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, "license": "BSD-2-Clause", "optional": true, "bin": { diff --git a/src/auth/auth.service.captcha.spec.ts b/src/auth/auth.service.captcha.spec.ts index 6d685ec2..7943fa08 100644 --- a/src/auth/auth.service.captcha.spec.ts +++ b/src/auth/auth.service.captcha.spec.ts @@ -184,6 +184,7 @@ describe('AuthService – CAPTCHA failure lockout', () => { password: '$2b$10$invalidhash', isBlocked: false, isDeactivated: false, + isVerified: true, twoFactorEnabled: false, }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index df407871..86f8fcda 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -32,7 +32,6 @@ import { comparePassword, createSha256, generateBackupCodes, - getPasswordHistoryLimit, hashPassword, parseDuration, randomBase32Secret, @@ -116,7 +115,7 @@ export class AuthService { throw new BadRequestException('A user with that email already exists'); } - const passwordErrors = validatePassword(data.password); + const passwordErrors = validatePassword(data.password, this.configService); if (passwordErrors.length > 0) { throw new BadRequestException( `Password does not meet complexity requirements: ${passwordErrors.join('; ')}`, @@ -175,7 +174,7 @@ export class AuthService { * 2. CAPTCHA check: If failed attempts exceed threshold, require CAPTCHA to proceed. * 3. Credentials check: (Performed in the main login method after preflight) */ - private async preflightChecks(data: LoginDto): Promise { + private async preflightChecks(data: LoginDto, ipAddress?: string, userAgent?: string): Promise { // Check if account is locked out const isLocked = await this.rateLimitService.isAccountLocked(data.email); if (isLocked) { @@ -205,7 +204,7 @@ export class AuthService { } async login(data: LoginDto, ipAddress?: string, userAgent?: string) { - await this.preflightChecks(data); + await this.preflightChecks(data, ipAddress, userAgent); const user = await this.usersService.findByEmail(data.email); if (!user) { @@ -704,7 +703,7 @@ export class AuthService { } async changePassword(user: AuthUserPayload, data: ChangePasswordDto) { - const passwordHistoryLimit = getPasswordHistoryLimit(); + const passwordHistoryLimit = this.getPasswordHistoryLimit(); const existingUser = await this.prisma.user.findUnique({ where: { id: user.sub }, include: { @@ -726,7 +725,7 @@ export class AuthService { throw new UnauthorizedException('Current password is incorrect'); } - const passwordErrors = validatePassword(data.newPassword); + const passwordErrors = validatePassword(data.newPassword, this.configService); if (passwordErrors.length > 0) { throw new BadRequestException( `Password does not meet complexity requirements: ${passwordErrors.join('; ')}`, @@ -1335,9 +1334,9 @@ export class AuthService { throw new BadRequestException('Account is blocked'); } - const passwordHistoryLimit = getPasswordHistoryLimit(); + const passwordHistoryLimit = this.getPasswordHistoryLimit(); - const passwordErrors = validatePassword(data.newPassword); + const passwordErrors = validatePassword(data.newPassword, this.configService); if (passwordErrors.length > 0) { throw new BadRequestException( `Password does not meet complexity requirements: ${passwordErrors.join('; ')}`, @@ -1437,6 +1436,11 @@ export class AuthService { }); } + private getPasswordHistoryLimit(): number { + const parsed = Number(this.configService.get('PASSWORD_HISTORY_LIMIT') ?? 5); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 5; + } + private async verifyCaptcha(token: string): Promise { const secret = this.configService.get('RECAPTCHA_SECRET'); if (!secret) { diff --git a/src/auth/password.utils.ts b/src/auth/password.utils.ts index 2fa144b6..183a56a1 100644 --- a/src/auth/password.utils.ts +++ b/src/auth/password.utils.ts @@ -1,5 +1,7 @@ // @ts-nocheck +import { ConfigService } from '@nestjs/config'; + export type PasswordPolicy = { minLength: number; requireUppercase: boolean; @@ -9,22 +11,22 @@ export type PasswordPolicy = { specialChars?: string; }; -export function getPasswordPolicy(): PasswordPolicy { - const minLength = Number(process.env.PASSWORD_MIN_LENGTH ?? 8); +export function getPasswordPolicy(configService: ConfigService): PasswordPolicy { + const minLength = Number(configService.get('PASSWORD_MIN_LENGTH') ?? 8); return { minLength: Number.isFinite(minLength) && minLength > 0 ? minLength : 8, - requireUppercase: (process.env.PASSWORD_REQUIRE_UPPERCASE ?? 'true') === 'true', - requireLowercase: (process.env.PASSWORD_REQUIRE_LOWERCASE ?? 'true') === 'true', - requireDigit: (process.env.PASSWORD_REQUIRE_DIGIT ?? 'true') === 'true', - requireSpecial: (process.env.PASSWORD_REQUIRE_SPECIAL ?? 'true') === 'true', + requireUppercase: (configService.get('PASSWORD_REQUIRE_UPPERCASE') ?? 'true') === 'true', + requireLowercase: (configService.get('PASSWORD_REQUIRE_LOWERCASE') ?? 'true') === 'true', + requireDigit: (configService.get('PASSWORD_REQUIRE_DIGIT') ?? 'true') === 'true', + requireSpecial: (configService.get('PASSWORD_REQUIRE_SPECIAL') ?? 'true') === 'true', specialChars: - process.env.PASSWORD_SPECIAL_CHARS ?? '!@#$%^&*()_+-=[]{}|;:\",./<>?'.slice(0, 32), + configService.get('PASSWORD_SPECIAL_CHARS') ?? '!@#$%^&*()_+-=[]{}|;:\",./<>?'.slice(0, 32), }; } -export function validatePassword(password: string): string[] { +export function validatePassword(password: string, configService: ConfigService): string[] { const errors: string[] = []; - const policy = getPasswordPolicy(); + const policy = getPasswordPolicy(configService); if (!password || password.length < policy.minLength) { errors.push(`Password must be at least ${policy.minLength} characters long`); diff --git a/src/auth/security.utils.ts b/src/auth/security.utils.ts index a1436bf3..51c96c2b 100644 --- a/src/auth/security.utils.ts +++ b/src/auth/security.utils.ts @@ -78,11 +78,6 @@ export function generateBackupCodes(count = 8): string[] { return Array.from({ length: count }, () => randomBytes(4).toString('hex').toUpperCase()); } -export function getPasswordHistoryLimit(): number { - const parsed = Number(process.env.PASSWORD_HISTORY_LIMIT ?? 5); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 5; -} - export function verifyBackupCode(candidate: string, backupCodeHashes: string[]) { const digest = createSha256(candidate.trim().toUpperCase()); const digestBuffer = Buffer.from(digest); diff --git a/src/blockchain/blockchain.service.spec.ts b/src/blockchain/blockchain.service.spec.ts index 1d05703e..751ec9c0 100644 --- a/src/blockchain/blockchain.service.spec.ts +++ b/src/blockchain/blockchain.service.spec.ts @@ -75,12 +75,14 @@ describe('BlockchainService', () => { }); it('should produce different hashes for different data', () => { + const ts = 1716812345678; const data1 = { transactionId: 'tx-123', propertyId: 'prop-456', buyerAddress: '0xBuyer', sellerAddress: '0xSeller', amount: 1000, + timestamp: ts, }; const data2 = { @@ -89,6 +91,7 @@ describe('BlockchainService', () => { buyerAddress: '0xBuyer', sellerAddress: '0xSeller', amount: 1000, + timestamp: ts, }; const hash1 = service.generateBlockchainHash(data1); @@ -98,12 +101,14 @@ describe('BlockchainService', () => { }); it('should handle address normalization', () => { + const ts = 1716812345678; const data1 = { transactionId: 'tx-123', propertyId: 'prop-456', buyerAddress: '0xBUYER', sellerAddress: '0xSELLER', amount: 1000, + timestamp: ts, }; const data2 = { @@ -112,6 +117,7 @@ describe('BlockchainService', () => { buyerAddress: '0xbuyer', sellerAddress: '0xseller', amount: 1000, + timestamp: ts, }; const hash1 = service.generateBlockchainHash(data1); diff --git a/src/transactions/transactions.service.spec.ts b/src/transactions/transactions.service.spec.ts index f6ea1362..21cdf0d2 100644 --- a/src/transactions/transactions.service.spec.ts +++ b/src/transactions/transactions.service.spec.ts @@ -256,14 +256,31 @@ describe('TransactionsService', () => { expect(result.volumeTrends).toEqual([]); }); - it('should reject date ranges larger than 365 days', async () => { + it('should cap date ranges larger than maxDays', async () => { const startDate = new Date('2025-01-01T00:00:00.000Z'); const endDate = new Date('2026-01-02T00:00:00.000Z'); + const cappedEnd = new Date(startDate); + cappedEnd.setDate(cappedEnd.getDate() + 365); - await expect( - service.getAnalytics({ startDate, endDate, granularity: TransactionAnalyticsGranularity.MONTH }), - ).rejects.toThrow(BadRequestException); - expect(prisma.transaction.findMany).not.toHaveBeenCalled(); + mockPrismaService.transaction.findMany.mockResolvedValue([]); + + const result = await service.getAnalytics({ + startDate, + endDate, + granularity: TransactionAnalyticsGranularity.MONTH, + }); + + expect(prisma.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: expect.objectContaining({ + gte: startDate, + lte: cappedEnd, + }), + }), + }), + ); + expect(result.totalTransactions).toBe(0); }); }); }); diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index a9477b53..0981df69 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -318,15 +318,9 @@ export class TransactionsService { const maxDays = query.maxDays ?? 365; if (query.startDate && query.endDate) { - const maxRangeMs = 365 * 24 * 60 * 60 * 1000; - const durationMs = query.endDate.getTime() - query.startDate.getTime(); - - if (durationMs < 0) { + if (query.endDate.getTime() < query.startDate.getTime()) { throw new BadRequestException('endDate must be on or after startDate'); } - if (durationMs > maxRangeMs) { - throw new BadRequestException('Date range cannot exceed 365 days'); - } } if (query.type) { diff --git a/test/auth/password.utils.spec.ts b/test/auth/password.utils.spec.ts index 57ccb871..d6ca560c 100644 --- a/test/auth/password.utils.spec.ts +++ b/test/auth/password.utils.spec.ts @@ -1,30 +1,74 @@ -import { validatePassword } from '../../src/auth/password.utils'; +import { ConfigService } from '@nestjs/config'; +import { getPasswordPolicy, validatePassword } from '../../src/auth/password.utils'; -describe('validatePassword', () => { - const OLD_ENV = process.env; +function mockConfig(overrides: Record = {}): ConfigService { + const defaults: Record = { + PASSWORD_MIN_LENGTH: '8', + PASSWORD_REQUIRE_UPPERCASE: 'true', + PASSWORD_REQUIRE_LOWERCASE: 'true', + PASSWORD_REQUIRE_DIGIT: 'true', + PASSWORD_REQUIRE_SPECIAL: 'true', + }; + return { + get(key: string) { + return overrides[key] ?? defaults[key] ?? undefined; + }, + } as unknown as ConfigService; +} + +describe('getPasswordPolicy', () => { + it('returns default values when config service has no overrides', () => { + const configService = mockConfig(); + const policy = getPasswordPolicy(configService); + + expect(policy.minLength).toBe(8); + expect(policy.requireUppercase).toBe(true); + expect(policy.requireLowercase).toBe(true); + expect(policy.requireDigit).toBe(true); + expect(policy.requireSpecial).toBe(true); + expect(policy.specialChars).toBeDefined(); + }); - afterEach(() => { - process.env = { ...OLD_ENV }; + it('reflects env-driven overrides', () => { + const configService = mockConfig({ + PASSWORD_MIN_LENGTH: '12', + PASSWORD_REQUIRE_UPPERCASE: 'false', + PASSWORD_SPECIAL_CHARS: '!@#$', + }); + + const policy = getPasswordPolicy(configService); + + expect(policy.minLength).toBe(12); + expect(policy.requireUppercase).toBe(false); + expect(policy.requireLowercase).toBe(true); + expect(policy.requireDigit).toBe(true); + expect(policy.requireSpecial).toBe(true); + expect(policy.specialChars).toBe('!@#$'); }); +}); +describe('validatePassword', () => { it('accepts a strong password by default policy', () => { - const errors = validatePassword('Str0ng!Pass'); + const configService = mockConfig(); + const errors = validatePassword('Str0ng!Pass', configService); expect(errors).toHaveLength(0); }); it('rejects short or simple passwords', () => { - process.env.PASSWORD_MIN_LENGTH = '12'; - const errors = validatePassword('weak'); + const configService = mockConfig({ PASSWORD_MIN_LENGTH: '12' }); + const errors = validatePassword('weak', configService); expect(errors.length).toBeGreaterThan(0); }); it('requires uppercase/lowercase/digit/special as configured', () => { - process.env.PASSWORD_REQUIRE_UPPERCASE = 'true'; - process.env.PASSWORD_REQUIRE_LOWERCASE = 'true'; - process.env.PASSWORD_REQUIRE_DIGIT = 'true'; - process.env.PASSWORD_REQUIRE_SPECIAL = 'true'; + const configService = mockConfig({ + PASSWORD_REQUIRE_UPPERCASE: 'true', + PASSWORD_REQUIRE_LOWERCASE: 'true', + PASSWORD_REQUIRE_DIGIT: 'true', + PASSWORD_REQUIRE_SPECIAL: 'true', + }); - const errors = validatePassword('noupper1!'); + const errors = validatePassword('noupper1!', configService); expect(errors).toContain('Password must include at least one uppercase letter'); }); });