From b382267f98a0f63172c1fd51258793fb004c854e Mon Sep 17 00:00:00 2001 From: ahmadogo Date: Tue, 24 Mar 2026 09:00:16 +0100 Subject: [PATCH 1/2] Insecure Password Hashing Configuration --- .env.example | 1 + junit.xml | 667 ++++++++++-------- prisma/schema.prisma | 16 + .../services/password-rotation.service.ts | 239 +++++++ src/common/validators/password.validator.ts | 190 ++++- src/config/configuration.service.ts | 4 +- src/users/user.service.ts | 39 +- src/users/users.module.ts | 5 +- test/common/password-rotation.service.spec.ts | 532 ++++++++++++++ 9 files changed, 1362 insertions(+), 331 deletions(-) create mode 100644 src/common/services/password-rotation.service.ts create mode 100644 test/common/password-rotation.service.spec.ts diff --git a/.env.example b/.env.example index 836f2915..36973c5c 100644 --- a/.env.example +++ b/.env.example @@ -102,6 +102,7 @@ PASSWORD_REQUIRE_NUMBERS=true PASSWORD_REQUIRE_UPPERCASE=true PASSWORD_HISTORY_COUNT=5 PASSWORD_EXPIRY_DAYS=90 +PASSWORD_EXPIRY_WARNING_DAYS=7 # Authentication Security JWT_BLACKLIST_ENABLED=true diff --git a/junit.xml b/junit.xml index ac5d3f5e..1eb64a2c 100644 --- a/junit.xml +++ b/junit.xml @@ -1,586 +1,639 @@ - - - + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + - + - - - + - + - + - + - + - + - + - + - + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + - - - + - + - + - + - + - + - + + + - + + + - + - + - + - + - + - + - + - + - + - + - + - + - + + + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - + + + - + - + - + - + + + - + - + - + - + - + + + - + - + - - - + - + - + - + - + - + - + - + + + - + - + - + - + - + - + + + - + - + - + - + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + - + - - + + - + + + - + - + - + + + - + - + - + - + - + - - - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + - + - + + + - + + + - + - + - + - - - + + + + + + + - + - + - + - + - - + + - + - + - + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - TypeError: Cannot read properties of null (reading 'trackEmailSent') - at EmailService.trackEmailSent [as sendTemplatedEmail] (/workspaces/PropChain-BackEnd/src/communication/email/email.service.ts:87:35) - at AuthService.sendTemplatedEmail [as sendVerificationEmail] (/workspaces/PropChain-BackEnd/src/auth/auth.service.ts:373:24) - at AuthService.register (/workspaces/PropChain-BackEnd/src/auth/auth.service.ts:36:7) - at Object.<anonymous> (/workspaces/PropChain-BackEnd/test/auth/auth.service.spec.ts:90:22) + + - + - + - + - - - + - + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + - + - + - - + + + + - + - + - + + + + + - - + + - + - + - + - + - - - + - - - + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 77c01a4c..6966d1a6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,9 @@ model User { // Activity activities UserActivity[] + // Password history for rotation policy + passwordHistory PasswordHistory[] + properties Property[] receivedTransactions Transaction[] @relation("UserTransactions") userRole Role? @relation(fields: [roleId], references: [id], onDelete: SetNull) @@ -390,3 +393,16 @@ model ApiKey { @@index([createdAt]) @@map("api_keys") } + +model PasswordHistory { + id String @id @default(cuid()) + userId String @map("user_id") + passwordHash String @map("password_hash") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([createdAt]) + @@map("password_history") +} diff --git a/src/common/services/password-rotation.service.ts b/src/common/services/password-rotation.service.ts new file mode 100644 index 00000000..86e424b4 --- /dev/null +++ b/src/common/services/password-rotation.service.ts @@ -0,0 +1,239 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../database/prisma/prisma.service'; +import * as bcrypt from 'bcrypt'; + +export interface PasswordRotationCheck { + canRotate: boolean; + reason?: string; + daysUntilExpiry?: number; + lastRotation?: Date; +} + +export interface PasswordHistoryEntry { + id: string; + userId: string; + createdAt: Date; +} + +@Injectable() +export class PasswordRotationService { + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) {} + + /** + * Check if password rotation is required for a user + */ + async checkRotationStatus(userId: string): Promise { + const passwordExpiryDays = this.configService.get('PASSWORD_EXPIRY_DAYS', 90); + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + passwordHistory: { + orderBy: { createdAt: 'desc' }, + take: 1, + }, + }, + }); + + if (!user || !user.password) { + return { canRotate: false, reason: 'User not found or no password set' }; + } + + // Check if password has expired + const lastPasswordChange = user.passwordHistory[0]?.createdAt || user.createdAt; + const daysSinceChange = Math.floor((Date.now() - lastPasswordChange.getTime()) / (1000 * 60 * 60 * 24)); + const daysUntilExpiry = Math.max(0, passwordExpiryDays - daysSinceChange); + + if (daysSinceChange >= passwordExpiryDays) { + return { + canRotate: true, + reason: 'Password has expired and must be changed', + daysUntilExpiry: 0, + lastRotation: lastPasswordChange, + }; + } + + return { + canRotate: true, + daysUntilExpiry, + lastRotation: lastPasswordChange, + }; + } + + /** + * Validate that a new password is not in the user's password history + */ + async validatePasswordNotInHistory( + userId: string, + newPassword: string, + ): Promise<{ valid: boolean; reason?: string }> { + const passwordHistoryCount = this.configService.get('PASSWORD_HISTORY_COUNT', 5); + + // Get recent password history + const passwordHistory = await this.prisma.passwordHistory.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: passwordHistoryCount, + }); + + // Check against each historical password + for (const entry of passwordHistory) { + const isMatch = await bcrypt.compare(newPassword, entry.passwordHash); + if (isMatch) { + return { + valid: false, + reason: `Cannot reuse any of your last ${passwordHistoryCount} passwords`, + }; + } + } + + return { valid: true }; + } + + /** + * Add a password to the user's history + */ + async addPasswordToHistory(userId: string, passwordHash: string): Promise { + const passwordHistoryCount = this.configService.get('PASSWORD_HISTORY_COUNT', 5); + + await this.prisma.$transaction(async tx => { + // Add new password to history + await tx.passwordHistory.create({ + data: { + userId, + passwordHash, + }, + }); + + // Remove old entries beyond the history limit + const historyEntries = await tx.passwordHistory.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + skip: passwordHistoryCount, + }); + + if (historyEntries.length > 0) { + await tx.passwordHistory.deleteMany({ + where: { + id: { + in: historyEntries.map(entry => entry.id), + }, + }, + }); + } + }); + } + + /** + * Validate password rotation requirements before allowing password change + */ + async validatePasswordRotation(userId: string, newPassword: string): Promise<{ valid: boolean; reason?: string }> { + // Check rotation status + const rotationStatus = await this.checkRotationStatus(userId); + if (!rotationStatus.canRotate && rotationStatus.reason) { + return { valid: false, reason: rotationStatus.reason }; + } + + // Check password history + const historyCheck = await this.validatePasswordNotInHistory(userId, newPassword); + if (!historyCheck.valid) { + return { valid: false, reason: historyCheck.reason }; + } + + return { valid: true }; + } + + /** + * Get password history for a user + */ + async getPasswordHistory(userId: string, limit = 10): Promise { + const history = await this.prisma.passwordHistory.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + userId: true, + createdAt: true, + }, + }); + + return history; + } + + /** + * Clear password history for a user (admin function) + */ + async clearPasswordHistory(userId: string): Promise { + await this.prisma.passwordHistory.deleteMany({ + where: { userId }, + }); + } + + /** + * Get users with expired passwords + */ + async getUsersWithExpiredPasswords(): Promise<{ userId: string; email: string; daysExpired: number }[]> { + const passwordExpiryDays = this.configService.get('PASSWORD_EXPIRY_DAYS', 90); + + const users = await this.prisma.user.findMany({ + where: { + password: { not: null }, + }, + include: { + passwordHistory: { + orderBy: { createdAt: 'desc' }, + take: 1, + }, + }, + }); + + const expiredUsers: { userId: string; email: string; daysExpired: number }[] = []; + + for (const user of users) { + const lastPasswordChange = user.passwordHistory[0]?.createdAt || user.createdAt; + const daysSinceChange = Math.floor((Date.now() - lastPasswordChange.getTime()) / (1000 * 60 * 60 * 24)); + + if (daysSinceChange > passwordExpiryDays) { + expiredUsers.push({ + userId: user.id, + email: user.email, + daysExpired: daysSinceChange - passwordExpiryDays, + }); + } + } + + return expiredUsers; + } + + /** + * Check if user needs to rotate password (for middleware/guard usage) + */ + async requiresPasswordRotation(userId: string): Promise { + const passwordExpiryDays = this.configService.get('PASSWORD_EXPIRY_DAYS', 90); + const warningDays = this.configService.get('PASSWORD_EXPIRY_WARNING_DAYS', 7); + + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + passwordHistory: { + orderBy: { createdAt: 'desc' }, + take: 1, + }, + }, + }); + + if (!user || !user.password) { + return false; + } + + const lastPasswordChange = user.passwordHistory[0]?.createdAt || user.createdAt; + const daysSinceChange = Math.floor((Date.now() - lastPasswordChange.getTime()) / (1000 * 60 * 60 * 24)); + + // Require rotation if expired or within warning period + return daysSinceChange >= passwordExpiryDays - warningDays; + } +} diff --git a/src/common/validators/password.validator.ts b/src/common/validators/password.validator.ts index 54f500fb..a5cdb253 100644 --- a/src/common/validators/password.validator.ts +++ b/src/common/validators/password.validator.ts @@ -1,24 +1,44 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +export interface PasswordValidationResult { + valid: boolean; + errors: string[]; + strength: 'weak' | 'medium' | 'strong'; + score: number; +} + @Injectable() export class PasswordValidator { constructor(private readonly configService: ConfigService) {} - validatePassword(password: string): { valid: boolean; errors: string[] } { + validatePassword(password: string): PasswordValidationResult { const errors: string[] = []; + let score = 0; // Length validation const minLength = this.configService.get('PASSWORD_MIN_LENGTH', 12); if (password.length < minLength) { errors.push(`Password must be at least ${minLength} characters long`); + } else { + score += password.length >= 16 ? 2 : 1; } - // Special characters validation - if (this.configService.get('PASSWORD_REQUIRE_SPECIAL_CHARS', true)) { - const specialCharRegex = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/; - if (!specialCharRegex.test(password)) { - errors.push('Password must contain at least one special character'); + // Lowercase validation + const lowercaseRegex = /[a-z]/; + if (!lowercaseRegex.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } else { + score += 1; + } + + // Uppercase validation + if (this.configService.get('PASSWORD_REQUIRE_UPPERCASE', true)) { + const uppercaseRegex = /[A-Z]/; + if (!uppercaseRegex.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } else { + score += 1; } } @@ -27,19 +47,73 @@ export class PasswordValidator { const numberRegex = /\d/; if (!numberRegex.test(password)) { errors.push('Password must contain at least one number'); + } else { + score += 1; } } - // Uppercase validation - if (this.configService.get('PASSWORD_REQUIRE_UPPERCASE', true)) { - const uppercaseRegex = /[A-Z]/; - if (!uppercaseRegex.test(password)) { - errors.push('Password must contain at least one uppercase letter'); + // Special characters validation + if (this.configService.get('PASSWORD_REQUIRE_SPECIAL_CHARS', true)) { + const specialCharRegex = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/; + if (!specialCharRegex.test(password)) { + errors.push('Password must contain at least one special character'); + } else { + score += 1; } } + // Entropy check - bonus points for complexity + const uniqueChars = new Set(password).size; + if (uniqueChars >= password.length * 0.7) { + score += 1; + } + + // Sequential characters check + if (this.hasSequentialChars(password)) { + errors.push('Password must not contain sequential characters (e.g., abc, 123)'); + } else { + score += 1; + } + + // Repeated characters check + if (this.hasRepeatedChars(password)) { + errors.push('Password must not contain repeated characters (e.g., aaa, 111)'); + } else { + score += 1; + } + // Common password patterns to avoid - const commonPatterns = [/password/i, /123456/, /qwerty/, /abc123/, /admin/, /welcome/]; + const commonPatterns = [ + /password/i, + /123456/, + /qwerty/i, + /abc123/i, + /admin/i, + /welcome/i, + /letmein/i, + /monkey/i, + /dragon/i, + /master/i, + /sunshine/i, + /princess/i, + /football/i, + /baseball/i, + /iloveyou/i, + /trustno1/i, + /shadow/i, + /ashley/i, + /michael/i, + /jesus/i, + /mustang/i, + /access/i, + /love/i, + /pussy/i, + /696969/i, + /qwertyuiop/i, + /qazwsx/i, + /zaq12wsx/i, + /!@#\$%^&\*/, + ]; for (const pattern of commonPatterns) { if (pattern.test(password)) { @@ -48,19 +122,107 @@ export class PasswordValidator { } } + // Check for keyboard patterns + if (this.hasKeyboardPattern(password)) { + errors.push('Password must not contain keyboard patterns (e.g., asdf, qwer)'); + } else { + score += 1; + } + + // Determine strength + let strength: 'weak' | 'medium' | 'strong' = 'weak'; + if (score >= 7) { + strength = 'strong'; + } else if (score >= 5) { + strength = 'medium'; + } + return { valid: errors.length === 0, errors, + strength, + score, }; } isPasswordStrong(password: string): boolean { - const { valid } = this.validatePassword(password); - return valid; + const { valid, strength } = this.validatePassword(password); + return valid && strength === 'strong'; } getValidationMessage(password: string): string { const { errors } = this.validatePassword(password); return errors.join(', ') || 'Password is valid'; } + + calculateEntropy(password: string): number { + let poolSize = 0; + if (/[a-z]/.test(password)) { + poolSize += 26; + } + if (/[A-Z]/.test(password)) { + poolSize += 26; + } + if (/\d/.test(password)) { + poolSize += 10; + } + if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { + poolSize += 32; + } + + return Math.log2(Math.pow(poolSize, password.length)); + } + + private hasSequentialChars(password: string): boolean { + const lowerPassword = password.toLowerCase(); + const sequences = ['abcdefghijklmnopqrstuvwxyz', '0123456789']; + + for (const seq of sequences) { + for (let i = 0; i < seq.length - 2; i++) { + const pattern = seq.substring(i, i + 3); + if (lowerPassword.includes(pattern)) { + return true; + } + } + } + return false; + } + + private hasRepeatedChars(password: string): boolean { + const repeatedPattern = /(.)\1{2,}/; + return repeatedPattern.test(password); + } + + private hasKeyboardPattern(password: string): boolean { + const keyboardPatterns = [ + 'qwerty', + 'asdf', + 'zxcv', + 'qwer', + 'wasd', + 'qazwsx', + 'zaq12wsx', + '1qaz2wsx', + 'qaz', + 'wsx', + 'edc', + 'rfv', + 'tgb', + 'yhn', + 'ujm', + 'ikl', + 'ppp', + 'ooo', + 'lll', + 'kkk', + ]; + + const lowerPassword = password.toLowerCase(); + for (const pattern of keyboardPatterns) { + if (lowerPassword.includes(pattern)) { + return true; + } + } + return false; + } } diff --git a/src/config/configuration.service.ts b/src/config/configuration.service.ts index 347c8e4d..43333359 100644 --- a/src/config/configuration.service.ts +++ b/src/config/configuration.service.ts @@ -192,7 +192,9 @@ export class ConfigurationService { // Security get bcryptRounds(): number { - return this.configService.get('BCRYPT_ROUNDS'); + const rounds = this.configService.get('BCRYPT_ROUNDS', 12); + // Enforce minimum of 12 rounds for security + return Math.max(rounds, 12); } get sessionSecret(): string { diff --git a/src/users/user.service.ts b/src/users/user.service.ts index 0e8e482f..386a03c4 100644 --- a/src/users/user.service.ts +++ b/src/users/user.service.ts @@ -9,6 +9,8 @@ import { PrismaService } from '../database/prisma/prisma.service'; import { CreateUserDto } from './dto/create-user.dto'; import * as bcrypt from 'bcrypt'; import { PasswordValidator } from '../common/validators/password.validator'; +import { PasswordRotationService } from '../common/services/password-rotation.service'; +import { ConfigService } from '@nestjs/config'; /** * UserService @@ -31,6 +33,8 @@ export class UserService { constructor( private prisma: PrismaService, private readonly passwordValidator: PasswordValidator, + private readonly passwordRotationService: PasswordRotationService, + private readonly configService: ConfigService, ) {} /** @@ -88,10 +92,11 @@ export class UserService { // === PASSWORD HASHING === // Uses bcrypt for secure password hashing - // Salt rounds configurable via BCRYPT_ROUNDS (default: 12) + // Salt rounds configurable via BCRYPT_ROUNDS (default: 12, minimum: 12) // Higher = more secure but slower - const bcryptRounds = this.passwordValidator['configService'].get('BCRYPT_ROUNDS', 12); - const hashedPassword = await bcrypt.hash(password, bcryptRounds); + const bcryptRounds = this.configService.get('BCRYPT_ROUNDS', 12); + const effectiveRounds = Math.max(bcryptRounds, 12); // Enforce minimum 12 rounds + const hashedPassword = await bcrypt.hash(password, effectiveRounds); // Create user with hashed password const user = await this.prisma.user.create({ @@ -103,6 +108,10 @@ export class UserService { }, }); + // === PASSWORD HISTORY TRACKING === + // Add initial password to history for rotation policy enforcement + await this.passwordRotationService.addPasswordToHistory(user.id, hashedPassword); + return user; } @@ -192,14 +201,30 @@ export class UserService { throw new BadRequestException(`Password validation failed: ${passwordValidation.errors.join(', ')}`); } + // === PASSWORD ROTATION POLICY CHECK === + // Validate password rotation requirements (history check) + const rotationCheck = await this.passwordRotationService.validatePasswordRotation(userId, newPassword); + if (!rotationCheck.valid) { + throw new BadRequestException(`Password rotation failed: ${rotationCheck.reason}`); + } + // === BCRYPT HASHING === - // Hash new password before storing - const bcryptRounds = this.passwordValidator['configService'].get('BCRYPT_ROUNDS', 12); - const hashedPassword = await bcrypt.hash(newPassword, bcryptRounds); - return this.prisma.user.update({ + // Hash new password before storing with minimum 12 rounds + const bcryptRounds = this.configService.get('BCRYPT_ROUNDS', 12); + const effectiveRounds = Math.max(bcryptRounds, 12); // Enforce minimum 12 rounds + const hashedPassword = await bcrypt.hash(newPassword, effectiveRounds); + + // Update user password + const updatedUser = await this.prisma.user.update({ where: { id: userId }, data: { password: hashedPassword }, }); + + // === PASSWORD HISTORY TRACKING === + // Add new password to history for rotation policy enforcement + await this.passwordRotationService.addPasswordToHistory(userId, hashedPassword); + + return updatedUser; } /** diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 8f18f828..7f25e15c 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -4,6 +4,7 @@ import { UserController } from './user.controller'; import { PrismaService } from '../database/prisma/prisma.service'; import { AuthModule } from '../auth/auth.module'; import { PasswordValidator } from '../common/validators/password.validator'; +import { PasswordRotationService } from '../common/services/password-rotation.service'; @Module({ imports: [ @@ -11,7 +12,7 @@ import { PasswordValidator } from '../common/validators/password.validator'; forwardRef(() => AuthModule), ], controllers: [UserController], - providers: [UserService, PrismaService, PasswordValidator], - exports: [UserService], // This allows AuthService to use UserService + providers: [UserService, PrismaService, PasswordValidator, PasswordRotationService], + exports: [UserService, PasswordRotationService], // Export for use in other modules }) export class UsersModule {} diff --git a/test/common/password-rotation.service.spec.ts b/test/common/password-rotation.service.spec.ts new file mode 100644 index 00000000..3d40d0fc --- /dev/null +++ b/test/common/password-rotation.service.spec.ts @@ -0,0 +1,532 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { PasswordRotationService } from '../../src/common/services/password-rotation.service'; +import { PrismaService } from '../../src/database/prisma/prisma.service'; +import * as bcrypt from 'bcrypt'; + +// Mock bcrypt +jest.mock('bcrypt', () => ({ + compare: jest.fn(), +})); + +describe('PasswordRotationService', () => { + let service: PasswordRotationService; + let prismaService: PrismaService; + let configService: ConfigService; + + const mockPrismaService = { + user: { + findUnique: jest.fn(), + findMany: jest.fn(), + }, + passwordHistory: { + findMany: jest.fn(), + create: jest.fn(), + deleteMany: jest.fn(), + }, + $transaction: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn().mockImplementation((key: string, defaultValue?: number) => { + const config: Record = { + PASSWORD_EXPIRY_DAYS: 90, + PASSWORD_HISTORY_COUNT: 5, + PASSWORD_EXPIRY_WARNING_DAYS: 7, + }; + return config[key] ?? defaultValue; + }), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PasswordRotationService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + service = module.get(PasswordRotationService); + prismaService = module.get(PrismaService); + configService = module.get(ConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('checkRotationStatus', () => { + it('should return canRotate false when user not found', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + const result = await service.checkRotationStatus('user-id'); + + expect(result.canRotate).toBe(false); + expect(result.reason).toBe('User not found or no password set'); + }); + + it('should return canRotate false when user has no password', async () => { + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + email: 'test@example.com', + password: null, + createdAt: new Date(), + passwordHistory: [], + }); + + const result = await service.checkRotationStatus('user-id'); + + expect(result.canRotate).toBe(false); + expect(result.reason).toBe('User not found or no password set'); + }); + + it('should return expired status when password has expired', async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 100); // 100 days ago + + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + email: 'test@example.com', + password: 'hashed-password', + createdAt: oldDate, + passwordHistory: [], + }); + + const result = await service.checkRotationStatus('user-id'); + + expect(result.canRotate).toBe(true); + expect(result.reason).toBe('Password has expired and must be changed'); + expect(result.daysUntilExpiry).toBe(0); + }); + + it('should return valid status with days until expiry', async () => { + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 30); // 30 days ago + + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + email: 'test@example.com', + password: 'hashed-password', + createdAt: recentDate, + passwordHistory: [], + }); + + const result = await service.checkRotationStatus('user-id'); + + expect(result.canRotate).toBe(true); + expect(result.daysUntilExpiry).toBe(60); // 90 - 30 + expect(result.reason).toBeUndefined(); + }); + + it('should use password history date when available', async () => { + const createdDate = new Date(); + createdDate.setDate(createdDate.getDate() - 100); + + const passwordChangeDate = new Date(); + passwordChangeDate.setDate(passwordChangeDate.getDate() - 30); + + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + email: 'test@example.com', + password: 'hashed-password', + createdAt: createdDate, + passwordHistory: [{ createdAt: passwordChangeDate }], + }); + + const result = await service.checkRotationStatus('user-id'); + + expect(result.canRotate).toBe(true); + expect(result.daysUntilExpiry).toBe(60); // 90 - 30 (from password change date) + expect(result.lastRotation).toEqual(passwordChangeDate); + }); + }); + + describe('validatePasswordNotInHistory', () => { + it('should return valid true when history is empty', async () => { + mockPrismaService.passwordHistory.findMany.mockResolvedValue([]); + + const result = await service.validatePasswordNotInHistory('user-id', 'new-password'); + + expect(result.valid).toBe(true); + }); + + it('should return valid true when password not in history', async () => { + mockPrismaService.passwordHistory.findMany.mockResolvedValue([ + { id: '1', passwordHash: 'old-hash-1' }, + { id: '2', passwordHash: 'old-hash-2' }, + ]); + (bcrypt.compare as jest.Mock).mockResolvedValue(false); + + const result = await service.validatePasswordNotInHistory('user-id', 'new-password'); + + expect(result.valid).toBe(true); + }); + + it('should return valid false when password matches history', async () => { + mockPrismaService.passwordHistory.findMany.mockResolvedValue([ + { id: '1', passwordHash: 'old-hash-1' }, + { id: '2', passwordHash: 'old-hash-2' }, + ]); + (bcrypt.compare as jest.Mock).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const result = await service.validatePasswordNotInHistory('user-id', 'reused-password'); + + expect(result.valid).toBe(false); + expect(result.reason).toBe('Cannot reuse any of your last 5 passwords'); + }); + + it('should respect custom password history count', async () => { + mockConfigService.get.mockImplementation((key: string, defaultValue?: number) => { + if (key === 'PASSWORD_HISTORY_COUNT') return 10; + return defaultValue; + }); + mockPrismaService.passwordHistory.findMany.mockResolvedValue([]); + + await service.validatePasswordNotInHistory('user-id', 'new-password'); + + expect(mockPrismaService.passwordHistory.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10 }), + ); + }); + }); + + describe('addPasswordToHistory', () => { + it('should add password to history and not delete when under limit', async () => { + const mockTx = { + passwordHistory: { + create: jest.fn().mockResolvedValue({ id: 'new-entry-id' }), + findMany: jest.fn().mockResolvedValue([]), + deleteMany: jest.fn(), + }, + }; + mockPrismaService.$transaction.mockImplementation(async (callback: Function) => { + return callback(mockTx); + }); + + await service.addPasswordToHistory('user-id', 'hashed-password'); + + expect(mockTx.passwordHistory.create).toHaveBeenCalledWith({ + data: { userId: 'user-id', passwordHash: 'hashed-password' }, + }); + expect(mockTx.passwordHistory.deleteMany).not.toHaveBeenCalled(); + }); + + it('should delete old entries when over limit', async () => { + const oldEntries = [ + { id: 'old-entry-1' }, + { id: 'old-entry-2' }, + ]; + const mockTx = { + passwordHistory: { + create: jest.fn().mockResolvedValue({ id: 'new-entry-id' }), + findMany: jest.fn().mockResolvedValue(oldEntries), + deleteMany: jest.fn().mockResolvedValue({ count: 2 }), + }, + }; + mockPrismaService.$transaction.mockImplementation(async (callback: Function) => { + return callback(mockTx); + }); + + await service.addPasswordToHistory('user-id', 'hashed-password'); + + expect(mockTx.passwordHistory.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ['old-entry-1', 'old-entry-2'] } }, + }); + }); + }); + + describe('validatePasswordRotation', () => { + it('should return invalid when rotation status cannot rotate', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + const result = await service.validatePasswordRotation('user-id', 'new-password'); + + expect(result.valid).toBe(false); + expect(result.reason).toBe('User not found or no password set'); + }); + + it('should return invalid when password is in history', async () => { + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 30); + + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + email: 'test@example.com', + password: 'hashed-password', + createdAt: recentDate, + passwordHistory: [], + }); + mockPrismaService.passwordHistory.findMany.mockResolvedValue([ + { id: '1', passwordHash: 'old-hash' }, + ]); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + + // Reset config mock to return default values + mockConfigService.get.mockImplementation((key: string, defaultValue?: number) => { + const config: Record = { + PASSWORD_EXPIRY_DAYS: 90, + PASSWORD_HISTORY_COUNT: 5, + PASSWORD_EXPIRY_WARNING_DAYS: 7, + }; + return config[key] ?? defaultValue; + }); + + const result = await service.validatePasswordRotation('user-id', 'reused-password'); + + expect(result.valid).toBe(false); + expect(result.reason).toBe('Cannot reuse any of your last 5 passwords'); + }); + + it('should return valid when all checks pass', async () => { + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 30); + + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + email: 'test@example.com', + password: 'hashed-password', + createdAt: recentDate, + passwordHistory: [], + }); + mockPrismaService.passwordHistory.findMany.mockResolvedValue([]); + (bcrypt.compare as jest.Mock).mockResolvedValue(false); + + const result = await service.validatePasswordRotation('user-id', 'new-password'); + + expect(result.valid).toBe(true); + }); + }); + + describe('getPasswordHistory', () => { + it('should return password history with default limit', async () => { + const mockHistory = [ + { id: '1', userId: 'user-id', createdAt: new Date() }, + { id: '2', userId: 'user-id', createdAt: new Date() }, + ]; + mockPrismaService.passwordHistory.findMany.mockResolvedValue(mockHistory); + + const result = await service.getPasswordHistory('user-id'); + + expect(result).toEqual(mockHistory); + expect(mockPrismaService.passwordHistory.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10 }), + ); + }); + + it('should return password history with custom limit', async () => { + const mockHistory = [{ id: '1', userId: 'user-id', createdAt: new Date() }]; + mockPrismaService.passwordHistory.findMany.mockResolvedValue(mockHistory); + + const result = await service.getPasswordHistory('user-id', 5); + + expect(result).toEqual(mockHistory); + expect(mockPrismaService.passwordHistory.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 5 }), + ); + }); + + it('should select only required fields', async () => { + mockPrismaService.passwordHistory.findMany.mockResolvedValue([]); + + await service.getPasswordHistory('user-id'); + + expect(mockPrismaService.passwordHistory.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: { id: true, userId: true, createdAt: true }, + }), + ); + }); + }); + + describe('clearPasswordHistory', () => { + it('should delete all password history for user', async () => { + mockPrismaService.passwordHistory.deleteMany.mockResolvedValue({ count: 5 }); + + await service.clearPasswordHistory('user-id'); + + expect(mockPrismaService.passwordHistory.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'user-id' }, + }); + }); + }); + + describe('getUsersWithExpiredPasswords', () => { + it('should return empty array when no users have expired passwords', async () => { + const recentDate = new Date(); + mockPrismaService.user.findMany.mockResolvedValue([ + { + id: 'user-1', + email: 'user1@example.com', + password: 'hash', + createdAt: recentDate, + passwordHistory: [], + }, + ]); + + const result = await service.getUsersWithExpiredPasswords(); + + expect(result).toEqual([]); + }); + + it('should return users with expired passwords', async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 100); + + mockPrismaService.user.findMany.mockResolvedValue([ + { + id: 'user-1', + email: 'expired@example.com', + password: 'hash', + createdAt: oldDate, + passwordHistory: [], + }, + ]); + + const result = await service.getUsersWithExpiredPasswords(); + + expect(result).toHaveLength(1); + expect(result[0].userId).toBe('user-1'); + expect(result[0].email).toBe('expired@example.com'); + expect(result[0].daysExpired).toBe(10); // 100 - 90 + }); + + it('should use password history date for expiry calculation', async () => { + const createdDate = new Date(); + createdDate.setDate(createdDate.getDate() - 200); + + const passwordChangeDate = new Date(); + passwordChangeDate.setDate(passwordChangeDate.getDate() - 100); + + mockPrismaService.user.findMany.mockResolvedValue([ + { + id: 'user-1', + email: 'expired@example.com', + password: 'hash', + createdAt: createdDate, + passwordHistory: [{ createdAt: passwordChangeDate }], + }, + ]); + + const result = await service.getUsersWithExpiredPasswords(); + + expect(result).toHaveLength(1); + expect(result[0].daysExpired).toBe(10); // 100 - 90 + }); + + it('should only include users with passwords', async () => { + mockPrismaService.user.findMany.mockResolvedValue([]); + + await service.getUsersWithExpiredPasswords(); + + expect(mockPrismaService.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { password: { not: null } }, + }), + ); + }); + }); + + describe('requiresPasswordRotation', () => { + it('should return false when user not found', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + const result = await service.requiresPasswordRotation('user-id'); + + expect(result).toBe(false); + }); + + it('should return false when user has no password', async () => { + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + password: null, + createdAt: new Date(), + passwordHistory: [], + }); + + const result = await service.requiresPasswordRotation('user-id'); + + expect(result).toBe(false); + }); + + it('should return true when password is expired', async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 100); + + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + password: 'hashed-password', + createdAt: oldDate, + passwordHistory: [], + }); + + const result = await service.requiresPasswordRotation('user-id'); + + expect(result).toBe(true); + }); + + it('should return true when within warning period', async () => { + const warningPeriodDate = new Date(); + warningPeriodDate.setDate(warningPeriodDate.getDate() - 85); // 90 - 7 = 83, so 85 is within warning + + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + password: 'hashed-password', + createdAt: warningPeriodDate, + passwordHistory: [], + }); + + const result = await service.requiresPasswordRotation('user-id'); + + expect(result).toBe(true); + }); + + it('should return false when not in warning period and not expired', async () => { + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 30); + + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + password: 'hashed-password', + createdAt: recentDate, + passwordHistory: [], + }); + + const result = await service.requiresPasswordRotation('user-id'); + + expect(result).toBe(false); + }); + + it('should use custom warning days from config', async () => { + mockConfigService.get.mockImplementation((key: string, defaultValue?: number) => { + if (key === 'PASSWORD_EXPIRY_WARNING_DAYS') return 14; + if (key === 'PASSWORD_EXPIRY_DAYS') return 90; + return defaultValue; + }); + + const borderlineDate = new Date(); + borderlineDate.setDate(borderlineDate.getDate() - 75); // 90 - 14 = 76, so 75 is just before warning + + mockPrismaService.user.findUnique.mockResolvedValue({ + id: 'user-id', + password: 'hashed-password', + createdAt: borderlineDate, + passwordHistory: [], + }); + + const result = await service.requiresPasswordRotation('user-id'); + + expect(result).toBe(false); + }); + }); +}); From 79c61af49beca316950b9924a99e15f711df82ed Mon Sep 17 00:00:00 2001 From: ahmadogo Date: Tue, 24 Mar 2026 09:46:45 +0100 Subject: [PATCH 2/2] API Key Management Security --- junit.xml | 688 +++++++++++---------- prisma/schema.prisma | 47 +- src/api-keys/api-key-analytics.service.ts | 281 +++++++++ src/api-keys/api-key-rotation.scheduler.ts | 83 +++ src/api-keys/api-key.controller.ts | 91 ++- src/api-keys/api-key.service.ts | 238 ++++++- src/api-keys/api-keys.module.ts | 9 +- src/api-keys/dto/api-key-response.dto.ts | 6 + test/api-keys/api-key.service.spec.ts | 225 ++++++- 9 files changed, 1315 insertions(+), 353 deletions(-) create mode 100644 src/api-keys/api-key-analytics.service.ts create mode 100644 src/api-keys/api-key-rotation.scheduler.ts diff --git a/junit.xml b/junit.xml index 1eb64a2c..a5884473 100644 --- a/junit.xml +++ b/junit.xml @@ -1,639 +1,659 @@ - - - + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + - + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + - + - + - + - + - + - + - + - - - + - - - + + + + + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + - - + + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - - + + - + - - + + - + - - - - - + - - + + - + - + - + - + - + - + - + - + - + - - - + - + - + - + + + - + - + - - - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + - + - + - + - + - + - + - + - + - - - + - + - + - + - + - - - + - - + + - + - + - - - + - + - + - + - + - + - + - + - + - + - + - + - + - - - + - + - + - + - + - + - + + + - + - + - + - + - + - + - + - + - + - + - + - + + + - + - + - + - - - + + + + + - - + + - + - + - + - + - + - + - + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + + + - + - + - + - + - + + + - + - + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - + - + - + - + - + - + - + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - - - + - + - + - + + + - + - + - + - + - + - + + + - + - - - + - + - + - + + + - + - + - + - + - + - + - + - + - + - + - + + + - + + + \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6966d1a6..df173052 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -376,24 +376,49 @@ model Document { } model ApiKey { - id String @id @default(cuid()) - name String - key String @unique - keyPrefix String @map("key_prefix") - scopes String[] - requestCount BigInt @default(0) @map("request_count") - lastUsedAt DateTime? @map("last_used_at") - isActive Boolean @default(true) @map("is_active") - rateLimit Int? @map("rate_limit") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(cuid()) + name String + key String @unique + keyPrefix String @map("key_prefix") + keyVersion Int @default(1) @map("key_version") + scopes String[] + requestCount BigInt @default(0) @map("request_count") + lastUsedAt DateTime? @map("last_used_at") + isActive Boolean @default(true) @map("is_active") + rateLimit Int? @map("rate_limit") + lastRotatedAt DateTime? @map("last_rotated_at") + rotationDueAt DateTime? @map("rotation_due_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + usageLogs ApiKeyUsageLog[] @@index([keyPrefix]) @@index([isActive]) @@index([createdAt]) + @@index([rotationDueAt]) @@map("api_keys") } +model ApiKeyUsageLog { + id String @id @default(cuid()) + apiKeyId String @map("api_key_id") + endpoint String + method String + statusCode Int @map("status_code") + responseTime Int @map("response_time") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + createdAt DateTime @default(now()) @map("created_at") + + apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade) + + @@index([apiKeyId]) + @@index([endpoint]) + @@index([createdAt]) + @@map("api_key_usage_logs") +} + model PasswordHistory { id String @id @default(cuid()) userId String @map("user_id") diff --git a/src/api-keys/api-key-analytics.service.ts b/src/api-keys/api-key-analytics.service.ts new file mode 100644 index 00000000..a7c391ef --- /dev/null +++ b/src/api-keys/api-key-analytics.service.ts @@ -0,0 +1,281 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../database/prisma/prisma.service'; + +export interface UsageLogEntry { + apiKeyId: string; + endpoint: string; + method: string; + statusCode: number; + responseTime: number; + ipAddress?: string; + userAgent?: string; +} + +export interface ApiKeyAnalyticsSummary { + totalRequests: number; + uniqueEndpoints: number; + averageResponseTime: number; + errorRate: number; + topEndpoints: EndpointUsage[]; + requestsByDay: DailyRequestCount[]; + requestsByHour: HourlyRequestCount[]; +} + +export interface EndpointUsage { + endpoint: string; + method: string; + count: number; + averageResponseTime: number; + errorCount: number; +} + +export interface DailyRequestCount { + date: string; + count: number; +} + +export interface HourlyRequestCount { + hour: number; + count: number; +} + +export interface ApiKeyUsageReport { + apiKeyId: string; + apiKeyName: string; + period: { + start: Date; + end: Date; + }; + summary: ApiKeyAnalyticsSummary; +} + +@Injectable() +export class ApiKeyAnalyticsService { + private readonly logger = new Logger(ApiKeyAnalyticsService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Log an API key usage event + */ + async logUsage(entry: UsageLogEntry): Promise { + try { + await this.prisma.apiKeyUsageLog.create({ + data: { + apiKeyId: entry.apiKeyId, + endpoint: entry.endpoint, + method: entry.method, + statusCode: entry.statusCode, + responseTime: entry.responseTime, + ipAddress: entry.ipAddress, + userAgent: entry.userAgent, + }, + }); + } catch (error) { + this.logger.error(`Failed to log API key usage: ${error.message}`); + // Don't throw - usage logging should not break the request + } + } + + /** + * Get analytics summary for a specific API key + */ + async getAnalyticsSummary(apiKeyId: string, startDate: Date, endDate: Date): Promise { + const logs = await this.prisma.apiKeyUsageLog.findMany({ + where: { + apiKeyId, + createdAt: { + gte: startDate, + lte: endDate, + }, + }, + select: { + endpoint: true, + method: true, + statusCode: true, + responseTime: true, + createdAt: true, + }, + }); + + const totalRequests = logs.length; + const errorCount = logs.filter(l => l.statusCode >= 400).length; + const totalResponseTime = logs.reduce((sum, l) => sum + l.responseTime, 0); + + // Group by endpoint + const endpointMap = new Map(); + for (const log of logs) { + const key = `${log.method} ${log.endpoint}`; + const existing = endpointMap.get(key) || { count: 0, responseTime: 0, errors: 0 }; + existing.count++; + existing.responseTime += log.responseTime; + if (log.statusCode >= 400) { + existing.errors++; + } + endpointMap.set(key, existing); + } + + const topEndpoints: EndpointUsage[] = Array.from(endpointMap.entries()) + .map(([key, data]) => { + const [method, endpoint] = key.split(' ', 2); + return { + endpoint, + method, + count: data.count, + averageResponseTime: Math.round(data.responseTime / data.count), + errorCount: data.errors, + }; + }) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + // Group by day + const dayMap = new Map(); + for (const log of logs) { + const day = log.createdAt.toISOString().split('T')[0]; + dayMap.set(day, (dayMap.get(day) || 0) + 1); + } + + const requestsByDay: DailyRequestCount[] = Array.from(dayMap.entries()) + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)); + + // Group by hour + const hourMap = new Map(); + for (const log of logs) { + const hour = log.createdAt.getHours(); + hourMap.set(hour, (hourMap.get(hour) || 0) + 1); + } + + const requestsByHour: HourlyRequestCount[] = Array.from(hourMap.entries()) + .map(([hour, count]) => ({ hour, count })) + .sort((a, b) => a.hour - b.hour); + + return { + totalRequests, + uniqueEndpoints: endpointMap.size, + averageResponseTime: totalRequests > 0 ? Math.round(totalResponseTime / totalRequests) : 0, + errorRate: totalRequests > 0 ? Math.round((errorCount / totalRequests) * 100) : 0, + topEndpoints, + requestsByDay, + requestsByHour, + }; + } + + /** + * Get a full usage report for an API key + */ + async getUsageReport(apiKeyId: string, startDate: Date, endDate: Date): Promise { + const apiKey = await this.prisma.apiKey.findUnique({ + where: { id: apiKeyId }, + select: { id: true, name: true }, + }); + + if (!apiKey) { + throw new Error(`API key with ID ${apiKeyId} not found`); + } + + const summary = await this.getAnalyticsSummary(apiKeyId, startDate, endDate); + + return { + apiKeyId: apiKey.id, + apiKeyName: apiKey.name, + period: { start: startDate, end: endDate }, + summary, + }; + } + + /** + * Get analytics for all API keys (admin view) + */ + async getAllKeysAnalytics(startDate: Date, endDate: Date): Promise { + const apiKeys = await this.prisma.apiKey.findMany({ + select: { id: true, name: true }, + }); + + const reports: ApiKeyUsageReport[] = []; + for (const apiKey of apiKeys) { + const summary = await this.getAnalyticsSummary(apiKey.id, startDate, endDate); + if (summary.totalRequests > 0) { + reports.push({ + apiKeyId: apiKey.id, + apiKeyName: apiKey.name, + period: { start: startDate, end: endDate }, + summary, + }); + } + } + + return reports.sort((a, b) => b.summary.totalRequests - a.summary.totalRequests); + } + + /** + * Clean up old usage logs (data retention) + */ + async cleanupOldLogs(retentionDays: number): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + + const result = await this.prisma.apiKeyUsageLog.deleteMany({ + where: { + createdAt: { lt: cutoffDate }, + }, + }); + + this.logger.log(`Cleaned up ${result.count} old API key usage logs`); + return result.count; + } + + /** + * Get usage statistics for a specific endpoint + */ + async getEndpointStats( + endpoint: string, + startDate: Date, + endDate: Date, + ): Promise<{ + totalRequests: number; + averageResponseTime: number; + errorRate: number; + topApiKeys: { apiKeyId: string; apiKeyName: string; count: number }[]; + }> { + const logs = await this.prisma.apiKeyUsageLog.findMany({ + where: { + endpoint, + createdAt: { + gte: startDate, + lte: endDate, + }, + }, + include: { + apiKey: { + select: { id: true, name: true }, + }, + }, + }); + + const totalRequests = logs.length; + const errorCount = logs.filter(l => l.statusCode >= 400).length; + const totalResponseTime = logs.reduce((sum, l) => sum + l.responseTime, 0); + + // Group by API key + const keyMap = new Map(); + for (const log of logs) { + const existing = keyMap.get(log.apiKeyId) || { name: log.apiKey.name, count: 0 }; + existing.count++; + keyMap.set(log.apiKeyId, existing); + } + + const topApiKeys = Array.from(keyMap.entries()) + .map(([apiKeyId, data]) => ({ apiKeyId, apiKeyName: data.name, count: data.count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + return { + totalRequests, + averageResponseTime: totalRequests > 0 ? Math.round(totalResponseTime / totalRequests) : 0, + errorRate: totalRequests > 0 ? Math.round((errorCount / totalRequests) * 100) : 0, + topApiKeys, + }; + } +} diff --git a/src/api-keys/api-key-rotation.scheduler.ts b/src/api-keys/api-key-rotation.scheduler.ts new file mode 100644 index 00000000..306d73ca --- /dev/null +++ b/src/api-keys/api-key-rotation.scheduler.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { ApiKeyService } from './api-key.service'; +import { ApiKeyAnalyticsService } from './api-key-analytics.service'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class ApiKeyRotationScheduler { + private readonly logger = new Logger(ApiKeyRotationScheduler.name); + private readonly retentionDays: number; + + constructor( + private readonly apiKeyService: ApiKeyService, + private readonly analyticsService: ApiKeyAnalyticsService, + private readonly configService: ConfigService, + ) { + this.retentionDays = this.configService.get('API_KEY_LOG_RETENTION_DAYS', 90); + } + + /** + * Check for expired API keys and rotate them automatically + * Runs daily at midnight + */ + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async handleAutomaticRotation(): Promise { + this.logger.log('Starting automatic API key rotation check...'); + + try { + const results = await this.apiKeyService.autoRotateExpiredKeys(); + + if (results.length > 0) { + this.logger.log(`Automatically rotated ${results.length} API key(s)`); + results.forEach(result => { + this.logger.log(` - ${result.name}: ${result.oldKeyPrefix} -> ${result.newKeyPrefix}`); + }); + } else { + this.logger.log('No API keys required automatic rotation'); + } + } catch (error) { + this.logger.error(`Automatic rotation failed: ${error.message}`); + } + } + + /** + * Check for keys approaching rotation and log warnings + * Runs daily at 6 AM + */ + @Cron(CronExpression.EVERY_DAY_AT_6AM) + async handleRotationWarnings(): Promise { + this.logger.log('Checking for API keys approaching rotation...'); + + try { + const approachingKeys = await this.apiKeyService.getKeysApproachingRotation(); + + if (approachingKeys.length > 0) { + this.logger.warn(`${approachingKeys.length} API key(s) will require rotation soon:`); + approachingKeys.forEach(key => { + this.logger.warn(` - ${key.name} (${key.keyPrefix}): ${key.daysUntilRotation} days until rotation`); + }); + } else { + this.logger.log('No API keys approaching rotation'); + } + } catch (error) { + this.logger.error(`Rotation warning check failed: ${error.message}`); + } + } + + /** + * Clean up old usage logs for data retention compliance + * Runs weekly on Sunday at 2 AM + */ + @Cron(CronExpression.EVERY_WEEK) + async handleLogCleanup(): Promise { + this.logger.log('Starting API key usage log cleanup...'); + + try { + const deletedCount = await this.analyticsService.cleanupOldLogs(this.retentionDays); + this.logger.log(`Cleaned up ${deletedCount} old usage log entries`); + } catch (error) { + this.logger.error(`Log cleanup failed: ${error.message}`); + } + } +} diff --git a/src/api-keys/api-key.controller.ts b/src/api-keys/api-key.controller.ts index c126ea7e..43643e3c 100644 --- a/src/api-keys/api-key.controller.ts +++ b/src/api-keys/api-key.controller.ts @@ -11,8 +11,8 @@ import { HttpStatus, Query, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; -import { ApiKeyService } from './api-key.service'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { ApiKeyService, RotationStatus, RotationResult } from './api-key.service'; import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { UpdateApiKeyDto } from './dto/update-api-key.dto'; import { ApiKeyResponseDto, CreateApiKeyResponseDto } from './dto/api-key-response.dto'; @@ -125,4 +125,91 @@ export class ApiKeyController { async revoke(@Param('id') id: string): Promise { return this.apiKeyService.revoke(id); } + + // ==================== ROTATION ENDPOINTS ==================== + + @Post(':id/rotate') + @ApiOperation({ + summary: 'Rotate API key', + description: 'Generate a new API key and deactivate the old one. The new key is shown only once.', + }) + @ApiParam({ name: 'id', description: 'API key ID' }) + @ApiResponse({ + status: 200, + description: 'API key rotated successfully', + }) + @ApiResponse({ status: 404, description: 'API key not found' }) + @ApiResponse({ status: 400, description: 'Cannot rotate a revoked API key' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async rotateKey(@Param('id') id: string): Promise { + return this.apiKeyService.rotateKey(id); + } + + @Get(':id/rotation-status') + @ApiOperation({ + summary: 'Get rotation status', + description: 'Check if an API key requires rotation and when it was last rotated', + }) + @ApiParam({ name: 'id', description: 'API key ID' }) + @ApiResponse({ + status: 200, + description: 'Rotation status retrieved successfully', + }) + @ApiResponse({ status: 404, description: 'API key not found' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getRotationStatus(@Param('id') id: string): Promise { + return this.apiKeyService.getRotationStatus(id); + } + + @Get('rotation/required') + @ApiOperation({ + summary: 'Get keys requiring rotation', + description: 'List all API keys that have passed their rotation due date', + }) + @ApiResponse({ + status: 200, + description: 'List of API keys requiring rotation', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getKeysRequiringRotation(): Promise { + return this.apiKeyService.getKeysRequiringRotation(); + } + + @Get('rotation/approaching') + @ApiOperation({ + summary: 'Get keys approaching rotation', + description: 'List all API keys that will require rotation within the warning period', + }) + @ApiResponse({ + status: 200, + description: 'List of API keys approaching rotation', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getKeysApproachingRotation(): Promise { + return this.apiKeyService.getKeysApproachingRotation(); + } + + // ==================== ANALYTICS ENDPOINTS ==================== + + @Get(':id/analytics') + @ApiOperation({ + summary: 'Get API key usage analytics', + description: 'Retrieve detailed usage analytics for a specific API key', + }) + @ApiParam({ name: 'id', description: 'API key ID' }) + @ApiQuery({ name: 'startDate', description: 'Start date (ISO 8601)', example: '2026-01-01T00:00:00Z' }) + @ApiQuery({ name: 'endDate', description: 'End date (ISO 8601)', example: '2026-01-31T23:59:59Z' }) + @ApiResponse({ + status: 200, + description: 'Usage analytics retrieved successfully', + }) + @ApiResponse({ status: 404, description: 'API key not found' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getUsageAnalytics( + @Param('id') id: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + return this.apiKeyService.getUsageAnalytics(id, new Date(startDate), new Date(endDate)); + } } diff --git a/src/api-keys/api-key.service.ts b/src/api-keys/api-key.service.ts index a1fbc959..8ff5b1c0 100644 --- a/src/api-keys/api-key.service.ts +++ b/src/api-keys/api-key.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, UnauthorizedException, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../database/prisma/prisma.service'; import { RedisService } from '../common/services/redis.service'; @@ -7,22 +7,48 @@ import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { UpdateApiKeyDto } from './dto/update-api-key.dto'; import { ApiKeyResponseDto, CreateApiKeyResponseDto } from './dto/api-key-response.dto'; import { API_KEY_SCOPES, ApiKeyScope } from './enums/api-key-scope.enum'; +import { ApiKeyAnalyticsService, UsageLogEntry } from './api-key-analytics.service'; import * as crypto from 'crypto'; import * as CryptoJS from 'crypto-js'; +export interface RotationResult { + id: string; + name: string; + oldKeyPrefix: string; + newKeyPrefix: string; + key: string; // New plain key (shown only once) + rotatedAt: Date; +} + +export interface RotationStatus { + id: string; + name: string; + keyPrefix: string; + lastRotatedAt?: Date; + rotationDueAt?: Date; + daysUntilRotation?: number; + requiresRotation: boolean; +} + @Injectable() export class ApiKeyService { private readonly encryptionKey: string; private readonly globalRateLimit: number; + private readonly rotationIntervalDays: number; + private readonly rotationWarningDays: number; + private readonly logger = new Logger(ApiKeyService.name); constructor( private readonly prisma: PrismaService, private readonly redis: RedisService, private readonly configService: ConfigService, private readonly paginationService: PaginationService, + private readonly analyticsService: ApiKeyAnalyticsService, ) { this.encryptionKey = this.configService.get('ENCRYPTION_KEY'); this.globalRateLimit = this.configService.get('API_KEY_RATE_LIMIT_PER_MINUTE', 60); + this.rotationIntervalDays = this.configService.get('API_KEY_ROTATION_DAYS', 90); + this.rotationWarningDays = this.configService.get('API_KEY_ROTATION_WARNING_DAYS', 7); if (!this.encryptionKey) { throw new Error('ENCRYPTION_KEY must be set in environment variables'); @@ -36,6 +62,10 @@ export class ApiKeyService { const keyPrefix = this.extractKeyPrefix(plainKey); const encryptedKey = this.encryptKey(plainKey); + // Set rotation due date + const rotationDueAt = new Date(); + rotationDueAt.setDate(rotationDueAt.getDate() + this.rotationIntervalDays); + const apiKey = await this.prisma.apiKey.create({ data: { name: createApiKeyDto.name, @@ -43,6 +73,8 @@ export class ApiKeyService { keyPrefix, scopes: createApiKeyDto.scopes, rateLimit: createApiKeyDto.rateLimit, + rotationDueAt, + lastRotatedAt: new Date(), }, }); @@ -253,8 +285,212 @@ export class ApiKeyService { lastUsedAt: apiKey.lastUsedAt, isActive: apiKey.isActive, rateLimit: apiKey.rateLimit, + lastRotatedAt: apiKey.lastRotatedAt, + rotationDueAt: apiKey.rotationDueAt, createdAt: apiKey.createdAt, updatedAt: apiKey.updatedAt, }; } + + // ==================== KEY ROTATION METHODS ==================== + + /** + * Rotate an API key - generates a new key and deactivates the old one + */ + async rotateKey(id: string): Promise { + const apiKey = await this.prisma.apiKey.findUnique({ + where: { id }, + }); + + if (!apiKey) { + throw new NotFoundException(`API key with ID ${id} not found`); + } + + if (!apiKey.isActive) { + throw new BadRequestException('Cannot rotate a revoked API key'); + } + + // Generate new key + const newPlainKey = this.generateApiKey(); + const newKeyPrefix = this.extractKeyPrefix(newPlainKey); + const newEncryptedKey = this.encryptKey(newPlainKey); + + // Calculate new rotation due date + const newRotationDueAt = new Date(); + newRotationDueAt.setDate(newRotationDueAt.getDate() + this.rotationIntervalDays); + + // Update the API key with new values + const updatedKey = await this.prisma.apiKey.update({ + where: { id }, + data: { + key: newEncryptedKey, + keyPrefix: newKeyPrefix, + keyVersion: { increment: 1 }, + lastRotatedAt: new Date(), + rotationDueAt: newRotationDueAt, + }, + }); + + // Clear the old rate limit cache + await this.redis.del(`rate_limit:${apiKey.keyPrefix}`); + + this.logger.log(`Rotated API key ${id}: ${apiKey.keyPrefix} -> ${newKeyPrefix}`); + + return { + id: updatedKey.id, + name: updatedKey.name, + oldKeyPrefix: apiKey.keyPrefix, + newKeyPrefix, + key: newPlainKey, + rotatedAt: updatedKey.lastRotatedAt!, + }; + } + + /** + * Get rotation status for an API key + */ + async getRotationStatus(id: string): Promise { + const apiKey = await this.prisma.apiKey.findUnique({ + where: { id }, + }); + + if (!apiKey) { + throw new NotFoundException(`API key with ID ${id} not found`); + } + + const now = new Date(); + const rotationDueAt = apiKey.rotationDueAt; + const daysUntilRotation = rotationDueAt + ? Math.ceil((rotationDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + : undefined; + + return { + id: apiKey.id, + name: apiKey.name, + keyPrefix: apiKey.keyPrefix, + lastRotatedAt: apiKey.lastRotatedAt || undefined, + rotationDueAt: rotationDueAt || undefined, + daysUntilRotation, + requiresRotation: daysUntilRotation !== undefined && daysUntilRotation <= 0, + }; + } + + /** + * Get all API keys that require rotation + */ + async getKeysRequiringRotation(): Promise { + const now = new Date(); + + const keys = await this.prisma.apiKey.findMany({ + where: { + isActive: true, + rotationDueAt: { lte: now }, + }, + }); + + return keys.map(apiKey => ({ + id: apiKey.id, + name: apiKey.name, + keyPrefix: apiKey.keyPrefix, + lastRotatedAt: apiKey.lastRotatedAt || undefined, + rotationDueAt: apiKey.rotationDueAt || undefined, + daysUntilRotation: 0, + requiresRotation: true, + })); + } + + /** + * Get API keys approaching rotation (within warning period) + */ + async getKeysApproachingRotation(): Promise { + const now = new Date(); + const warningDate = new Date(); + warningDate.setDate(warningDate.getDate() + this.rotationWarningDays); + + const keys = await this.prisma.apiKey.findMany({ + where: { + isActive: true, + rotationDueAt: { + gt: now, + lte: warningDate, + }, + }, + }); + + return keys.map(apiKey => { + const daysUntilRotation = apiKey.rotationDueAt + ? Math.ceil((apiKey.rotationDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + : 0; + + return { + id: apiKey.id, + name: apiKey.name, + keyPrefix: apiKey.keyPrefix, + lastRotatedAt: apiKey.lastRotatedAt || undefined, + rotationDueAt: apiKey.rotationDueAt || undefined, + daysUntilRotation, + requiresRotation: false, + }; + }); + } + + /** + * Automatic rotation for all expired keys (called by scheduler) + */ + async autoRotateExpiredKeys(): Promise { + const expiredKeys = await this.getKeysRequiringRotation(); + const results: RotationResult[] = []; + + for (const key of expiredKeys) { + try { + const result = await this.rotateKey(key.id); + results.push(result); + this.logger.log(`Auto-rotated API key: ${key.name} (${key.id})`); + } catch (error) { + this.logger.error(`Failed to auto-rotate API key ${key.id}: ${error.message}`); + } + } + + return results; + } + + // ==================== USAGE ANALYTICS INTEGRATION ==================== + + /** + * Log detailed usage for analytics + */ + async logDetailedUsage( + apiKeyId: string, + endpoint: string, + method: string, + statusCode: number, + responseTime: number, + ipAddress?: string, + userAgent?: string, + ): Promise { + await this.analyticsService.logUsage({ + apiKeyId, + endpoint, + method, + statusCode, + responseTime, + ipAddress, + userAgent, + }); + } + + /** + * Get usage analytics for an API key + */ + async getUsageAnalytics(id: string, startDate: Date, endDate: Date) { + const apiKey = await this.prisma.apiKey.findUnique({ + where: { id }, + }); + + if (!apiKey) { + throw new NotFoundException(`API key with ID ${id} not found`); + } + + return this.analyticsService.getUsageReport(id, startDate, endDate); + } } diff --git a/src/api-keys/api-keys.module.ts b/src/api-keys/api-keys.module.ts index 76dc089a..e35e1994 100644 --- a/src/api-keys/api-keys.module.ts +++ b/src/api-keys/api-keys.module.ts @@ -1,13 +1,16 @@ import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; import { ApiKeyService } from './api-key.service'; import { ApiKeyController } from './api-key.controller'; +import { ApiKeyAnalyticsService } from './api-key-analytics.service'; +import { ApiKeyRotationScheduler } from './api-key-rotation.scheduler'; import { PrismaModule } from '../database/prisma/prisma.module'; import { PaginationService } from '../common/pagination'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, ScheduleModule.forRoot()], controllers: [ApiKeyController], - providers: [ApiKeyService, PaginationService], - exports: [ApiKeyService], + providers: [ApiKeyService, ApiKeyAnalyticsService, ApiKeyRotationScheduler, PaginationService], + exports: [ApiKeyService, ApiKeyAnalyticsService], }) export class ApiKeysModule {} diff --git a/src/api-keys/dto/api-key-response.dto.ts b/src/api-keys/dto/api-key-response.dto.ts index c669d677..485c1d3f 100644 --- a/src/api-keys/dto/api-key-response.dto.ts +++ b/src/api-keys/dto/api-key-response.dto.ts @@ -25,6 +25,12 @@ export class ApiKeyResponseDto { @ApiProperty({ example: 100, nullable: true }) rateLimit: number | null; + @ApiProperty({ example: '2026-01-15T08:00:00.000Z', nullable: true }) + lastRotatedAt?: Date; + + @ApiProperty({ example: '2026-04-15T08:00:00.000Z', nullable: true }) + rotationDueAt?: Date; + @ApiProperty({ example: '2026-01-15T08:00:00.000Z' }) createdAt: Date; diff --git a/test/api-keys/api-key.service.spec.ts b/test/api-keys/api-key.service.spec.ts index edfb407a..5e4597b3 100644 --- a/test/api-keys/api-key.service.spec.ts +++ b/test/api-keys/api-key.service.spec.ts @@ -2,10 +2,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UnauthorizedException, BadRequestException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ApiKeyService } from '../../src/api-keys/api-key.service'; +import { ApiKeyAnalyticsService } from '../../src/api-keys/api-key-analytics.service'; import { PrismaService } from '../../src/database/prisma/prisma.service'; import { RedisService } from '../../src/common/services/redis.service'; import { CreateApiKeyDto } from '../../src/api-keys/dto/create-api-key.dto'; import { UpdateApiKeyDto } from '../../src/api-keys/dto/update-api-key.dto'; +import { ApiKeyResponseDto } from '../../src/api-keys/dto/api-key-response.dto'; import { ApiKeyScope } from '../../src/api-keys/enums/api-key-scope.enum'; import { PaginationService } from '../../src/common/pagination/pagination.service'; @@ -14,6 +16,7 @@ describe('ApiKeyService', () => { let prismaService: PrismaService; let redisService: RedisService; let configService: ConfigService; + let analyticsService: ApiKeyAnalyticsService; const mockPrismaService = { apiKey: { @@ -35,14 +38,23 @@ describe('ApiKeyService', () => { const mockConfigService = { get: jest.fn((key: string) => { - const config = { + const config: Record = { ENCRYPTION_KEY: 'test-encryption-key-32-characters', API_KEY_RATE_LIMIT_PER_MINUTE: 60, + API_KEY_ROTATION_DAYS: 90, + API_KEY_ROTATION_WARNING_DAYS: 7, }; return config[key]; }), }; + const mockAnalyticsService = { + logUsage: jest.fn(), + getUsageReport: jest.fn(), + getAnalyticsSummary: jest.fn(), + cleanupOldLogs: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -51,6 +63,7 @@ describe('ApiKeyService', () => { { provide: RedisService, useValue: mockRedisService }, { provide: ConfigService, useValue: mockConfigService }, { provide: PaginationService, useValue: {} }, + { provide: ApiKeyAnalyticsService, useValue: mockAnalyticsService }, ], }).compile(); @@ -58,6 +71,7 @@ describe('ApiKeyService', () => { prismaService = module.get(PrismaService); redisService = module.get(RedisService); configService = module.get(ConfigService); + analyticsService = module.get(ApiKeyAnalyticsService); jest.clearAllMocks(); }); @@ -130,7 +144,7 @@ describe('ApiKeyService', () => { mockPrismaService.apiKey.findMany.mockResolvedValue(mockApiKeys); - const result = await service.findAll(); + const result = (await service.findAll()) as ApiKeyResponseDto[]; expect(result).toHaveLength(1); expect(result[0].name).toBe('Test API Key 1'); @@ -281,4 +295,211 @@ describe('ApiKeyService', () => { await expect(service.validateApiKey('propchain_live_abc123xyz')).rejects.toThrow(UnauthorizedException); }); }); + + // ==================== ROTATION TESTS ==================== + + describe('rotateKey', () => { + it('should rotate an API key successfully', async () => { + const mockApiKey = { + id: 'test-id', + name: 'Test API Key', + key: 'encrypted-old-key', + keyPrefix: 'propchain_live_oldprefix', + keyVersion: 1, + scopes: [ApiKeyScope.READ_PROPERTIES], + requestCount: BigInt(5), + lastUsedAt: new Date(), + isActive: true, + rateLimit: 60, + lastRotatedAt: new Date('2026-01-01'), + rotationDueAt: new Date('2026-03-24'), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const updatedApiKey = { + ...mockApiKey, + keyVersion: 2, + lastRotatedAt: new Date(), + }; + + mockPrismaService.apiKey.findUnique.mockResolvedValue(mockApiKey); + mockPrismaService.apiKey.update.mockResolvedValue(updatedApiKey); + mockRedisService.del.mockResolvedValue(1); + + const result = await service.rotateKey('test-id'); + + expect(result.id).toBe('test-id'); + expect(result.name).toBe('Test API Key'); + expect(result.oldKeyPrefix).toBe('propchain_live_oldprefix'); + expect(result.key).toMatch(/^propchain_live_/); + expect(mockRedisService.del).toHaveBeenCalledWith('rate_limit:propchain_live_oldprefix'); + }); + + it('should throw NotFoundException if API key not found', async () => { + mockPrismaService.apiKey.findUnique.mockResolvedValue(null); + + await expect(service.rotateKey('non-existent-id')).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException if API key is revoked', async () => { + const mockApiKey = { + id: 'test-id', + name: 'Test API Key', + key: 'encrypted-key', + keyPrefix: 'propchain_live_abc123', + isActive: false, + }; + + mockPrismaService.apiKey.findUnique.mockResolvedValue(mockApiKey); + + await expect(service.rotateKey('test-id')).rejects.toThrow(BadRequestException); + }); + }); + + describe('getRotationStatus', () => { + it('should return rotation status for an API key', async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + + const mockApiKey = { + id: 'test-id', + name: 'Test API Key', + keyPrefix: 'propchain_live_abc123', + lastRotatedAt: new Date('2026-01-01'), + rotationDueAt: futureDate, + isActive: true, + }; + + mockPrismaService.apiKey.findUnique.mockResolvedValue(mockApiKey); + + const result = await service.getRotationStatus('test-id'); + + expect(result.id).toBe('test-id'); + expect(result.requiresRotation).toBe(false); + expect(result.daysUntilRotation).toBeGreaterThan(0); + }); + + it('should return requiresRotation true for expired key', async () => { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 10); + + const mockApiKey = { + id: 'test-id', + name: 'Test API Key', + keyPrefix: 'propchain_live_abc123', + lastRotatedAt: new Date('2025-12-01'), + rotationDueAt: pastDate, + isActive: true, + }; + + mockPrismaService.apiKey.findUnique.mockResolvedValue(mockApiKey); + + const result = await service.getRotationStatus('test-id'); + + expect(result.requiresRotation).toBe(true); + expect(result.daysUntilRotation).toBeLessThanOrEqual(0); + }); + + it('should throw NotFoundException if API key not found', async () => { + mockPrismaService.apiKey.findUnique.mockResolvedValue(null); + + await expect(service.getRotationStatus('non-existent-id')).rejects.toThrow(NotFoundException); + }); + }); + + describe('getKeysRequiringRotation', () => { + it('should return keys that have passed rotation due date', async () => { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 10); + + const mockApiKeys = [ + { + id: 'test-id-1', + name: 'Expired Key 1', + keyPrefix: 'propchain_live_expired1', + lastRotatedAt: new Date('2025-12-01'), + rotationDueAt: pastDate, + isActive: true, + }, + ]; + + mockPrismaService.apiKey.findMany.mockResolvedValue(mockApiKeys); + + const result = await service.getKeysRequiringRotation(); + + expect(result).toHaveLength(1); + expect(result[0].requiresRotation).toBe(true); + }); + }); + + describe('getKeysApproachingRotation', () => { + it('should return keys within warning period', async () => { + const nearFutureDate = new Date(); + nearFutureDate.setDate(nearFutureDate.getDate() + 5); + + const mockApiKeys = [ + { + id: 'test-id-1', + name: 'Approaching Key', + keyPrefix: 'propchain_live_approaching', + lastRotatedAt: new Date('2026-01-01'), + rotationDueAt: nearFutureDate, + isActive: true, + }, + ]; + + mockPrismaService.apiKey.findMany.mockResolvedValue(mockApiKeys); + + const result = await service.getKeysApproachingRotation(); + + expect(result).toHaveLength(1); + expect(result[0].requiresRotation).toBe(false); + }); + }); + + describe('getUsageAnalytics', () => { + it('should return usage analytics for an API key', async () => { + const mockApiKey = { + id: 'test-id', + name: 'Test API Key', + keyPrefix: 'propchain_live_abc123', + }; + + const mockReport = { + apiKeyId: 'test-id', + apiKeyName: 'Test API Key', + period: { start: new Date('2026-01-01'), end: new Date('2026-01-31') }, + summary: { + totalRequests: 100, + uniqueEndpoints: 5, + averageResponseTime: 150, + errorRate: 2, + topEndpoints: [], + requestsByDay: [], + requestsByHour: [], + }, + }; + + mockPrismaService.apiKey.findUnique.mockResolvedValue(mockApiKey); + mockAnalyticsService.getUsageReport.mockResolvedValue(mockReport); + + const result = await service.getUsageAnalytics( + 'test-id', + new Date('2026-01-01'), + new Date('2026-01-31'), + ); + + expect(result.apiKeyId).toBe('test-id'); + expect(result.summary.totalRequests).toBe(100); + }); + + it('should throw NotFoundException if API key not found', async () => { + mockPrismaService.apiKey.findUnique.mockResolvedValue(null); + + await expect( + service.getUsageAnalytics('non-existent-id', new Date(), new Date()), + ).rejects.toThrow(NotFoundException); + }); + }); });