fix: harden auth and investment synchronization
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s

Resolve A01-A10 and C01-C07 from the backend audit.
Preserve auth failures, forced candle reconciliation and forecast freshness.
Validate MOEX responses and nested HTTP errors; remove unused legacy paths.

Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability.
Record the remaining macOS native SQLite abort in the audit.
This commit is contained in:
2026-09-27 14:09:05 +03:00
parent 5d3835e796
commit 4796d9d3f0
55 changed files with 1954 additions and 569 deletions
@@ -356,7 +356,10 @@ export class BrokerageOperationsService {
: {
accountId,
items: rows.map((row) => ({
...mapBrokerageOperation(row.payload as TbankOperationItem),
...mapBrokerageOperation(
row.payload as TbankOperationItem,
row.occurredAt,
),
...(accountId === 'all'
? {
account: {
@@ -616,7 +619,10 @@ export class BrokerageOperationsService {
),
)
.map((row) => ({
...mapBrokerageOperation(row.payload as TbankOperationItem),
...mapBrokerageOperation(
row.payload as TbankOperationItem,
row.occurredAt,
),
...(accountId === 'all'
? {
account: {
@@ -4,6 +4,17 @@ import {
} from './brokerage-operation.mapper';
describe('mapBrokerageOperation', () => {
it('keeps subsecond timestamps at persisted millisecond precision and the standalone epoch default', () => {
expect(
mapBrokerageOperation({
date: { seconds: '1768000000', nanos: 600999999 },
}).occurredAt,
).toBe('2026-01-09T23:06:40.600Z');
expect(mapBrokerageOperation({}).occurredAt).toBe(
'1970-01-01T00:00:00.000Z',
);
});
it.each([
['OPERATION_TYPE_COUPON', 'income'],
['OPERATION_TYPE_DIVIDEND', 'income'],
@@ -10,6 +10,8 @@ import {
OperationCategory,
} from '../presentation/brokerage-operation.dto';
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
const types: Record<string, OperationCategory> = {
OPERATION_TYPE_BUY: 'trade',
OPERATION_TYPE_BUY_CARD: 'trade',
@@ -42,10 +44,11 @@ export function operationCategory(type: string | undefined): OperationCategory {
export function mapBrokerageOperation(
operation: TbankOperationItem,
occurredAt = operationTimestamp(operation.date),
): BrokerageOperationDto {
return {
id: operation.id ?? operation.cursor ?? '',
occurredAt: timestamp(operation.date),
occurredAt: occurredAt.toISOString(),
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
category: operationCategory(operation.type),
description: operation.description?.trim() || operation.name?.trim() || '',
@@ -82,10 +85,11 @@ function money(value: TbankMoney | undefined) {
: null;
}
function timestamp(
export function operationTimestamp(
value: { seconds?: string | number; nanos?: number } | undefined,
) {
return new Date(
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND,
).toISOString();
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND +
Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND),
);
}
@@ -5,7 +5,6 @@ import {
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { PrismaService } from '../../../../prisma/prisma.service';
import type { Prisma } from '../../../../../generated/prisma/client';
import type {
@@ -17,7 +16,10 @@ import {
dateOnlyBounds,
moscowDate,
} from '../../investment-calendar';
import { operationCategory } from './brokerage-operation.mapper';
import {
operationCategory,
operationTimestamp,
} from './brokerage-operation.mapper';
import {
type OperationInterval,
operationIntervals,
@@ -45,7 +47,6 @@ type PageProgress = {
pages: number;
};
const progressItem = '';
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
@Injectable()
export class BrokerageOperationsRepository {
@@ -650,13 +651,11 @@ export class BrokerageOperationsRepository {
for (const operation of response.items ?? []) {
if (!operation.id?.trim())
throw new Error('Missing broker operation identity');
const occurredAt = new Date(
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
Math.floor(
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
),
);
if (!Number.isFinite(occurredAt.valueOf()))
const occurredAt = operationTimestamp(operation.date);
if (
operation.date?.seconds === undefined ||
!Number.isFinite(occurredAt.valueOf())
)
throw new Error('Invalid operation timestamp');
if (
occurredAt < interval.from ||
@@ -793,12 +792,7 @@ export class BrokerageOperationsRepository {
if (previous && isDeepStrictEqual(previous.payload, operation))
continue;
const data = {
occurredAt: new Date(
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
Math.floor(
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
),
),
occurredAt: operationTimestamp(operation.date),
instrumentUid: operation.instrumentUid ?? null,
category: operationCategory(operation.type),
payload: row.payload as Prisma.InputJsonObject,
@@ -2,10 +2,7 @@
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
export { BrokerageOperationsService } from './application/brokerage-operations.service';
export {
operationIntervals,
type OperationInterval,
} from './domain/operations-sync-plan';
export type { OperationInterval } from './domain/operations-sync-plan';
export { operationCategories } from './presentation/brokerage-operation.dto';
export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';