diff --git a/backend/src/auth/brute-force.guard.spec.ts b/backend/src/auth/brute-force.guard.spec.ts index 76c50dad..02ff40e0 100644 --- a/backend/src/auth/brute-force.guard.spec.ts +++ b/backend/src/auth/brute-force.guard.spec.ts @@ -1,60 +1,117 @@ -import { BruteForceGuard } from './brute-force.guard'; import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +const store = new Map(); + +jest.mock('ioredis', () => ({ + default: jest.fn().mockImplementation(() => ({ + get: jest.fn((key: string) => Promise.resolve(store.get(key) || null)), + setex: jest.fn((key: string, _ttl: number, value: string) => { + store.set(key, value); + return Promise.resolve('OK'); + }), + incr: jest.fn((key: string) => { + const current = parseInt(store.get(key) || '0', 10); + store.set(key, String(current + 1)); + return Promise.resolve(current + 1); + }), + expire: jest.fn(() => Promise.resolve(1)), + del: jest.fn((...keys: string[]) => { + keys.forEach((k) => store.delete(k)); + return Promise.resolve(keys.length); + }), + quit: jest.fn(() => Promise.resolve('OK')), + flushall: jest.fn(() => { + store.clear(); + return Promise.resolve('OK'); + }), + })), +})); + +import { BruteForceGuard } from './brute-force.guard'; + +const mockContext = (body: any): ExecutionContext => ({ + switchToHttp: () => ({ + getRequest: () => ({ body }), + }), + getHandler: () => jest.fn(), + getClass: () => jest.fn(), +} as any); describe('BruteForceGuard', () => { let guard: BruteForceGuard; - let context: ExecutionContext; - beforeEach(() => { - guard = new BruteForceGuard(); - context = { - switchToHttp: () => ({ - getRequest: () => ({ - body: { email: 'test@example.com' }, - }), - }), - } as ExecutionContext; + beforeEach(async () => { + const { Test } = await import('@nestjs/testing'); + const testModule = await Test.createTestingModule({ + providers: [ + BruteForceGuard, + { + provide: ConfigService, + useValue: { + get: (key: string) => { + if (key === 'REDIS_HOST') return '127.0.0.1'; + if (key === 'REDIS_PORT') return '6379'; + return undefined; + }, + }, + }, + ], + }).compile(); + + guard = testModule.get(BruteForceGuard); + await (guard as any).redis.flushall(); + }); + + afterEach(async () => { + await guard.onModuleDestroy(); }); it('should be defined', () => { expect(guard).toBeDefined(); }); - it('should allow access if email is not present', () => { - context = { - switchToHttp: () => ({ - getRequest: () => ({ - body: {}, - }), - }), - } as ExecutionContext; - expect(guard.canActivate(context)).toBe(true); + it('should allow access when no email in body', async () => { + const result = await guard.canActivate(mockContext({})); + expect(result).toBe(true); }); - it('should allow access if there are no previous failed attempts', () => { - expect(guard.canActivate(context)).toBe(true); + it('should allow access for non-locked account', async () => { + const result = await guard.canActivate( + mockContext({ email: 'test@example.com' }), + ); + expect(result).toBe(true); }); - it('should lock the account after MAX_ATTEMPTS', () => { - const email = 'test@example.com'; + it('should lock account after max failed attempts', async () => { + const email = 'brute@example.com'; for (let i = 0; i < 5; i++) { - guard.recordFailedLogin(email); + await guard.recordFailedLogin(email); } - expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + + await expect( + guard.canActivate(mockContext({ email })), + ).rejects.toThrow(UnauthorizedException); }); - it('should deny access if the account is locked', () => { - const email = 'test@example.com'; - for (let i = 0; i < 5; i++) { - guard.recordFailedLogin(email); + it('should allow access for email not yet at threshold', async () => { + const email = 'partial@example.com'; + for (let i = 0; i < 4; i++) { + await guard.recordFailedLogin(email); } - expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + + const result = await guard.canActivate(mockContext({ email })); + expect(result).toBe(true); }); - it('should reset attempts after a successful login', () => { - const email = 'test@example.com'; - guard.recordFailedLogin(email); - guard.resetAttempts(email); - expect(guard.canActivate(context)).toBe(true); + it('should reset attempts after clearing', async () => { + const email = 'reset@example.com'; + for (let i = 0; i < 3; i++) { + await guard.recordFailedLogin(email); + } + await guard.resetAttempts(email); + + const result = await guard.canActivate(mockContext({ email })); + expect(result).toBe(true); }); }); diff --git a/backend/src/auth/brute-force.guard.ts b/backend/src/auth/brute-force.guard.ts index 461b4632..72d34535 100644 --- a/backend/src/auth/brute-force.guard.ts +++ b/backend/src/auth/brute-force.guard.ts @@ -4,24 +4,35 @@ import { ExecutionContext, UnauthorizedException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +const LOCKOUT_PREFIX = 'brute:lock:'; +const ATTEMPTS_PREFIX = 'brute:attempts:'; +const MAX_ATTEMPTS = 5; +const LOCK_TIME_SECONDS = 15 * 60; // 15 minutes @Injectable() export class BruteForceGuard implements CanActivate { - private failedAttempts = new Map< - string, - { count: number; lockUntil?: Date } - >(); - private readonly MAX_ATTEMPTS = 5; - private readonly LOCK_TIME_MS = 15 * 60 * 1000; // 15 mins - - canActivate(context: ExecutionContext): boolean { + private readonly redis: Redis; + + constructor(private readonly configService: ConfigService) { + const host = this.configService.get('REDIS_HOST') || '127.0.0.1'; + const port = Number(this.configService.get('REDIS_PORT') || '6379'); + const password = + this.configService.get('REDIS_PASSWORD') || undefined; + this.redis = new Redis({ host, port, password }); + } + + async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); const email = req.body?.email || req.body?.username; if (!email) return true; - const record = this.failedAttempts.get(email); - if (record && record.lockUntil && record.lockUntil > new Date()) { + const lockKey = `${LOCKOUT_PREFIX}${email}`; + const isLocked = await this.redis.get(lockKey); + if (isLocked) { throw new UnauthorizedException( 'Account locked due to multiple failed login attempts. Please try again later.', ); @@ -30,16 +41,22 @@ export class BruteForceGuard implements CanActivate { return true; } - recordFailedLogin(email: string) { - const record = this.failedAttempts.get(email) || { count: 0 }; - record.count += 1; - if (record.count >= this.MAX_ATTEMPTS) { - record.lockUntil = new Date(Date.now() + this.LOCK_TIME_MS); + async recordFailedLogin(email: string): Promise { + const attemptsKey = `${ATTEMPTS_PREFIX}${email}`; + const count = await this.redis.incr(attemptsKey); + await this.redis.expire(attemptsKey, LOCK_TIME_SECONDS); + + if (count >= MAX_ATTEMPTS) { + const lockKey = `${LOCKOUT_PREFIX}${email}`; + await this.redis.setex(lockKey, LOCK_TIME_SECONDS, 'locked'); } - this.failedAttempts.set(email, record); } - resetAttempts(email: string) { - this.failedAttempts.delete(email); + async resetAttempts(email: string): Promise { + await this.redis.del(`${ATTEMPTS_PREFIX}${email}`, `${LOCKOUT_PREFIX}${email}`); + } + + async onModuleDestroy(): Promise { + await this.redis.quit(); } } diff --git a/backend/src/external-validation/providers/business-registration.provider.spec.ts b/backend/src/external-validation/providers/business-registration.provider.spec.ts new file mode 100644 index 00000000..09b31c2a --- /dev/null +++ b/backend/src/external-validation/providers/business-registration.provider.spec.ts @@ -0,0 +1,42 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { BusinessRegistrationProvider } from './business-registration.provider'; + +describe('BusinessRegistrationProvider', () => { + let provider: BusinessRegistrationProvider; + + beforeEach(() => { + provider = new BusinessRegistrationProvider(); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + it('should return a valid response', async () => { + const result = await provider.validateDocument(); + expect(result.success).toBe(true); + expect(result.result).toBe('VALID'); + }); + + it('should report healthy when circuit is closed', async () => { + const healthy = await provider.healthCheck(); + expect(healthy).toBe(true); + }); + + it('should open circuit after threshold failures', async () => { + for (let i = 0; i < 3; i++) { + (provider as any).failureCount++; + } + (provider as any).circuitOpenUntil = new Date(Date.now() + 60000); + + await expect(provider.validateDocument()).rejects.toThrow( + ServiceUnavailableException, + ); + }); + + it('should report unhealthy when circuit is open', async () => { + (provider as any).circuitOpenUntil = new Date(Date.now() + 60000); + const healthy = await provider.healthCheck(); + expect(healthy).toBe(false); + }); +}); diff --git a/backend/src/external-validation/providers/business-registration.provider.ts b/backend/src/external-validation/providers/business-registration.provider.ts index 2ad25369..4826bb34 100644 --- a/backend/src/external-validation/providers/business-registration.provider.ts +++ b/backend/src/external-validation/providers/business-registration.provider.ts @@ -1,13 +1,68 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { IValidationProvider, ValidationResponse, } from '../interfaces/validation-provider.interface'; import { ValidationResult } from '../entities/validation-request.entity'; +const REQUEST_TIMEOUT_MS = 10_000; +const CIRCUIT_BREAKER_THRESHOLD = 3; +const CIRCUIT_BREAKER_RESET_MS = 60_000; + @Injectable() export class BusinessRegistrationProvider implements IValidationProvider { + private readonly logger = new Logger(BusinessRegistrationProvider.name); + private failureCount = 0; + private circuitOpenUntil: Date | null = null; + async validateDocument(): Promise { + this.checkCircuit(); + + try { + const result = await this.withTimeout( + this.doValidate(), + REQUEST_TIMEOUT_MS, + ); + this.failureCount = 0; + return result; + } catch (error) { + this.failureCount++; + if (this.failureCount >= CIRCUIT_BREAKER_THRESHOLD) { + this.circuitOpenUntil = new Date( + Date.now() + CIRCUIT_BREAKER_RESET_MS, + ); + this.logger.warn( + `Circuit breaker opened for BusinessRegistrationProvider after ${this.failureCount} failures`, + ); + } + throw error; + } + } + + async healthCheck(): Promise { + return !this.isCircuitOpen(); + } + + private checkCircuit(): void { + if (this.isCircuitOpen()) { + throw new ServiceUnavailableException( + 'BusinessRegistrationProvider circuit breaker is open', + ); + } + } + + private isCircuitOpen(): boolean { + if (this.circuitOpenUntil && this.circuitOpenUntil > new Date()) { + return true; + } + if (this.circuitOpenUntil && this.circuitOpenUntil <= new Date()) { + this.circuitOpenUntil = null; + this.failureCount = 0; + } + return false; + } + + private async doValidate(): Promise { return { result: ValidationResult.VALID, data: {}, @@ -21,7 +76,25 @@ export class BusinessRegistrationProvider implements IValidationProvider { }; } - async healthCheck(): Promise { - return true; + private withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new ServiceUnavailableException( + `BusinessRegistrationProvider request timed out after ${ms}ms`, + ), + ); + }, ms); + + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); } } diff --git a/backend/src/external-validation/providers/government-id.provider.spec.ts b/backend/src/external-validation/providers/government-id.provider.spec.ts new file mode 100644 index 00000000..c265cb6c --- /dev/null +++ b/backend/src/external-validation/providers/government-id.provider.spec.ts @@ -0,0 +1,36 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { GovernmentIdProvider } from './government-id.provider'; + +describe('GovernmentIdProvider', () => { + let provider: GovernmentIdProvider; + + beforeEach(() => { + provider = new GovernmentIdProvider(); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + it('should return a valid response', async () => { + const result = await provider.validateDocument(); + expect(result.success).toBe(true); + expect(result.result).toBe('VALID'); + }); + + it('should report healthy when circuit is closed', async () => { + const healthy = await provider.healthCheck(); + expect(healthy).toBe(true); + }); + + it('should open circuit after threshold failures', async () => { + for (let i = 0; i < 3; i++) { + (provider as any).failureCount++; + } + (provider as any).circuitOpenUntil = new Date(Date.now() + 60000); + + await expect(provider.validateDocument()).rejects.toThrow( + ServiceUnavailableException, + ); + }); +}); diff --git a/backend/src/external-validation/providers/government-id.provider.ts b/backend/src/external-validation/providers/government-id.provider.ts index d60832e9..457d6ba4 100644 --- a/backend/src/external-validation/providers/government-id.provider.ts +++ b/backend/src/external-validation/providers/government-id.provider.ts @@ -1,13 +1,68 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { IValidationProvider, ValidationResponse, } from '../interfaces/validation-provider.interface'; import { ValidationResult } from '../entities/validation-request.entity'; +const REQUEST_TIMEOUT_MS = 10_000; +const CIRCUIT_BREAKER_THRESHOLD = 3; +const CIRCUIT_BREAKER_RESET_MS = 60_000; + @Injectable() export class GovernmentIdProvider implements IValidationProvider { + private readonly logger = new Logger(GovernmentIdProvider.name); + private failureCount = 0; + private circuitOpenUntil: Date | null = null; + async validateDocument(): Promise { + this.checkCircuit(); + + try { + const result = await this.withTimeout( + this.doValidate(), + REQUEST_TIMEOUT_MS, + ); + this.failureCount = 0; + return result; + } catch (error) { + this.failureCount++; + if (this.failureCount >= CIRCUIT_BREAKER_THRESHOLD) { + this.circuitOpenUntil = new Date( + Date.now() + CIRCUIT_BREAKER_RESET_MS, + ); + this.logger.warn( + `Circuit breaker opened for GovernmentIdProvider after ${this.failureCount} failures`, + ); + } + throw error; + } + } + + async healthCheck(): Promise { + return !this.isCircuitOpen(); + } + + private checkCircuit(): void { + if (this.isCircuitOpen()) { + throw new ServiceUnavailableException( + 'GovernmentIdProvider circuit breaker is open', + ); + } + } + + private isCircuitOpen(): boolean { + if (this.circuitOpenUntil && this.circuitOpenUntil > new Date()) { + return true; + } + if (this.circuitOpenUntil && this.circuitOpenUntil <= new Date()) { + this.circuitOpenUntil = null; + this.failureCount = 0; + } + return false; + } + + private async doValidate(): Promise { return { result: ValidationResult.VALID, data: {}, @@ -21,7 +76,25 @@ export class GovernmentIdProvider implements IValidationProvider { }; } - async healthCheck(): Promise { - return true; + private withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new ServiceUnavailableException( + `GovernmentIdProvider request timed out after ${ms}ms`, + ), + ); + }, ms); + + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); } } diff --git a/backend/src/external-validation/providers/land-registry.provider.spec.ts b/backend/src/external-validation/providers/land-registry.provider.spec.ts new file mode 100644 index 00000000..42e12899 --- /dev/null +++ b/backend/src/external-validation/providers/land-registry.provider.spec.ts @@ -0,0 +1,36 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { LandRegistryProvider } from './land-registry.provider'; + +describe('LandRegistryProvider', () => { + let provider: LandRegistryProvider; + + beforeEach(() => { + provider = new LandRegistryProvider(); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + it('should return a valid response', async () => { + const result = await provider.validateDocument(); + expect(result.success).toBe(true); + expect(result.result).toBe('VALID'); + }); + + it('should report healthy when circuit is closed', async () => { + const healthy = await provider.healthCheck(); + expect(healthy).toBe(true); + }); + + it('should open circuit after threshold failures', async () => { + for (let i = 0; i < 3; i++) { + (provider as any).failureCount++; + } + (provider as any).circuitOpenUntil = new Date(Date.now() + 60000); + + await expect(provider.validateDocument()).rejects.toThrow( + ServiceUnavailableException, + ); + }); +}); diff --git a/backend/src/external-validation/providers/land-registry.provider.ts b/backend/src/external-validation/providers/land-registry.provider.ts index 5f16b720..ab9dc7e1 100644 --- a/backend/src/external-validation/providers/land-registry.provider.ts +++ b/backend/src/external-validation/providers/land-registry.provider.ts @@ -1,13 +1,68 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { IValidationProvider, ValidationResponse, } from '../interfaces/validation-provider.interface'; import { ValidationResult } from '../entities/validation-request.entity'; +const REQUEST_TIMEOUT_MS = 10_000; +const CIRCUIT_BREAKER_THRESHOLD = 3; +const CIRCUIT_BREAKER_RESET_MS = 60_000; + @Injectable() export class LandRegistryProvider implements IValidationProvider { + private readonly logger = new Logger(LandRegistryProvider.name); + private failureCount = 0; + private circuitOpenUntil: Date | null = null; + async validateDocument(): Promise { + this.checkCircuit(); + + try { + const result = await this.withTimeout( + this.doValidate(), + REQUEST_TIMEOUT_MS, + ); + this.failureCount = 0; + return result; + } catch (error) { + this.failureCount++; + if (this.failureCount >= CIRCUIT_BREAKER_THRESHOLD) { + this.circuitOpenUntil = new Date( + Date.now() + CIRCUIT_BREAKER_RESET_MS, + ); + this.logger.warn( + `Circuit breaker opened for LandRegistryProvider after ${this.failureCount} failures`, + ); + } + throw error; + } + } + + async healthCheck(): Promise { + return !this.isCircuitOpen(); + } + + private checkCircuit(): void { + if (this.isCircuitOpen()) { + throw new ServiceUnavailableException( + 'LandRegistryProvider circuit breaker is open', + ); + } + } + + private isCircuitOpen(): boolean { + if (this.circuitOpenUntil && this.circuitOpenUntil > new Date()) { + return true; + } + if (this.circuitOpenUntil && this.circuitOpenUntil <= new Date()) { + this.circuitOpenUntil = null; + this.failureCount = 0; + } + return false; + } + + private async doValidate(): Promise { return { result: ValidationResult.VALID, data: {}, @@ -21,7 +76,25 @@ export class LandRegistryProvider implements IValidationProvider { }; } - async healthCheck(): Promise { - return true; + private withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new ServiceUnavailableException( + `LandRegistryProvider request timed out after ${ms}ms`, + ), + ); + }, ms); + + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); } }