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
+153 -5
View File
@@ -10,6 +10,7 @@ import { PrismaService } from '../src/prisma/prisma.service';
import {
TbankClient,
TbankTokenService,
tbankSourceTtlMs,
} from '../src/integrations/tbank/tbank.api';
import { MoexClient } from '../src/integrations/moex/moex.api';
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
@@ -423,6 +424,95 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
expect(moex.bondEvents).not.toHaveBeenCalled();
});
it.each([
['stocks', shareUid, 'dividends', 'STOCK'],
['bonds', bondUid, 'coupons', 'BOND'],
])(
'ages the saved %s payout position without RPC or a financial revision, including incompatible fresh candidates',
async (kind, uid, payout, type) => {
const date = new Date(Date.now() + 7 * 86400000)
.toISOString()
.slice(0, 10);
upstream.getDividends.mockResolvedValue([
{
recordDate: time(date),
dividendNet: { units: '10', nano: 1, currency: 'rub' },
},
]);
upstream.getBondCoupons.mockResolvedValue([
{
couponDate: time(date),
fixDate: time(date),
payOneBond: { units: '2', currency: 'rub' },
},
]);
await portfolio([position(uid, '3', type)]);
const path = `${kind}/${uid}/${payout}`;
const read = async () =>
(await get(`${path}?sync=observe`).expect(200)).body as
StockDividendsResourceDto | BondCouponsResourceDto;
await get(path).expect(200);
await settled();
const before = await read();
if (!before.data?.positionAt)
throw new Error('Expected a saved position');
expect(before.data.positionIsStale).toBe(false);
const saved = await prisma.tbankForecast.findFirstOrThrow();
const expiresAt =
Date.parse(before.data.positionAt) + tbankSourceTtlMs.portfolio;
const clock = jest.spyOn(Date, 'now').mockReturnValue(expiresAt - 1);
jest.clearAllMocks();
try {
expect((await read()).data).toEqual(before.data);
clock.mockReturnValue(expiresAt);
const aged = await read();
expect(aged.data).toEqual({ ...before.data, positionIsStale: true });
expect(aged.meta.freshness).toBe('stale');
expect(aged.meta.dataRevision).toBe(before.meta.dataRevision);
expect(aged.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved);
await portfolio([position(uid, '9', type)]);
await prisma.tbankPortfolio.updateMany({
where: { userId: 'owner' },
data: { fetchedAt: new Date(expiresAt) },
});
const repository = app.get(ForecastRepository);
const resolve = repository.resolve.bind(repository);
const publish = jest
.spyOn(repository, 'resolve')
.mockImplementationOnce(async (input) => {
expect(input.candidate).toMatchObject({
positionAt: new Date(expiresAt).toISOString(),
positionIsStale: false,
});
await prisma.tbankPortfolio.updateMany({
where: { userId: 'owner' },
data: { dataRevision: { increment: 1 } },
});
return resolve(input);
});
try {
const fallback = await read();
expect(fallback.data).toEqual(aged.data);
expect(fallback.meta.freshness).toBe('stale');
expect(fallback.meta.dataRevision).toBe(before.meta.dataRevision);
expect(fallback.meta.lastSuccessfulAt).toBe(
before.meta.lastSuccessfulAt,
);
expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved);
} finally {
publish.mockRestore();
}
for (const rpc of Object.values(upstream))
expect(rpc).not.toHaveBeenCalled();
expect(moex.bondEvents).not.toHaveBeenCalled();
} finally {
clock.mockRestore();
}
},
);
it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => {
upstream.getBond.mockRejectedValue(new Error('metadata unavailable'));
await portfolio([position(bondUid, '3', 'BOND')]);
@@ -436,14 +526,72 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
});
it('does not turn a missing MOEX block into a complete empty forecast and defaults to a 30-day window', async () => {
it.each([
['missing block', { coupons: undefined }],
[
'invalid cursor',
{ 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } },
],
])(
'preserves the saved calendar and forecast when real MOEX parsing rejects %s',
async (_name, invalid) => {
await portfolio([position(bondUid, '3', 'BOND')]);
const response = {
coupons: {
columns: ['coupondate', 'value', 'faceunit'],
data: [['2099-02-01', '2', 'RUB']],
},
offers: { columns: ['offerdate'], data: [] },
description: { columns: ['name', 'value'], data: [] },
};
const fetchMock = jest
.spyOn(global, 'fetch')
.mockImplementation(() => Promise.resolve(Response.json(response)));
moex.bondEvents.mockImplementation((ticker: string) =>
new MoexClient().bondEvents(ticker),
);
try {
await events(false).expect(200);
await settled();
const before = (await events().expect(200))
.body as UpcomingEventsResourceDto;
expect(before.data?.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '6' },
]);
const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({
where: { kind: 'bond-events' },
});
const forecast = await prisma.tbankForecast.findFirstOrThrow();
fetchMock.mockImplementation(() =>
Promise.resolve(Response.json({ ...response, ...invalid })),
);
await app
.get(CatalogEnrichmentService)
.requestMany('owner', 'owner-revision', 'bond', [bondUid], true, [
'bond-events',
]);
await settled();
const after = (await events().expect(200))
.body as UpcomingEventsResourceDto;
expect(after.data).toEqual(before.data);
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
expect(after.meta.sync.state).toBe('failed');
expect(
await prisma.tbankInstrumentSource.findFirstOrThrow({
where: { kind: 'bond-events' },
}),
).toEqual(calendar);
expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(forecast);
} finally {
fetchMock.mockRestore();
}
},
);
it('defaults to a 30-day window', async () => {
await portfolio([position(bondUid, '3', 'BOND')]);
moex.bondEvents.mockRejectedValueOnce(new Error('missing MOEX block'));
await events(false).expect(200);
await settled();
expect(
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
).toBeNull();
await get('brokerage-accounts/account/events?from=2099-02-01').expect(200);
await settled();
const result = (