Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 7 additions & 2 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,15 @@ jobs:
with:
tool: cargo-llvm-cov

- name: Generate lcov + coverage.json
- name: Generate lcov
run: |
cargo llvm-cov --workspace --lcov --output-path coverage.lcov \
--json-path coverage.json --ignore-filename-regex 'tests/'
--ignore-filename-regex 'tests/'

- name: Generate coverage.json
run: |
cargo llvm-cov --workspace --json --output-path coverage.json \
--ignore-filename-regex 'tests/'

- name: Print per-module summary
run: |
Expand Down
20 changes: 19 additions & 1 deletion backend/src/access-logs/access-logs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ export class AccessLogsService {

constructor(private readonly accessLogRepository: Repository<AccessLog>) {}


async logDocumentAccess(
documentId: string,
action: string,
userId?: string,
ipAddress?: string,
userAgent?: string,
isDenied = false,
): Promise<void> {

async create(dto: CreateAccessLogDto): Promise<AccessLog> {
const log = this.accessLogRepository.create({
userId: dto.userId ?? null,
Expand All @@ -30,14 +40,22 @@ export class AccessLogsService {
}

async logDocumentAccess(documentId: string, action: string, userId?: string, ipAddress?: string, userAgent?: string, isDenied = false): Promise<void> {

try {
this.logger.log(`Document Access Log: docId=${documentId}, action=${action}, user=${userId || 'anonymous'}, denied=${isDenied}`);
this.logger.log(
`Document Access Log: docId=${documentId}, action=${action}, user=${userId || 'anonymous'}, denied=${isDenied}`,
);
// Asynchronously record log without blocking the main thread
} catch (err) {
this.logger.error('Failed to log document access', err);
}
}

async create(dto: CreateAccessLogDto): Promise<AccessLog> {
const log = this.accessLogRepository.create(dto);
return this.accessLogRepository.save(log);
}

async findAll(filterDto: FilterAccessLogsDto): Promise<PaginatedAccessLogs> {
const {
page = 1,
Expand Down
16 changes: 13 additions & 3 deletions backend/src/auth/brute-force.guard.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';

@Injectable()
export class BruteForceGuard implements CanActivate {
private failedAttempts = new Map<string, { count: number; lockUntil?: Date }>();
private failedAttempts = new Map<
string,
{ count: number; lockUntil?: Date }
>();
private readonly MAX_ATTEMPTS = 5;
private readonly LOCK_TIME_MS = 15 * 60 * 1000; // 15 mins

Expand All @@ -14,7 +22,9 @@ export class BruteForceGuard implements CanActivate {

const record = this.failedAttempts.get(email);
if (record && record.lockUntil && record.lockUntil > new Date()) {
throw new UnauthorizedException('Account locked due to multiple failed login attempts. Please try again later.');
throw new UnauthorizedException(
'Account locked due to multiple failed login attempts. Please try again later.',
);
}

return true;
Expand Down
15 changes: 15 additions & 0 deletions backend/src/common/correlation/correlation-id.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

import { randomUUID } from 'crypto';

export const CORRELATION_ID_HEADER = 'x-correlation-id';

@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const correlationId =
(req.headers[CORRELATION_ID_HEADER] as string) || randomUUID();
req.headers[CORRELATION_ID_HEADER] = correlationId;
res.setHeader(CORRELATION_ID_HEADER, correlationId);
next();


import {
correlationIdStorage,
generateCorrelationId,
Expand Down Expand Up @@ -31,5 +45,6 @@ export class CorrelationIdMiddleware implements NestMiddleware {
correlationIdStorage.run(requestId, () => {
next();
});

}
}
46 changes: 46 additions & 0 deletions backend/src/common/cors.config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,48 @@

import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';

type OriginCallback = (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => void;

interface CorsConfig {
origin: boolean | string | string[] | RegExp | OriginCallback;
methods?: string[];
allowedHeaders?: string[];
credentials?: boolean;
}

describe('CORS configuration', () => {
const createConfig = (overrides: Partial<CorsConfig> = {}): CorsOptions => ({
origin: true,
credentials: true,
...overrides,
});

it('should allow requests from any origin in development', () => {
const config = createConfig({ origin: true });
expect(config.origin).toBe(true);
});

it('should restrict origin to specific domains in production', () => {
const allowedOrigins = ['https://smalda.app', 'https://www.smalda.app'];
const config = createConfig({ origin: allowedOrigins });
expect(config.origin).toEqual(allowedOrigins);
});

it('should support origin as a function', () => {
const originFn: OriginCallback = (origin, callback) => {
callback(null, true);
};
const config = createConfig({ origin: originFn });
expect(typeof config.origin).toBe('function');
});

it('should enable credentials by default', () => {
const config = createConfig();
expect(config.credentials).toBe(true);

import { ConfigService } from '@nestjs/config';
import { buildCorsOptions, getFrontendUrl } from './cors.config';

Expand Down Expand Up @@ -112,5 +157,6 @@ describe('getFrontendUrl', () => {
it('returns fallback when nothing is configured', () => {
const config = createConfigService({});
expect(getFrontendUrl(config)).toBe('http://localhost:3001');

});
});
15 changes: 12 additions & 3 deletions backend/src/common/crypto.util.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import * as crypto from 'crypto';

const ALGORITHM = 'aes-256-cbc';
const SECRET_KEY = process.env.ENCRYPTION_SECRET || 'smalda-super-secret-key-32chars!';
const SECRET_KEY =
process.env.ENCRYPTION_SECRET || 'smalda-super-secret-key-32chars!';
const IV_LENGTH = 16;

export function encryptBuffer(buffer: Buffer): Buffer {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(SECRET_KEY.padEnd(32).slice(0, 32)), iv);
const cipher = crypto.createCipheriv(
ALGORITHM,
Buffer.from(SECRET_KEY.padEnd(32).slice(0, 32)),
iv,
);
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
return Buffer.concat([iv, encrypted]);
}

export function decryptBuffer(encryptedBuffer: Buffer): Buffer {
const iv = encryptedBuffer.subarray(0, IV_LENGTH);
const encryptedText = encryptedBuffer.subarray(IV_LENGTH);
const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(SECRET_KEY.padEnd(32).slice(0, 32)), iv);
const decipher = crypto.createDecipheriv(
ALGORITHM,
Buffer.from(SECRET_KEY.padEnd(32).slice(0, 32)),
iv,
);
return Buffer.concat([decipher.update(encryptedText), decipher.final()]);
}
7 changes: 7 additions & 0 deletions backend/src/common/filters/http-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,16 @@ export class HttpExceptionFilter implements ExceptionFilter {

const { message, error } = this.normalizeResponse(errorResponse, exception);


const requestId =
(request as any).requestId || request.headers['x-request-id'] || 'req-id';
const errorCode =
(errorResponse as any)?.errorCode || error || `ERR_${status}`;

const requestId = request.requestId || 'unknown';
const errorCode = (errorResponse as any)?.errorCode || error || `ERR_${status}`;


const payload = {
statusCode: status,
errorCode,
Expand Down
73 changes: 42 additions & 31 deletions backend/src/config/config.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@ import * as Joi from 'joi';
/**
* Custom validation to ensure placeholder values are not used in production
*/
const notPlaceholder = Joi.string().invalid(
'your-super-secret-jwt-key-change-this-in-production',
'your-super-secret-refresh-key-change-this-in-production',
'your-google-client-id',
'your-google-client-secret',
'your-github-client-id',
'your-github-client-secret',
'your-email@gmail.com',
'your-app-password',
'your-stellar-secret-key',
).messages({
'any.invalid': 'Placeholder value detected - please set a real production value for this environment variable',
});
const notPlaceholder = Joi.string()
.invalid(
'your-super-secret-jwt-key-change-this-in-production',
'your-super-secret-refresh-key-change-this-in-production',
'your-google-client-id',
'your-google-client-secret',
'your-github-client-id',
'your-github-client-secret',
'your-email@gmail.com',
'your-app-password',
'your-stellar-secret-key',
)
.messages({
'any.invalid':
'Placeholder value detected - please set a real production value for this environment variable',
});

/**
* Joi schema that validates all required environment variables at startup.
Expand Down Expand Up @@ -47,15 +50,21 @@ export const ConfigValidationSchema = Joi.object({
// Defaults to testnet in development; must be explicitly set to "public"
// in production — any other value in production is rejected.
STELLAR_NETWORK: Joi.string()
.valid('Test SDF Network ; September 2015', 'Public Global Stellar Network ; September 2015')
.valid(
'Test SDF Network ; September 2015',
'Public Global Stellar Network ; September 2015',
)
.when('NODE_ENV', {
is: 'production',
then: Joi.string().valid('Public Global Stellar Network ; September 2015').required().messages({
'any.only':
'STELLAR_NETWORK must be explicitly set to "Public Global Stellar Network ; September 2015" in production.',
'any.required':
'STELLAR_NETWORK is required in production and must be "Public Global Stellar Network ; September 2015".',
}),
then: Joi.string()
.valid('Public Global Stellar Network ; September 2015')
.required()
.messages({
'any.only':
'STELLAR_NETWORK must be explicitly set to "Public Global Stellar Network ; September 2015" in production.',
'any.required':
'STELLAR_NETWORK is required in production and must be "Public Global Stellar Network ; September 2015".',
}),
otherwise: Joi.string().default('Test SDF Network ; September 2015'),
}),

Expand All @@ -67,14 +76,16 @@ export const ConfigValidationSchema = Joi.object({
STELLAR_HORIZON_URL: Joi.string().uri().required(),

// ── Auth ───────────────────────────────────────────────────────────────────
JWT_SECRET: Joi.string().when('NODE_ENV', {
is: 'production',
then: notPlaceholder.min(32).required(),
otherwise: Joi.string().min(32).required(),
}).messages({
'string.min': 'JWT_SECRET must be at least 32 characters.',
'any.required': 'JWT_SECRET is required.',
}),
JWT_SECRET: Joi.string()
.when('NODE_ENV', {
is: 'production',
then: notPlaceholder.min(32).required(),
otherwise: Joi.string().min(32).required(),
})
.messages({
'string.min': 'JWT_SECRET must be at least 32 characters.',
'any.required': 'JWT_SECRET is required.',
}),
JWT_EXPIRATION: Joi.string().default('15m'),
JWT_REFRESH_SECRET: Joi.string().when('NODE_ENV', {
is: 'production',
Expand All @@ -95,7 +106,7 @@ export const ConfigValidationSchema = Joi.object({
otherwise: Joi.string().required(),
}),
GOOGLE_CALLBACK_URL: Joi.string().uri().required(),

GITHUB_CLIENT_ID: Joi.string().when('NODE_ENV', {
is: 'production',
then: notPlaceholder.required(),
Expand All @@ -122,7 +133,7 @@ export const ConfigValidationSchema = Joi.object({
otherwise: Joi.string().required(),
}),
MAIL_FROM: Joi.string().email().required(),

// Keep SMTP config in sync for compatibility
SMTP_HOST: Joi.string().default(Joi.ref('MAIL_HOST')),
SMTP_PORT: Joi.number().default(Joi.ref('MAIL_PORT')),
Expand All @@ -146,4 +157,4 @@ export const ConfigValidationSchema = Joi.object({
then: Joi.string().default('warn'),
otherwise: Joi.string().default('debug'),
}),
});
});
13 changes: 9 additions & 4 deletions backend/src/danielships.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import * as fs from 'fs';
import * as path from 'path';

describe('danielships Backend Features (BE-148, BE-147, BE-146, BE-145)', () => {
it('API Versioning and OpenAPI export script exists', () => {
const fs = require('fs');
const path = require('path');
expect(fs.existsSync(path.resolve(__dirname, '../scripts/export-openapi.ts'))).toBe(true);
expect(fs.existsSync(path.resolve(__dirname, '../docs/ARCHITECTURE.md'))).toBe(true);
expect(
fs.existsSync(path.resolve(__dirname, '../scripts/export-openapi.ts')),
).toBe(true);
expect(
fs.existsSync(path.resolve(__dirname, '../docs/ARCHITECTURE.md')),
).toBe(true);
});
});
Loading
Loading