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
@@ -0,0 +1,113 @@
jest.mock('../infrastructure/candle.repository', () => ({
CandleRepository: class {},
}));
jest.mock('./catalog-enrichment.service', () => ({
CatalogEnrichmentService: class {},
}));
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
TbankAccessService: class {},
TbankClient: class {},
}));
import { CandleService } from './candle.service';
describe('CandleService forced demand', () => {
it.each([false, true])(
'reconciles the full range through the latest forced timestamp while force=%s is loading',
async (initialForce) => {
let entered = () => {};
let release = () => {};
let complete = () => {};
const started = new Promise<void>((resolve) => (entered = resolve));
const blocked = new Promise<void>((resolve) => (release = resolve));
const finished = new Promise<void>((resolve) => (complete = resolve));
let completed = 0;
const work = { id: 'work', attemptId: 'attempt' };
const repository = {
reserve: jest
.fn()
.mockResolvedValueOnce({ work, launch: true })
.mockResolvedValueOnce({
work: { id: 'other', attemptId: 'other-attempt' },
launch: true,
})
.mockResolvedValue({ work, launch: false }),
read: jest.fn().mockResolvedValue({
coverage: [
{
from: new Date('2026-01-01Z'),
to: new Date('2026-02-01Z'),
isComplete: true,
fetchedAt: new Date('2026-02-01Z'),
},
],
}),
stage: jest.fn().mockResolvedValue(true),
publish: jest.fn((_attempt, _intent, done = true) => {
if (done && ++completed === 2) complete();
return Promise.resolve(true);
}),
fail: jest.fn(),
};
const sources = {
ensureDetail: jest.fn().mockResolvedValue({
first_1dayCandleDate: {
seconds: String(Date.parse('2026-01-01Z') / 1000),
},
}),
};
const upstream = { getCandles: jest.fn().mockResolvedValue([]) };
upstream.getCandles.mockImplementationOnce(async () => {
entered();
await blocked;
return [];
});
const service = new CandleService(
repository as never,
sources as never,
{ tokenForAttempt: jest.fn().mockResolvedValue('token') } as never,
upstream as never,
{ event: jest.fn() } as never,
);
const input = {
uid: 'uid',
instrumentType: 'share' as const,
period: 'all' as const,
};
await service.request(
'owner',
'revision',
input,
new Date('2026-02-02T12:00:00Z'),
initialForce,
2,
);
await started;
const latest = new Date('2026-02-02T13:00:00Z');
await service.request(
'owner',
'revision',
{ ...input, uid: 'other' },
latest,
false,
1,
);
expect(
await service.request('owner', 'revision', input, latest, true, 0),
).toBe('work');
release();
await finished;
expect(upstream.getCandles).toHaveBeenCalledWith(
'token',
'uid',
new Date('2026-01-01Z'),
latest,
);
expect(repository.fail).not.toHaveBeenCalled();
expect(
upstream.getCandles.mock.calls.map((call: unknown[]) => call[1]),
).toEqual(['uid', 'uid', 'other']);
},
);
});
@@ -13,8 +13,8 @@ import {
TbankAccessService,
TbankClient,
type TbankShare,
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { CandleRepository } from '../infrastructure/candle.repository';
@@ -39,8 +39,12 @@ type Producer = {
force: boolean;
priority: number;
observers: TbankSyncAttempt[];
ranges?: CandleRange[];
nextWindow?: number;
pass?: {
now: Date;
force: boolean;
ranges: CandleRange[];
nextWindow: number;
};
};
const months = { month: 1, halfYear: 6, year: 12 };
const tailFreshForMs = 60_000;
@@ -323,9 +327,14 @@ export class CandleService {
id: reserved.work.id,
attemptId: reserved.work.attemptId,
};
const producer = this.producers.get(scope(input));
if (producer) {
producer.force ||= force;
producer.priority = Math.min(producer.priority, priority);
if (force && now > producer.now) producer.now = now;
}
if (reserved.launch) {
this.active.add(attempt.attemptId);
const producer = this.producers.get(scope(input));
if (producer) producer.observers.push(attempt);
else {
const next = { input, attempt, now, force, priority, observers: [] };
@@ -346,7 +355,7 @@ export class CandleService {
}
private async load(producer: Producer) {
const { input, attempt, now } = producer;
const { input, attempt } = producer;
const instrument = await this.sources.ensureDetail(
attempt.userId,
attempt.revision,
@@ -354,20 +363,24 @@ export class CandleService {
input.uid,
);
if (!instrument) throw new NotFoundException();
const range = rangeFor(input, instrument, now);
if (!producer.ranges) {
let pass = producer.pass;
if (!pass || (producer.force && (!pass.force || producer.now > pass.now))) {
const { now, force } = producer;
const range = rangeFor(input, instrument, now);
const saved = await this.repository.read(input.uid, range);
producer.ranges = (
producer.force ? [range] : candleGaps(range, saved.coverage, now)
)
.flatMap(candleWindows)
.reverse();
producer.nextWindow = 0;
pass = producer.pass = {
now,
force,
ranges: (force ? [range] : candleGaps(range, saved.coverage, now))
.flatMap(candleWindows)
.reverse(),
nextWindow: 0,
};
}
const ranges = producer.ranges;
const index = producer.nextWindow ?? 0;
const { ranges, now, force } = pass;
const index = pass.nextWindow;
let window = ranges[index];
if (window && !producer.force) {
if (window && !force) {
const latest = await this.repository.read(input.uid, window);
const gaps = candleGaps(window, latest.coverage, now)
.flatMap(candleWindows)
@@ -398,7 +411,7 @@ export class CandleService {
if (!(await this.repository.stage(attempt, window, rows, now)))
return false;
if (
!producer.force &&
!force &&
!(await this.repository.publish(
attempt,
{ uid: input.uid, ranges: [window] },
@@ -406,13 +419,10 @@ export class CandleService {
))
)
return false;
producer.nextWindow = index + 1;
if (producer.nextWindow < ranges.length) return null;
pass.nextWindow = index + 1;
if (pass.nextWindow < ranges.length) return null;
}
return this.repository.publish(attempt, {
uid: input.uid,
ranges: producer.force ? ranges : [],
});
return true;
}
private async drain() {
@@ -443,20 +453,43 @@ export class CandleService {
code = response.code;
}
}
if (!success) await this.repository.fail(producer.attempt, code);
this.active.delete(producer.attempt.attemptId);
for (const observer of producer.observers) {
if (
!success ||
!(await this.repository.completeObserver(
observer,
producer.attempt,
))
)
await this.repository.fail(observer, code);
this.active.delete(observer.attemptId);
// Seal publication with reservations so a late forced join cannot be
// acknowledged after the final demand check but before work completion.
const finished = this.reservations.then(async () => {
const pass = producer.pass;
if (success && pass) {
if (producer.force && (!pass.force || producer.now > pass.now))
return false;
try {
success = await this.repository.publish(producer.attempt, {
uid: producer.input.uid,
ranges: pass.force ? pass.ranges : [],
});
} catch {
success = false;
}
}
if (!success) await this.repository.fail(producer.attempt, code);
this.active.delete(producer.attempt.attemptId);
for (const observer of producer.observers) {
if (
!success ||
!(await this.repository.completeObserver(
observer,
producer.attempt,
))
)
await this.repository.fail(observer, code);
this.active.delete(observer.attemptId);
}
this.producers.delete(scope(producer.input));
return true;
});
this.reservations = finished.catch(() => undefined);
if (!(await finished)) {
this.queue.push(producer);
continue;
}
this.producers.delete(scope(producer.input));
if (success && !producer.force && producer.input.period !== 'all')
await this.request(
producer.attempt.userId,
@@ -11,8 +11,8 @@ import {
TbankClient,
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
CatalogDiscoveryRepository,
@@ -1,4 +1,3 @@
import type { TbankDividend } from '../../../../integrations/tbank/tbank.api';
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
import { CandleRepository } from '../infrastructure/candle.repository';
import { candleGaps, utcDay } from '../domain/candle-coverage';
@@ -14,11 +13,6 @@ import {
} from '../../../../common/time';
import { MoexClient } from '../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
import type {
TbankCandle,
TbankFundamentals,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types';
import { previousClose, timestamp } from './instrument-market';
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
@@ -29,10 +23,14 @@ import {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankBond,
type TbankCandle,
type TbankCoupon,
type TbankDividend,
type TbankFundamentals,
type TbankLastPrice,
type TbankShare,
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankLastPrice } from '../../../../integrations/tbank/tbank-market.types';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
CatalogDiscoveryRepository,
@@ -1,5 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { ServiceUnavailableException } from '@nestjs/common';
import type {
TbankCandle,
TbankTimestamp,
@@ -36,16 +35,3 @@ export function previousClose(
null
);
}
export async function optional<T>(
operation: () => Promise<T>,
name: string,
unavailable: string[],
): Promise<T | null> {
try {
return await operation();
} catch (error) {
if (!(error instanceof ServiceUnavailableException)) throw error;
unavailable.push(name);
return null;
}
}
@@ -1,8 +1,8 @@
import {
PortfolioDto,
PortfolioPositionDto,
} from '../../../portfolio/presentation/portfolio.dto';
import { stockPosition } from './stock-position';
} from '../../portfolio/presentation/portfolio.dto';
import { instrumentPosition } from './instrument-position';
const money = (value: string, currency = 'RUB') => ({ value, currency });
const row = (
@@ -34,13 +34,14 @@ const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
describe('stock position', () => {
it('weights costs exactly and keeps preferred shares separate', () => {
const result = stockPosition(
const result = instrumentPosition(
portfolio([
row(),
row('3', '20', '70'),
row('100', '1', '100', 'preferred'),
]),
'common',
'STOCK',
);
expect(result.quantity).toBe('5');
expect(result.averagePurchasePrice).toEqual(money('16'));
@@ -49,14 +50,18 @@ describe('stock position', () => {
expect(result.returnPercent).toBe('25');
expect(result.accounts[0].returnPercent).toBe('50');
});
it('does not round large amounts through floating point', () => {
const result = stockPosition(
portfolio([row('9007199254740993', '0.1', '900719925474099.3')]),
'common',
);
expect(result.quantity).toBe('9007199254740993');
expect(result.averagePurchasePrice?.value).toBe('0.1');
});
it.each(['9007199254740993', '0.25'])(
'preserves exact quantity %s',
(quantity) => {
const result = instrumentPosition(
portfolio([row(quantity, '0.1', '900719925474099.3')]),
'common',
'STOCK',
);
expect(result.quantity).toBe(quantity);
expect(result.averagePurchasePrice?.value).toBe('0.1');
},
);
it('does not sum currencies or treat unknown values as zero', () => {
const second = {
...row(),
@@ -64,7 +69,11 @@ describe('stock position', () => {
averagePurchasePrice: null,
absoluteReturn: null,
};
const result = stockPosition(portfolio([row(), second]), 'common');
const result = instrumentPosition(
portfolio([row(), second]),
'common',
'STOCK',
);
expect(result.quantity).toBe('4');
expect(result.currentValue).toBeNull();
expect(result.averagePurchasePrice).toBeNull();
@@ -72,14 +81,14 @@ describe('stock position', () => {
expect(result.returnPercent).toBeNull();
});
it('keeps no-position and offsetting long/short holdings honest', () => {
expect(stockPosition(portfolio([]), 'common')).toMatchObject({
expect(instrumentPosition(portfolio([]), 'common', 'STOCK')).toMatchObject({
quantity: '0',
accounts: [],
currentValue: null,
returnPercent: null,
});
expect(
stockPosition(portfolio([row(), row('-2')]), 'common')
instrumentPosition(portfolio([row(), row('-2')]), 'common', 'STOCK')
.averagePurchasePrice,
).toBeNull();
});
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { createHash } from 'node:crypto';
import type { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
import {
confirmedCandleCoverage,
type CandleRange,
@@ -78,8 +78,37 @@ export class CandleRepository {
previous?.state === 'running' &&
previous.processRunId === runId &&
active.has(previous.attemptId)
)
return { work: previous, launch: false };
) {
const previousIntent = previous.intent as Prisma.JsonObject;
const previousNow =
typeof previousIntent.now === 'string' ? previousIntent.now : '';
const nextPriority = Math.min(previous.priority, priority);
if (
nextPriority === previous.priority &&
(!intent.force ||
(previousIntent.force === true &&
(!intent.now || intent.now <= previousNow)))
)
return { work: previous, launch: false };
const work = await tx.tbankSyncWork.update({
where: { id: previous.id },
data: {
priority: nextPriority,
intent: {
...previousIntent,
...(intent.force
? {
force: true,
...(intent.now && intent.now > previousNow
? { now: intent.now }
: {}),
}
: {}),
},
},
});
return { work, launch: false };
}
if (previous)
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
const data = {
@@ -3,10 +3,10 @@ import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import type { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import type {
TbankBond,
TbankShare,
TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
export type CatalogQuery = {
@@ -1,5 +0,0 @@
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
import { instrumentPosition } from '../../application/instrument-position';
export function stockPosition(portfolio: PortfolioDto, uid: string) {
return instrumentPosition(portfolio, uid, 'STOCK');
}