chore: strengthen backend eslint profile

This commit is contained in:
2026-09-28 11:04:18 +03:00
parent ca5dbcb382
commit 9b3558f96c
179 changed files with 4485 additions and 2716 deletions
+11 -5
View File
@@ -1,8 +1,10 @@
export type Decimal = { value: bigint; scale: number };
export type Decimal = { scale: number; value: bigint };
export function decimalFromString(value: string): Decimal {
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) {
throw new Error('Invalid decimal string');
}
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return {
@@ -46,8 +48,12 @@ export function formatDecimal({ value, scale }: Decimal): string {
.toString()
.padStart(scale + 1, '0');
const whole = digits.slice(0, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, '');
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
const rawFraction = digits.slice(-scale);
let fractionEnd = rawFraction.length;
while (rawFraction.charAt(fractionEnd - 1) === '0') fractionEnd -= 1;
const fraction = rawFraction.slice(0, fractionEnd);
const fractionalPart = fraction ? '.' + fraction : '';
return `${sign}${whole}${fractionalPart}`;
}
// Round half away from zero; callers choose presentation precision explicitly.
@@ -55,7 +61,7 @@ export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale);
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
const sign = (numerator < 0n) !== (denominator < 0n) ? -1n : 1n;
const n = numerator < 0n ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale };
@@ -1,7 +1,8 @@
import { HttpStatus, applyDecorators } from '@nestjs/common';
import { applyDecorators, HttpStatus } from '@nestjs/common';
import {
ApiExtraModels,
ApiProperty,
ApiPropertyOptional,
ApiResponse,
getSchemaPath,
} from '@nestjs/swagger';
@@ -13,8 +14,8 @@ export class ProblemDetailsDto {
@ApiProperty({ example: 'UNAUTHORIZED' })
declare code: string;
@ApiProperty({
required: false,
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
@ApiPropertyOptional({
type: 'array',
items: {
type: 'object',
@@ -23,7 +24,7 @@ export class ProblemDetailsDto {
},
example: [{ pointer: '#/email', detail: 'must be a valid email address' }],
})
errors?: { pointer: string; detail: string }[];
errors?: { detail: string; pointer: string }[];
}
function problemResponse(status: number, description: string) {
@@ -38,7 +39,7 @@ function problemResponse(status: number, description: string) {
});
}
export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
export function ApiProblemResponses(): ClassDecorator & MethodDecorator {
return applyDecorators(
ApiExtraModels(ProblemDetailsDto),
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
+3 -1
View File
@@ -1,4 +1,5 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
import {
CreateBankCardDto,
@@ -6,7 +7,8 @@ import {
} from '../modules/banking/bank-cards/presentation/bank-card.dto';
describe('DTO validation', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true });
const pipe = new ValidationPipe({
forbidUnknownValues: true, transform: true, whitelist: true });
it('requires credentials and normalizes a valid email', async () => {
const metadata = { type: 'body' as const, metatype: CredentialsDto };
@@ -1,4 +1,5 @@
import { ArgumentsHost } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => {
+16 -13
View File
@@ -1,3 +1,5 @@
import type { Response } from 'express';
import {
ArgumentsHost,
Catch,
@@ -6,7 +8,6 @@ import {
HttpStatus,
ValidationError,
} from '@nestjs/common';
import type { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
@@ -14,32 +15,33 @@ export class HttpExceptionFilter implements ExceptionFilter {
const context = host.switchToHttp();
const response = context.getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
exception instanceof HttpException ?
exception.getStatus() :
HttpStatus.INTERNAL_SERVER_ERROR;
const payload =
exception instanceof HttpException ? exception.getResponse() : undefined;
const isValidation = Array.isArray(
(payload as { message?: unknown })?.message,
);
const code = isValidation
? 'VALIDATION_ERROR'
: this.codeFor(status, payload);
(response.locals ??= {}).publicErrorCode = code;
const code = isValidation ?
'VALIDATION_ERROR' :
this.codeFor(status, payload);
response.locals ??= {};
response.locals.publicErrorCode = code;
response
.status(status)
.type('application/problem+json')
.json({
status,
code,
...(isValidation
? {
...(isValidation ?
{
errors: this.validationErrors(
(payload as { message: ValidationError[] }).message,
),
}
: {}),
} :
{}),
});
}
@@ -49,6 +51,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
if (typeof code === 'string') return code;
}
return (
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
{
400: 'BAD_REQUEST',
401: 'UNAUTHORIZED',
@@ -62,7 +65,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
private validationErrors(
errors: ValidationError[],
prefix = '#',
): { pointer: string; detail: string }[] {
): { detail: string; pointer: string }[] {
return errors.flatMap((error) => {
if (!error || typeof error.property !== 'string') return [];
const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
+1
View File
@@ -1,4 +1,5 @@
import { Global, Module } from '@nestjs/common';
import { RequestContextService } from './request-context.service';
import { RequestLoggingMiddleware } from './request-logging.middleware';
import { StructuredLogger } from './structured-logger.service';
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestLogContext = {
requestId: string;
clientRequestId?: string;
userId?: string;
brokerageAccountId?: string;
clientRequestId?: string;
requestId: string;
userId?: string;
};
@Injectable()
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events';
import { RequestLoggingMiddleware } from './request-logging.middleware';
describe('RequestLoggingMiddleware', () => {
@@ -19,11 +20,11 @@ describe('RequestLoggingMiddleware', () => {
const next = jest.fn();
const request: {
method: string;
baseUrl: string;
route: { path: string };
header: jest.Mock<string>;
id?: string;
method: string;
route: { path: string };
} = {
method: 'GET',
baseUrl: '/api/v1',
@@ -1,7 +1,9 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { RequestContextService } from './request-context.service';
import { StructuredLogger } from './structured-logger.service';
@@ -31,26 +33,26 @@ export class RequestLoggingMiddleware implements NestMiddleware {
const routePath = (routeRequest as { route?: { path?: unknown } }).route
?.path;
const route =
typeof routePath === 'string'
? `${request.baseUrl}${routePath}`
: '<unmatched>';
typeof routePath === 'string' ?
`${request.baseUrl}${routePath}` :
'<unmatched>';
const context = {
method: request.method,
route,
status: response.statusCode,
durationMs: Math.round(performance.now() - startedAt),
...(response.locals.publicErrorCode
? { code: response.locals.publicErrorCode as string }
: {}),
...(response.locals.publicErrorCode ?
{ code: response.locals.publicErrorCode as string } :
{}),
};
const level =
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
? 'error'
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
? 'warn'
: 'info';
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ?
'error' :
response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ?
'warn' :
'info';
this.logger.event(level, 'http.request.completed', context);
});
+17 -15
View File
@@ -1,27 +1,28 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
type LogContext = Record<string, string | number | boolean | undefined>;
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, boolean | number | string | undefined>;
const allowedFields = new Set([
'service',
'environment',
'requestId',
'clientRequestId',
'userId',
'brokerageAccountId',
'method',
'route',
'status',
'durationMs',
'code',
'operation',
'grpcCode',
'attempts',
'brokerageAccountId',
'clientRequestId',
'code',
'count',
'durationMs',
'environment',
'found',
'grpcCode',
'method',
'operation',
'requestId',
'route',
'service',
'status',
'userId',
]);
@Injectable()
@@ -48,6 +49,7 @@ export class StructuredLogger implements OnApplicationShutdown {
message: string,
context: LogContext = {},
): void {
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }),
message,
+1
View File
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Money } from './money.types';
export class MoneyDto implements Money {