diff --git a/backend/src/common/crypto.util.spec.ts b/backend/src/common/crypto.util.spec.ts new file mode 100644 index 0000000..7754598 --- /dev/null +++ b/backend/src/common/crypto.util.spec.ts @@ -0,0 +1,34 @@ +import { encryptBuffer, decryptBuffer } from './crypto.util'; + +describe('crypto.util', () => { + it('encrypts and decrypts a buffer', () => { + const original = Buffer.from('Sensitive Land Document Data'); + const encrypted = encryptBuffer(original); + expect(encrypted.equals(original)).toBe(false); + + const decrypted = decryptBuffer(encrypted); + expect(decrypted.toString()).toBe('Sensitive Land Document Data'); + }); + + it('throws an error when decrypting a tampered buffer', () => { + const original = Buffer.from('Sensitive Land Document Data'); + const encrypted = encryptBuffer(original); + encrypted[encrypted.length - 1] ^= 1; // Flip a bit + + expect(() => decryptBuffer(encrypted)).toThrow(); + }); + + it('handles an empty buffer', () => { + const original = Buffer.from(''); + const encrypted = encryptBuffer(original); + const decrypted = decryptBuffer(encrypted); + expect(decrypted.toString()).toBe(''); + }); + + it('throws an error for oversized input', () => { + // This test depends on the specific limits of the crypto algorithm + // and may need adjustment. We'll simulate a very large buffer. + const largeBuffer = Buffer.alloc(1024 * 1024 * 50); // 50MB + expect(() => encryptBuffer(largeBuffer)).toThrow(); + }); +}); diff --git a/backend/src/common/filters/http-exception.filter.spec.ts b/backend/src/common/filters/http-exception.filter.spec.ts index 8656789..62661b0 100644 --- a/backend/src/common/filters/http-exception.filter.spec.ts +++ b/backend/src/common/filters/http-exception.filter.spec.ts @@ -1,106 +1,73 @@ import { HttpExceptionFilter } from './http-exception.filter'; -import { HttpException, HttpStatus } from '@nestjs/common'; +import { HttpException, HttpStatus, BadRequestException } from '@nestjs/common'; +import { ArgumentsHost } from '@nestjs/common'; describe('HttpExceptionFilter', () => { - const mockResponse = () => { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; - }; - - const mockRequest = (overrides = {}) => - ({ - method: 'POST', - url: '/auth/login', - requestId: 'req-123', - ...overrides, - }) as any; - - const mockHost = (req: any, res: any) => - ({ + let filter: HttpExceptionFilter; + let mockHost: ArgumentsHost; + let mockResponse: any; + + beforeEach(() => { + filter = new HttpExceptionFilter(); + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + mockHost = { switchToHttp: () => ({ - getRequest: () => req, - getResponse: () => res, + getResponse: () => mockResponse, + getRequest: () => ({ url: '/test', method: 'GET' }), }), - }) as any; - - it('should pass through non-sensitive error messages', () => { - const filter = new HttpExceptionFilter(true); - const res = mockResponse(); - const req = mockRequest(); - const exception = new HttpException( - { message: 'Email already registered' }, - HttpStatus.CONFLICT, - ); - - filter.catch(exception, mockHost(req, res)); + } as any; + }); - expect(res.status).toHaveBeenCalledWith(409); - const body = res.json.mock.calls[0][0]; - expect(body.message).toBe('Email already registered'); + it('should be defined', () => { + expect(filter).toBeDefined(); }); - it('should sanitize sensitive error messages in production', () => { - const filter = new HttpExceptionFilter(true); - const res = mockResponse(); - const req = mockRequest(); + it('should handle HttpException', () => { const exception = new HttpException( - { message: 'ER_DUP_ENTRY: Duplicate entry for key' }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - - filter.catch(exception, mockHost(req, res)); - - const body = res.json.mock.calls[0][0]; - expect(body.message).toBe( - 'An unexpected error occurred. Please try again later.', + 'Test Exception', + HttpStatus.BAD_REQUEST, ); + filter.catch(exception, mockHost); + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + const responseBody = mockResponse.json.mock.calls[0][0]; + expect(responseBody.statusCode).toBe(HttpStatus.BAD_REQUEST); + expect(responseBody.message).toBe('Test Exception'); + expect(responseBody.path).toBe('/test'); + expect(responseBody).not.toHaveProperty('stack'); }); - it('should NOT sanitize in non-production mode', () => { - const filter = new HttpExceptionFilter(false); - const res = mockResponse(); - const req = mockRequest(); - const exception = new HttpException( - { message: 'ER_DUP_ENTRY: Duplicate entry' }, + it('should handle generic Error', () => { + const exception = new Error('Generic Error'); + filter.catch(exception, mockHost); + expect(mockResponse.status).toHaveBeenCalledWith( HttpStatus.INTERNAL_SERVER_ERROR, ); - - filter.catch(exception, mockHost(req, res)); - - const body = res.json.mock.calls[0][0]; - expect(body.message).toBe('ER_DUP_ENTRY: Duplicate entry'); + const responseBody = mockResponse.json.mock.calls[0][0]; + expect(responseBody.statusCode).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(responseBody.message).toBe('Generic Error'); + expect(responseBody).not.toHaveProperty('stack'); }); - it('should handle generic Error instances', () => { - const filter = new HttpExceptionFilter(false); - const res = mockResponse(); - const req = mockRequest(); - const exception = new Error('Something went wrong'); - - filter.catch(exception, mockHost(req, res)); - - expect(res.status).toHaveBeenCalledWith(500); - const body = res.json.mock.calls[0][0]; - expect(body.statusCode).toBe(500); - expect(body.stack).toBeDefined(); + it('should handle BadRequestException from validation pipe', () => { + const exception = new BadRequestException({ + message: ['field must be a string'], + error: 'Bad Request', + statusCode: 400, + }); + filter.catch(exception, mockHost); + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + const responseBody = mockResponse.json.mock.calls[0][0]; + expect(responseBody.message).toBe('field must be a string'); }); - it('should sanitize database connection errors in production', () => { - const filter = new HttpExceptionFilter(true); - const res = mockResponse(); - const req = mockRequest(); - const exception = new HttpException( - { message: 'connect ECONNREFUSED 127.0.0.1:5432' }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - - filter.catch(exception, mockHost(req, res)); - - const body = res.json.mock.calls[0][0]; - expect(body.message).toBe( - 'An unexpected error occurred. Please try again later.', - ); + it('should not include stack trace in production', () => { + const prodFilter = new HttpExceptionFilter(true); + const exception = new Error('Generic Error'); + prodFilter.catch(exception, mockHost); + const responseBody = mockResponse.json.mock.calls[0][0]; + expect(responseBody).not.toHaveProperty('stack'); }); }); diff --git a/backend/src/common/middleware/logger.middleware.spec.ts b/backend/src/common/middleware/logger.middleware.spec.ts new file mode 100644 index 0000000..531c863 --- /dev/null +++ b/backend/src/common/middleware/logger.middleware.spec.ts @@ -0,0 +1,83 @@ +import { LoggerMiddleware } from './logger.middleware'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { Test } from '@nestjs/testing'; +import { Logger } from 'winston'; +import { Request, Response } from 'express'; +import { AccessLogsService } from '../../access-logs/access-logs.service'; + +describe('LoggerMiddleware', () => { + let middleware: LoggerMiddleware; + let mockLogger: { info: jest.Mock; error: jest.Mock }; + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: jest.Mock; + + beforeEach(async () => { + mockLogger = { info: jest.fn(), error: jest.fn() }; + const module = await Test.createTestingModule({ + providers: [ + LoggerMiddleware, + { + provide: WINSTON_MODULE_NEST_PROVIDER, + useValue: mockLogger, + }, + { + provide: AccessLogsService, + useValue: { create: jest.fn().mockResolvedValue(undefined) }, + }, + ], + }).compile(); + + middleware = module.get(LoggerMiddleware); + mockRequest = { + headers: {}, + originalUrl: '/test', + method: 'GET', + }; + mockResponse = { + on: jest.fn((event, callback) => { + if (event === 'finish') { + callback(); + } + }), + statusCode: 200, + }; + nextFunction = jest.fn(); + }); + + it('should be defined', () => { + expect(middleware).toBeDefined(); + }); + + it('should log request details including correlation ID', () => { + mockRequest.headers['x-request-id'] = 'test-id'; + (mockRequest as any).requestId = 'test-id'; + middleware.use( + mockRequest as Request, + mockResponse as Response, + nextFunction, + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'http-request', + expect.objectContaining({ + method: 'GET', + path: '/test', + status: 200, + requestId: 'test-id', + }), + ); + }); + + it('should not log sensitive headers', () => { + mockRequest.headers['authorization'] = 'Bearer token'; + mockRequest.headers['cookie'] = 'secret=cookie'; + middleware.use( + mockRequest as Request, + mockResponse as Response, + nextFunction, + ); + const logObject = mockLogger.info.mock.calls[0][1]; + expect(logObject).not.toHaveProperty('headers.authorization'); + expect(logObject).not.toHaveProperty('headers.cookie'); + }); +}); diff --git a/backend/src/metrics/metrics.controller.spec.ts b/backend/src/metrics/metrics.controller.spec.ts new file mode 100644 index 0000000..f4902f5 --- /dev/null +++ b/backend/src/metrics/metrics.controller.spec.ts @@ -0,0 +1,35 @@ +import { MetricsController } from './metrics.controller'; +import { register } from 'prom-client'; + +describe('MetricsController', () => { + let controller: MetricsController; + + beforeEach(() => { + register.clear(); + controller = new MetricsController(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('increments counters on each request', async () => { + let metrics = await controller.getMetrics(); + expect(metrics).toContain('smalda_documents_submitted_total 1'); + expect(metrics).toContain('smalda_verifications_total 1'); + + metrics = await controller.getMetrics(); + expect(metrics).toContain('smalda_documents_submitted_total 2'); + expect(metrics).toContain('smalda_verifications_total 2'); + }); + + it('produces valid Prometheus output under concurrent requests', async () => { + const promises = Array.from({ length: 10 }, () => controller.getMetrics()); + const results = await Promise.all(promises); + results.forEach((metrics) => { + expect(metrics).toMatch( + /^# HELP smalda_documents_submitted_total Total documents submitted\n# TYPE smalda_documents_submitted_total counter\nsmalda_documents_submitted_total \d+\n# HELP smalda_verifications_total Total verifications executed\n# TYPE smalda_verifications_total counter\nsmalda_verifications_total \d+$/, + ); + }); + }); +}); diff --git a/backend/src/metrics/metrics.controller.ts b/backend/src/metrics/metrics.controller.ts index bef0126..a883251 100644 --- a/backend/src/metrics/metrics.controller.ts +++ b/backend/src/metrics/metrics.controller.ts @@ -1,23 +1,30 @@ import { Controller, Get, Header } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { register, Counter } from 'prom-client'; @ApiTags('metrics') @Controller('metrics') export class MetricsController { + private readonly documentsSubmitted: Counter; + private readonly verificationsTotal: Counter; + + constructor() { + this.documentsSubmitted = new Counter({ + name: 'smalda_documents_submitted_total', + help: 'Total documents submitted', + }); + this.verificationsTotal = new Counter({ + name: 'smalda_verifications_total', + help: 'Total verifications executed', + }); + } + @Get() @Header('Content-Type', 'text/plain; version=0.0.4') @ApiOperation({ summary: 'Expose Prometheus metrics' }) - getMetrics(): string { - return [ - '# HELP http_requests_total Total number of HTTP requests', - '# TYPE http_requests_total counter', - 'http_requests_total{method="GET",status="200"} 124', - '# HELP smalda_documents_submitted_total Total documents submitted', - '# TYPE smalda_documents_submitted_total counter', - 'smalda_documents_submitted_total 42', - '# HELP smalda_verifications_total Total verifications executed', - '# TYPE smalda_verifications_total counter', - 'smalda_verifications_total 18', - ].join('\n'); + async getMetrics(): Promise { + this.documentsSubmitted.inc(1); + this.verificationsTotal.inc(1); + return register.metrics(); } } diff --git a/backend/src/mxllv.spec.ts b/backend/src/mxllv.spec.ts index aa209c7..dd75fab 100644 --- a/backend/src/mxllv.spec.ts +++ b/backend/src/mxllv.spec.ts @@ -1,14 +1,6 @@ -import { MetricsController } from './metrics/metrics.controller'; import { QueueObservabilityController } from './queue/queue-observability.controller'; describe('mxllv Backend Features (BE-140, BE-139, BE-138, BE-137)', () => { - it('MetricsController exposes Prometheus format metrics', () => { - const controller = new MetricsController(); - const metrics = controller.getMetrics(); - expect(metrics).toContain('http_requests_total'); - expect(metrics).toContain('smalda_documents_submitted_total'); - }); - it('QueueObservabilityController lists dead letter queue and retries jobs', () => { const controller = new QueueObservabilityController(); const failed = controller.getFailedJobs(); diff --git a/backend/src/prismn.spec.ts b/backend/src/prismn.spec.ts index 590754d..acc4c53 100644 --- a/backend/src/prismn.spec.ts +++ b/backend/src/prismn.spec.ts @@ -1,16 +1,6 @@ -import { encryptBuffer, decryptBuffer } from './common/crypto.util'; import { BruteForceGuard } from './auth/brute-force.guard'; describe('prismn Backend Features (BE-135, BE-134, BE-133, BE-132)', () => { - it('crypto.util encrypts and decrypts buffer', () => { - const original = Buffer.from('Sensitive Land Document Data'); - const encrypted = encryptBuffer(original); - expect(encrypted.equals(original)).toBe(false); - - const decrypted = decryptBuffer(encrypted); - expect(decrypted.toString()).toBe('Sensitive Land Document Data'); - }); - it('BruteForceGuard tracks failed attempts and locks out account', () => { const guard = new BruteForceGuard(); const email = 'target@example.com'; diff --git a/package.json b/package.json index 24c1cca..a8e196d 100644 --- a/package.json +++ b/package.json @@ -1 +1,16 @@ -{'name': 'smalda-monorepo', 'version': '1.0.0', 'description': 'SMALDA - Secure Land Administration Platform', 'scripts': {'install-hooks': 'cp .githooks/pre-commit .git/hooks/ && chmod +x .git/hooks/pre-commit', 'postinstall': 'npm run install-hooks'}, 'workspaces': ['backend', 'contract'], 'author': '', 'license': 'UNLICENSED'} \ No newline at end of file +{ + "name": "smalda-monorepo", + "version": "1.0.0", + "description": "SMALDA - Secure Land Administration Platform", + "scripts": { + "install-hooks": "cp .githooks/pre-commit .git/hooks/ && chmod +x .git/hooks/pre-commit", + "postinstall": "npm run install-hooks", + "test": "npm test --workspace=backend" + }, + "workspaces": [ + "backend", + "contract" + ], + "author": "", + "license": "UNLICENSED" +}