Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions backend/src/common/crypto.util.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
73 changes: 73 additions & 0 deletions backend/src/common/filters/http-exception.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { HttpExceptionFilter } from './http-exception.filter';
import { HttpException, HttpStatus, BadRequestException } from '@nestjs/common';
import { ArgumentsHost } from '@nestjs/common';

describe('HttpExceptionFilter', () => {
let filter: HttpExceptionFilter;
let mockHost: ArgumentsHost;
let mockResponse: any;

beforeEach(() => {
filter = new HttpExceptionFilter();
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
mockHost = {
switchToHttp: () => ({
getResponse: () => mockResponse,
getRequest: () => ({ url: '/test', method: 'GET' }),
}),
} as any;
});

it('should be defined', () => {
expect(filter).toBeDefined();
});

it('should handle HttpException', () => {
const exception = new HttpException(
'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 handle generic Error', () => {
const exception = new Error('Generic Error');
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(
HttpStatus.INTERNAL_SERVER_ERROR,
);
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 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 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');
});
});
83 changes: 83 additions & 0 deletions backend/src/common/middleware/logger.middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Request>;
let mockResponse: Partial<Response>;
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>(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');
});
});
35 changes: 35 additions & 0 deletions backend/src/metrics/metrics.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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+$/,
);
});
});
});
31 changes: 19 additions & 12 deletions backend/src/metrics/metrics.controller.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
this.documentsSubmitted.inc(1);
this.verificationsTotal.inc(1);
return register.metrics();
}
}
8 changes: 0 additions & 8 deletions backend/src/mxllv.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
10 changes: 0 additions & 10 deletions backend/src/prismn.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
17 changes: 16 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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'}
{
"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"
}