chore: strengthen backend eslint profile
This commit is contained in:
+11
-5
@@ -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'),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import type { Money } from './money.types';
|
||||
|
||||
export class MoneyDto implements Money {
|
||||
|
||||
Reference in New Issue
Block a user